chore: Spring AI 重构
This commit is contained in:
+264
-62
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
# s01: The Agent Loop
|
||||
# s01: The Agent Loop (エージェントループ)
|
||||
|
||||
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 問題
|
||||
|
||||
言語モデルはコードについて推論できるが、現実世界に触れられない。ファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびにユーザーが手動で結果をコピーペーストする必要がある。つまりユーザー自身がループになる。
|
||||
言語モデルはコードについて推論できるが、現実世界に触れられない -- ファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびに手動で結果を貼り戻す必要がある。あなた自身がそのループになる。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -20,97 +20,299 @@
|
||||
^ |
|
||||
| tool_result |
|
||||
+----------------+
|
||||
(loop until stop_reason != "tool_use")
|
||||
(ChatClient.call() がツール呼び出しがなくなるまで自動ループ)
|
||||
```
|
||||
|
||||
1つの終了条件がフロー全体を制御する。モデルがツール呼び出しを止めるまでループが回り続ける。
|
||||
1つの `call()` 呼び出しがフロー全体を制御する。Spring AI が自動的にループし、モデルがツール呼び出しを止めるまで続ける。
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. ユーザーのプロンプトが最初のメッセージになる。
|
||||
### 1. ChatClient の構築:モデル注入 + ツール登録
|
||||
|
||||
```python
|
||||
messages.append({"role": "user", "content": query})
|
||||
Spring Boot の自動設定で `ChatModel` を注入し、`ChatClient.builder()` でクライアントを構築、システムプロンプトとツールを設定する。
|
||||
|
||||
```java
|
||||
// TIP: Python 版ではモジュールレベルで client = Anthropic() と MODEL を作成。
|
||||
// Spring AI は自動設定で ChatModel を注入し、builder で ChatClient を構築する。
|
||||
public S01AgentLoop(ChatModel chatModel) {
|
||||
this.chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
|
||||
+ ". Use bash to solve tasks. Act, don't explain.")
|
||||
.defaultTools(new BashTool()) // @Tool アノテーション付きツールオブジェクト
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
2. メッセージとツール定義をLLMに送信する。
|
||||
### 2. `@Tool` アノテーション:宣言的ツール登録
|
||||
|
||||
```python
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
Spring AI は `@Tool` アノテーションでツールを自動的に検出・登録する。起動時にフレームワークが `defaultTools()` に渡されたオブジェクトをスキャンし、すべての `@Tool` メソッドのシグネチャと説明を抽出し、LLM が必要とするツールスキーマ(名前、パラメータ、説明)を生成して、毎回の `call()` リクエストに自動的に含める。
|
||||
|
||||
```java
|
||||
// BashTool -- Python 版の run_bash() 関数に相当
|
||||
public class BashTool {
|
||||
@Tool(description = "Run a shell command and return stdout + stderr")
|
||||
public String bash(@ToolParam(description = "The shell command to execute")
|
||||
String command) {
|
||||
// 危険コマンドチェック + ProcessBuilder 実行 + タイムアウト制御 + 出力切り詰め
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. アシスタントのレスポンスを追加し、`stop_reason`を確認する。ツールが呼ばれなければ終了。
|
||||
> Python の手動登録方式との比較:
|
||||
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
|
||||
> - Java: `@Tool` + `@ToolParam` アノテーションだけで、フレームワークがスキーマ生成とメソッドディスパッチを自動化
|
||||
|
||||
### 3. Spring AI 内部自動ループ:`call()` の内部実装
|
||||
|
||||
**これが Java 版と Python 版の最も重要な違いだ。** Python 版ではツール呼び出しを駆動するために手書きの while ループが必要:
|
||||
|
||||
```python
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
```
|
||||
|
||||
4. 各ツール呼び出しを実行し、結果を収集してuserメッセージとして追加。ステップ2に戻る。
|
||||
|
||||
```python
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
output = run_bash(block.input["command"])
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": output,
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
```
|
||||
|
||||
1つの関数にまとめると:
|
||||
|
||||
```python
|
||||
def agent_loop(query):
|
||||
messages = [{"role": "user", "content": query}]
|
||||
# Python 版 -- 手動ループ
|
||||
def agent_loop(messages):
|
||||
while True:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SYSTEM, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000,
|
||||
)
|
||||
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
|
||||
# assistant メッセージを収集
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
|
||||
results = []
|
||||
return response # モデルがツールを呼ばなくなった、ループ終了
|
||||
# ツールを実行して結果を返送
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
output = run_bash(block.input["command"])
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": output,
|
||||
})
|
||||
messages.append({"role": "user", "content": results})
|
||||
result = TOOL_HANDLERS[block.name](block.input)
|
||||
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
|
||||
```
|
||||
|
||||
これでエージェント全体が30行未満に収まる。本コースの残りはすべてこのループの上に積み重なる -- ループ自体は変わらない。
|
||||
Spring AI の `ChatClient.call()` は**完全に等価なロジックを内部にカプセル化**している:
|
||||
|
||||
```
|
||||
call() 内部フロー:
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 1. リクエスト組み立て: system prompt + user msg + tools │
|
||||
│ 2. LLM に送信 │
|
||||
│ 3. レスポンス解析 │
|
||||
│ ├── tool_use あり? ──→ はい: │
|
||||
│ │ a. ツール名と引数を抽出 │
|
||||
│ │ b. リフレクションで対応する @Tool メソッドを呼出 │
|
||||
│ │ c. tool_result をメッセージリストに追加 │
|
||||
│ │ d. ステップ 2 に戻る(自動ループ) │
|
||||
│ └── いいえ ──→ 最終テキストを返す │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
キーポイント:
|
||||
- **ツール検出**: Spring AI はレスポンスに `tool_use` タイプのコンテンツブロックがあるかチェック(Python の `stop_reason == "tool_use"` に相当)
|
||||
- **リフレクションディスパッチ**: フレームワークが Java リフレクションで、LLM が返したツール名に対応する `@Tool` メソッドを見つけて呼び出す(Python の `TOOL_HANDLERS[block.name]` に相当)
|
||||
- **結果返送**: ツール実行結果は自動的に `tool_result` メッセージとして会話に追加(Python が手動で `tool_result` コンテンツブロックを構築するのに相当)
|
||||
- **ループ終了**: モデルが純粋なテキスト(ツール呼び出しなし)を返すと、`call()` が最終結果を返す
|
||||
|
||||
従って、Python 版の約15行の while ループは、Java 版では1行の `.call()` に凝縮される。
|
||||
|
||||
### 4. `AgentRunner.interactive()`:REPL インタラクションループ
|
||||
|
||||
`AgentRunner` は全レッスン共通の REPL(Read-Eval-Print Loop)ユーティリティクラスで、Python の `if __name__ == "__main__"` 内の `input()` ループに相当する。
|
||||
|
||||
```java
|
||||
public class AgentRunner {
|
||||
/**
|
||||
* インタラクティブ REPL ループを開始。
|
||||
* @param prefix プロンプトプレフィックス(例: "s01")
|
||||
* @param handler ユーザー入力を処理し Agent レスポンスを返す関数
|
||||
*/
|
||||
public static void interactive(String prefix, Function<String, String> handler) {
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
System.out.println("'q' または 'exit' で終了");
|
||||
while (true) {
|
||||
System.out.print("\033[36m" + prefix + " >> \033[0m"); // カラープロンプト
|
||||
String input;
|
||||
try {
|
||||
if (!scanner.hasNextLine()) break;
|
||||
input = scanner.nextLine().trim();
|
||||
} catch (Exception e) {
|
||||
break;
|
||||
}
|
||||
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
String response = handler.apply(input); // Agent ハンドラーを呼び出し
|
||||
if (response != null && !response.isBlank()) {
|
||||
System.out.println(response);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error: " + e.getMessage());
|
||||
}
|
||||
System.out.println();
|
||||
}
|
||||
System.out.println("Bye!");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
ワークフロー:`Scanner` で入力読み取り → `handler.apply()` で Agent に送信 → レスポンス出力 → ループ。`handler` は関数型インターフェースで、各レッスンが自分の Agent 呼び出しロジックを渡す。
|
||||
|
||||
### 5. 完全な Agent クラスとして組み立て
|
||||
|
||||
```java
|
||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
||||
public class S01AgentLoop implements CommandLineRunner {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
|
||||
public S01AgentLoop(ChatModel chatModel) {
|
||||
this.chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("You are a coding agent at ...")
|
||||
.defaultTools(new BashTool())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
AgentRunner.interactive("s01", userMessage ->
|
||||
chatClient.prompt()
|
||||
.user(userMessage)
|
||||
.call() // ← この1つの呼び出し = Python の while ループ全体
|
||||
.content()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **TIPS — Python → Java 主要な適応ポイント:**
|
||||
> - Python の `while True` + `stop_reason` 手動ループ → Spring AI `ChatClient.call()` 内蔵自動ループ
|
||||
> - Python の `TOOLS` 配列 + `TOOL_HANDLERS` 辞書 → `@Tool` アノテーション + `defaultTools()` 自動登録とリフレクションディスパッチ
|
||||
> - Python の `client = Anthropic()` → Spring Boot 自動設定で `ChatModel` を注入
|
||||
> - Python の `input()` インタラクション → `AgentRunner.interactive()` が Scanner REPL + 関数型インターフェースをカプセル化
|
||||
|
||||
コアコード40行未満、これがエージェント全体だ。残り11章はすべてこのループの上にメカニズムを積み重ねる -- ループ自体は決して変わらない。
|
||||
|
||||
## 変更点
|
||||
|
||||
| Component | Before | After |
|
||||
|---------------|------------|--------------------------------|
|
||||
| Agent loop | (none) | `while True` + stop_reason |
|
||||
| Tools | (none) | `bash` (one tool) |
|
||||
| Messages | (none) | Accumulating list |
|
||||
| Control flow | (none) | `stop_reason != "tool_use"` |
|
||||
| コンポーネント | 変更前 | 変更後 |
|
||||
|---------------|------------|--------------------------------------------------|
|
||||
| Agent loop | (なし) | `ChatClient.call()` 内蔵ツールループ |
|
||||
| Tools | (なし) | `BashTool` (単一の `@Tool` ツール) |
|
||||
| Messages | (なし) | Spring AI が内部でメッセージリストを管理 |
|
||||
| Control flow | (なし) | フレームワークが自動判定: ツール呼び出しなしで最終テキストを返す |
|
||||
|
||||
```java
|
||||
// コアコード -- 構築 + 呼び出し
|
||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("You are a coding agent ...")
|
||||
.defaultTools(new BashTool())
|
||||
.build();
|
||||
|
||||
AgentRunner.interactive("s01", userMessage ->
|
||||
chatClient.prompt().user(userMessage).call().content()
|
||||
);
|
||||
```
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s01_agent_loop.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
|
||||
```
|
||||
|
||||
1. `Create a file called hello.py that prints "Hello, World!"`
|
||||
2. `List all Python files in this directory`
|
||||
> 実行前に環境変数の設定が必要: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
||||
>
|
||||
> **デフォルトプロトコルは OpenAI**(OpenAI 公式、Azure OpenAI、OpenAI 互換インターフェースを提供するサードパーティモデルサービスなど、すべての OpenAI API 形式のサービスに対応)。
|
||||
> Anthropic プロトコル(Claude ネイティブ API)を使用する場合は、以下のセクションを展開してください。
|
||||
|
||||
<details>
|
||||
<summary><strong>AI プロトコルの切り替え(OpenAI ↔ Anthropic)</strong></summary>
|
||||
|
||||
このプロジェクトは **Spring AI の Starter 依存 + 設定ファイル** で基盤プロトコルを切り替える。Java ビジネスコード(`ChatModel`、`ChatClient`)は**変更不要**。
|
||||
|
||||
#### 方式 1:OpenAI プロトコル(デフォルト)
|
||||
|
||||
`pom.xml` の依存:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
`application.yml` の設定:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
ai:
|
||||
openai:
|
||||
api-key: ${AI_API_KEY:sk-xxx}
|
||||
base-url: ${AI_BASE_URL:https://api.openai.com}
|
||||
chat:
|
||||
options:
|
||||
model: ${AI_MODEL:gpt-4o}
|
||||
```
|
||||
|
||||
環境変数の例:
|
||||
|
||||
```sh
|
||||
export AI_API_KEY=sk-proj-xxxxxxxx
|
||||
export AI_BASE_URL=https://api.openai.com # 任意の OpenAI 互換エンドポイントに変更可
|
||||
export AI_MODEL=gpt-4o
|
||||
```
|
||||
|
||||
> **TIP**: 多くのサードパーティモデルサービス(DeepSeek、Mistral、Qwen など)が OpenAI 互換 API を提供している。`AI_BASE_URL` と `AI_MODEL` を変更するだけで接続でき、プロトコル切り替えは不要。
|
||||
|
||||
#### 方式 2:Anthropic プロトコル(Claude ネイティブ API)
|
||||
|
||||
**ステップ 1**:`pom.xml` を編集 — OpenAI starter を Anthropic starter に置き換え:
|
||||
|
||||
```xml
|
||||
<!-- OpenAI starter をコメントアウトまたは削除 -->
|
||||
<!-- <dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency> -->
|
||||
|
||||
<!-- Anthropic starter を追加 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-anthropic</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
**ステップ 2**:`application.yml` を編集 — `spring.ai.openai` を `spring.ai.anthropic` に置き換え:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
ai:
|
||||
anthropic:
|
||||
api-key: ${AI_API_KEY}
|
||||
base-url: ${AI_BASE_URL:https://api.anthropic.com}
|
||||
chat:
|
||||
options:
|
||||
model: ${AI_MODEL:claude-sonnet-4-20250514}
|
||||
```
|
||||
|
||||
**ステップ 3**:環境変数を設定:
|
||||
|
||||
```sh
|
||||
export AI_API_KEY=sk-ant-xxxxxxxx
|
||||
export AI_BASE_URL=https://api.anthropic.com
|
||||
export AI_MODEL=claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
#### 切り替えの仕組み
|
||||
|
||||
Spring AI の `ChatModel` は統一された抽象インターフェース。異なる Starter が異なる実装を提供する:
|
||||
|
||||
| Starter 依存 | 自動注入される ChatModel 実装 | 設定プレフィックス |
|
||||
|---|---|---|
|
||||
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
|
||||
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
|
||||
|
||||
ビジネスコードは常に `ChatModel` インターフェースに対してプログラムする。プロトコル切り替えには依存と設定の変更だけが必要で、Java コードの変更は不要。
|
||||
|
||||
</details>
|
||||
|
||||
以下のプロンプトを試してみよう(英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Create a file called Hello.java that prints "Hello, World!"`
|
||||
2. `List all Java files in this directory`
|
||||
3. `What is the current git branch?`
|
||||
4. `Create a directory called test_output and write 3 files in it`
|
||||
|
||||
+102
-62
@@ -1,99 +1,139 @@
|
||||
# s02: Tool Use
|
||||
# s02: Tool Use (ツール使用)
|
||||
|
||||
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
||||
|
||||
> *"ツールを足すなら、ハンドラーを1つ足すだけ"* -- ループは変わらない。新ツールは dispatch map に登録するだけ。
|
||||
> *"ツールを足すなら、@Tool メソッドを1つ足すだけ"* -- ループは変わらない。新ツールは `defaultTools()` に渡すだけ。
|
||||
>
|
||||
> **Harness 層**: ツール分配 -- モデルが届く範囲を広げる。
|
||||
|
||||
## 問題
|
||||
|
||||
`bash`だけでは、エージェントは何でもシェル経由で行う。`cat`は予測不能に切り詰め、`sed`は特殊文字で壊れ、すべてのbash呼び出しが制約のないセキュリティ面になる。`read_file`や`write_file`のような専用ツールなら、ツールレベルでパスのサンドボックス化を強制できる。
|
||||
`bash` だけでは、すべての操作がシェル経由になる。`cat` は予測不能に切り詰め、`sed` は特殊文字で壊れ、すべての bash 呼び出しが制約のないセキュリティ面になる。専用ツール (`read_file`, `write_file`) ならツールレベルでパスのサンドボックス化を強制できる。
|
||||
|
||||
重要な点: ツールを追加してもループの変更は不要。
|
||||
重要な洞察: ツールを追加してもループの変更は不要。
|
||||
|
||||
## 解決策
|
||||
|
||||
```
|
||||
+--------+ +-------+ +------------------+
|
||||
| User | ---> | LLM | ---> | Tool Dispatch |
|
||||
| prompt | | | | { |
|
||||
+--------+ +---+---+ | bash: run_bash |
|
||||
^ | read: run_read |
|
||||
| | write: run_wr |
|
||||
+-----------+ edit: run_edit |
|
||||
tool_result | } |
|
||||
+------------------+
|
||||
+--------+ +-------+ +--------------------+
|
||||
| User | ---> | LLM | ---> | defaultTools() |
|
||||
| prompt | | | | { |
|
||||
+--------+ +---+---+ | BashTool |
|
||||
^ | ReadFileTool |
|
||||
| | WriteFileTool |
|
||||
+-----------+ EditFileTool |
|
||||
tool_result | } |
|
||||
+--------------------+
|
||||
|
||||
The dispatch map is a dict: {tool_name: handler_function}.
|
||||
One lookup replaces any if/elif chain.
|
||||
Spring AI が @Tool アノテーションで自動的に登録・分配する。
|
||||
手書きの dispatch map は不要、フレームワークがツールオブジェクトのアノテーションメソッドをスキャンする。
|
||||
```
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. 各ツールにハンドラ関数を定義する。パスのサンドボックス化でワークスペース外への脱出を防ぐ。
|
||||
1. 各ツールは独立したクラスで、`@Tool` アノテーションで宣言する。`PathValidator` がパスサンドボックスでワークスペース外への脱出を防ぐ。
|
||||
|
||||
```python
|
||||
def safe_path(p: str) -> Path:
|
||||
path = (WORKDIR / p).resolve()
|
||||
if not path.is_relative_to(WORKDIR):
|
||||
raise ValueError(f"Path escapes workspace: {p}")
|
||||
return path
|
||||
```java
|
||||
// PathValidator -- Python 版の safe_path() 関数に相当
|
||||
public class PathValidator {
|
||||
private final Path workDir;
|
||||
|
||||
def run_read(path: str, limit: int = None) -> str:
|
||||
text = safe_path(path).read_text()
|
||||
lines = text.splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit]
|
||||
return "\n".join(lines)[:50000]
|
||||
```
|
||||
public Path resolve(String relativePath) {
|
||||
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
|
||||
if (!resolved.startsWith(workDir)) {
|
||||
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
2. ディスパッチマップがツール名とハンドラを結びつける。
|
||||
// ReadFileTool -- Python 版の run_read() 関数に相当
|
||||
public class ReadFileTool {
|
||||
private final PathValidator pathValidator;
|
||||
|
||||
```python
|
||||
TOOL_HANDLERS = {
|
||||
"bash": lambda **kw: run_bash(kw["command"]),
|
||||
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
|
||||
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
||||
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
|
||||
kw["new_text"]),
|
||||
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
|
||||
public String readFile(
|
||||
@ToolParam(description = "Relative path to the file") String path,
|
||||
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
|
||||
Path filePath = pathValidator.resolve(path);
|
||||
List<String> lines = Files.readAllLines(filePath);
|
||||
if (limit != null && limit > 0 && limit < lines.size()) {
|
||||
lines = lines.subList(0, limit);
|
||||
}
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. ループ内で名前によりハンドラをルックアップする。ループ本体はs01から不変。
|
||||
2. ツール登録は `defaultTools()` に渡すだけ。Spring AI が `@Tool` アノテーションメソッドをスキャンし、名前マッピングとパラメータバインディングを自動的に行う。
|
||||
|
||||
```python
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
output = handler(**block.input) if handler \
|
||||
else f"Unknown tool: {block.name}"
|
||||
results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": output,
|
||||
})
|
||||
```java
|
||||
// Python 版の TOOL_HANDLERS 辞書に相当
|
||||
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
|
||||
// Java: ツールオブジェクトを渡すだけ、@Tool アノテーションで自動登録
|
||||
this.chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("You are a coding agent ...")
|
||||
.defaultTools(
|
||||
new BashTool(), // bash コマンド実行
|
||||
new ReadFileTool(), // ファイル読み取り
|
||||
new WriteFileTool(), // ファイル書き込み
|
||||
new EditFileTool() // ファイル編集(検索置換)
|
||||
)
|
||||
.build();
|
||||
```
|
||||
|
||||
ツール追加 = ハンドラ追加 + スキーマ追加。ループは決して変わらない。
|
||||
3. 呼び出しコードは s01 と完全に同一。ループはフレームワークが管理し、開発者はツール実装だけに集中する。
|
||||
|
||||
## s01からの変更点
|
||||
```java
|
||||
// s01 との違いは defaultTools() に3つのツールオブジェクトが追加されたこと
|
||||
// ループコードは完全に同一 -- これが s02 の核心的な洞察
|
||||
AgentRunner.interactive("s02", userMessage ->
|
||||
chatClient.prompt()
|
||||
.user(userMessage)
|
||||
.call()
|
||||
.content()
|
||||
);
|
||||
```
|
||||
|
||||
| Component | Before (s01) | After (s02) |
|
||||
|----------------|--------------------|----------------------------|
|
||||
| Tools | 1 (bash only) | 4 (bash, read, write, edit)|
|
||||
| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |
|
||||
| Path safety | None | `safe_path()` sandbox |
|
||||
| Agent loop | Unchanged | Unchanged |
|
||||
ツール追加 = `@Tool` クラスを1つ追加 + `defaultTools()` に渡す。ループは決して変わらない。
|
||||
|
||||
> **TIPS — Python → Java 主要な適応ポイント:**
|
||||
> - Python の `TOOL_HANDLERS` 辞書 → Spring AI `@Tool` アノテーション + `defaultTools()` 自動登録・分配
|
||||
> - Python の `safe_path()` 関数 → `PathValidator` クラス(同じパス脱出チェックロジック)
|
||||
> - Python の `lambda **kw` パラメータ展開 → `@ToolParam` アノテーションで自動バインディング
|
||||
> - Python の `block.type == "tool_use"` 判定 → Spring AI が内部で自動検出・分配
|
||||
|
||||
## s01 からの変更点
|
||||
|
||||
| コンポーネント | 変更前 (s01) | 変更後 (s02) |
|
||||
|----------------|-----------------------|----------------------------------------|
|
||||
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
|
||||
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
|
||||
| パス安全性 | なし | `PathValidator` サンドボックス |
|
||||
| Agent loop | 不変 | 不変 |
|
||||
|
||||
```java
|
||||
// s01 → s02 唯一の変更: defaultTools() に3つのツールオブジェクトを追加
|
||||
.defaultTools(
|
||||
new BashTool(),
|
||||
new ReadFileTool(), // +新規追加
|
||||
new WriteFileTool(), // +新規追加
|
||||
new EditFileTool() // +新規追加
|
||||
)
|
||||
```
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s02_tool_use.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
|
||||
```
|
||||
|
||||
1. `Read the file requirements.txt`
|
||||
2. `Create a file called greet.py with a greet(name) function`
|
||||
3. `Edit greet.py to add a docstring to the function`
|
||||
4. `Read greet.py to verify the edit worked`
|
||||
> 実行前に環境変数の設定が必要: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
||||
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Read the file pom.xml`
|
||||
2. `Create a file called Greet.java with a greet(name) method`
|
||||
3. `Edit Greet.java to add a Javadoc comment to the method`
|
||||
4. `Read Greet.java to verify the edit worked`
|
||||
|
||||
+70
-47
@@ -1,14 +1,14 @@
|
||||
# s03: TodoWrite
|
||||
# s03: TodoWrite (Todo書き込み)
|
||||
|
||||
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
||||
|
||||
> *"計画のないエージェントは行き当たりばったり"* -- まずステップを書き出し、それから実行。
|
||||
> *"計画のないエージェントは行き当たりばったり"* -- まずステップを書き出し、それから実行。完了率は倍増する。
|
||||
>
|
||||
> **Harness 層**: 計画 -- 航路を描かずにモデルを軌道に乗せる。
|
||||
|
||||
## 問題
|
||||
|
||||
マルチステップのタスクで、モデルは途中で迷子になる。作業を繰り返したり、ステップを飛ばしたり、脱線したりする。長い会話になるほど悪化する -- ツール結果がコンテキストを埋めるにつれ、システムプロンプトの影響力が薄れる。10ステップのリファクタリングでステップ1-3を完了した後、残りを忘れて即興を始めてしまう。
|
||||
マルチステップのタスクで、モデルは進捗を見失う -- 既にやったことを繰り返したり、ステップを飛ばしたり、脱線したりする。会話が長くなるほど悪化する: ツール結果がコンテキストを埋め尽くし、システムプロンプトの影響力が徐々に薄れる。10ステップのリファクタリングでステップ1-3を完了した後、即興を始めてしまう。ステップ4-10はもう注意の外だ。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -28,69 +28,92 @@
|
||||
| [x] task C |
|
||||
+-----------------------+
|
||||
|
|
||||
if rounds_since_todo >= 3:
|
||||
inject <reminder> into tool_result
|
||||
毎回のリクエスト時に defaultSystem() で
|
||||
最新の todo 状態をシステムプロンプトに注入
|
||||
```
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. TodoManagerはアイテムのリストをステータス付きで保持する。`in_progress`にできるのは同時に1つだけ。
|
||||
1. TodoManager はステータス付きアイテムを保持する。同時に `in_progress` にできるのは1つだけ。
|
||||
|
||||
```python
|
||||
class TodoManager:
|
||||
def update(self, items: list) -> str:
|
||||
validated, in_progress_count = [], 0
|
||||
for item in items:
|
||||
status = item.get("status", "pending")
|
||||
if status == "in_progress":
|
||||
in_progress_count += 1
|
||||
validated.append({"id": item["id"], "text": item["text"],
|
||||
"status": status})
|
||||
if in_progress_count > 1:
|
||||
raise ValueError("Only one task can be in_progress")
|
||||
self.items = validated
|
||||
return self.render()
|
||||
```
|
||||
```java
|
||||
public class TodoManager {
|
||||
|
||||
2. `todo`ツールは他のツールと同様にディスパッチマップに追加される。
|
||||
public record TodoItem(String id, String text, String status) {}
|
||||
|
||||
```python
|
||||
TOOL_HANDLERS = {
|
||||
# ...base tools...
|
||||
"todo": lambda **kw: TODO.update(kw["items"]),
|
||||
private List<TodoItem> items = new ArrayList<>();
|
||||
|
||||
@Tool(description = "Update the full task list to track progress. "
|
||||
+ "Each item must have id, text, status (pending/in_progress/completed). "
|
||||
+ "Only one task can be in_progress at a time. Max 20 items.")
|
||||
public String updateTodos(
|
||||
@ToolParam(description = "The complete list of todo items")
|
||||
List<TodoItem> items) {
|
||||
if (items.size() > 20) return "Error: Max 20 todos allowed";
|
||||
List<TodoItem> validated = new ArrayList<>();
|
||||
int inProgressCount = 0;
|
||||
for (TodoItem item : items) {
|
||||
String status = (item.status() != null)
|
||||
? item.status().toLowerCase() : "pending";
|
||||
if ("in_progress".equals(status)) inProgressCount++;
|
||||
validated.add(new TodoItem(item.id(), item.text().trim(), status));
|
||||
}
|
||||
if (inProgressCount > 1)
|
||||
return "Error: Only one task can be in_progress at a time";
|
||||
this.items = validated;
|
||||
return render();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. nagリマインダーが、モデルが3ラウンド以上`todo`を呼ばなかった場合にナッジを注入する。
|
||||
2. `TodoManager` は `defaultTools()` で登録し、`@Tool` アノテーションメソッドが自動的にツールとして公開される。
|
||||
|
||||
```python
|
||||
if rounds_since_todo >= 3 and messages:
|
||||
last = messages[-1]
|
||||
if last["role"] == "user" and isinstance(last.get("content"), list):
|
||||
last["content"].insert(0, {
|
||||
"type": "text",
|
||||
"text": "<reminder>Update your todos.</reminder>",
|
||||
})
|
||||
```java
|
||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem(system)
|
||||
.defaultTools(
|
||||
new BashTool(),
|
||||
new ReadFileTool(),
|
||||
new WriteFileTool(),
|
||||
new EditFileTool(),
|
||||
todoManager // @Tool アノテーションメソッドが自動登録
|
||||
)
|
||||
.build();
|
||||
```
|
||||
|
||||
「一度にin_progressは1つだけ」の制約が逐次的な集中を強制し、nagリマインダーが説明責任を生む。
|
||||
3. システムプロンプト注入: ユーザー入力のたびに、最新の todo 状態をシステムプロンプトに注入し、更新指示を強調する。
|
||||
|
||||
## s02からの変更点
|
||||
```java
|
||||
// 動的システムプロンプト: 現在の todo 状態を含む
|
||||
String system = "You are a coding agent at " + workDir + ".\n"
|
||||
+ "Use the todo tool to plan multi-step tasks. "
|
||||
+ "Mark in_progress before starting, completed when done.\n"
|
||||
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
|
||||
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
|
||||
```
|
||||
|
||||
| Component | Before (s02) | After (s03) |
|
||||
|----------------|------------------|----------------------------|
|
||||
| Tools | 4 | 5 (+todo) |
|
||||
| Planning | None | TodoManager with statuses |
|
||||
| Nag injection | None | `<reminder>` after 3 rounds|
|
||||
| Agent loop | Simple dispatch | + rounds_since_todo counter|
|
||||
「同時に in_progress は1つだけ」の制約が逐次的な集中を強制する。システムプロンプトへの todo 状態の継続的な注入が説明責任を生む -- モデルは毎回自分の計画を見るため、更新を忘れない。
|
||||
|
||||
> **TIP**: Python 版ではツールループ内で `rounds_since_todo` を追跡し、3ラウンド連続で todo を呼ばなかった場合に `<reminder>` テキストを注入する。Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内での注入はできない。そのため、システムプロンプト注入方式で同等の効果を実現している。
|
||||
|
||||
## s02 からの変更点
|
||||
|
||||
| コンポーネント | 変更前 (s02) | 変更後 (s03) |
|
||||
|----------------|------------------|--------------------------------------|
|
||||
| Tools | 4 | 5 (+TodoManager `@Tool`) |
|
||||
| 計画 | なし | ステータス付き TodoManager |
|
||||
| 状態注入 | なし | システムプロンプトに `<current-todos>` を注入 |
|
||||
| ChatClient | 固定システムプロンプト | 毎ターン再構築、動的に todo 状態を注入 |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s03_todo_write.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
|
||||
```
|
||||
|
||||
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
|
||||
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
|
||||
3. `Review all Python files and fix any style issues`
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
|
||||
2. `Create a Java package with utils and tests`
|
||||
3. `Review all Java files and fix any style issues`
|
||||
|
||||
+59
-51
@@ -1,4 +1,4 @@
|
||||
# s04: Subagents
|
||||
# s04: Subagents (サブエージェント)
|
||||
|
||||
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 問題
|
||||
|
||||
エージェントが作業するにつれ、messages配列は膨張し続ける。すべてのファイル読み取り、すべてのbash出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親に必要なのは「pytest」という答えだけだ。
|
||||
エージェントが作業するにつれ、messages 配列は膨張し続ける。すべてのファイル読み取り、すべてのコマンド出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親エージェントに必要なのは「pytest」という一言だけだ。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -28,67 +28,75 @@ Parent context stays clean. Subagent context is discarded.
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. 親に`task`ツールを追加する。子は`task`を除くすべての基本ツールを取得する(再帰的な生成は不可)。
|
||||
1. 親エージェントに `task` ツールを持たせる。子は `task` を除くすべての基本ツールを持つ(再帰的な生成は不可)。
|
||||
|
||||
```python
|
||||
PARENT_TOOLS = CHILD_TOOLS + [
|
||||
{"name": "task",
|
||||
"description": "Spawn a subagent with fresh context.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"prompt": {"type": "string"}},
|
||||
"required": ["prompt"],
|
||||
}},
|
||||
]
|
||||
```
|
||||
|
||||
2. サブエージェントは`messages=[]`で開始し、自身のループを実行する。最終テキストだけが親に返る。
|
||||
|
||||
```python
|
||||
def run_subagent(prompt: str) -> str:
|
||||
sub_messages = [{"role": "user", "content": prompt}]
|
||||
for _ in range(30): # safety limit
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=SUBAGENT_SYSTEM,
|
||||
messages=sub_messages,
|
||||
tools=CHILD_TOOLS, max_tokens=8000,
|
||||
```java
|
||||
// 親 Agent: 基本ツール + SubagentTool を持つ
|
||||
this.chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("You are a coding agent. "
|
||||
+ "Use the task tool to delegate subtasks.")
|
||||
.defaultTools(
|
||||
new BashTool(),
|
||||
new ReadFileTool(),
|
||||
new WriteFileTool(),
|
||||
new EditFileTool(),
|
||||
new SubagentTool(chatModel) // 親 Agent 専用
|
||||
)
|
||||
sub_messages.append({"role": "assistant",
|
||||
"content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
output = handler(**block.input)
|
||||
results.append({"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": str(output)[:50000]})
|
||||
sub_messages.append({"role": "user", "content": results})
|
||||
return "".join(
|
||||
b.text for b in response.content if hasattr(b, "text")
|
||||
) or "(no summary)"
|
||||
.build();
|
||||
```
|
||||
|
||||
子のメッセージ履歴全体(30回以上のツール呼び出し)は破棄される。親は1段落の要約を通常の`tool_result`として受け取る。
|
||||
2. サブエージェントは新しい `ChatClient` で起動し、独立したコンテキストを持つ。最終テキストだけが親に返る。
|
||||
|
||||
## s03からの変更点
|
||||
```java
|
||||
@Tool(description = "Spawn a subagent with fresh context. "
|
||||
+ "Use for exploration or subtasks that might pollute the main context.")
|
||||
public String task(
|
||||
@ToolParam(description = "The task prompt") String prompt,
|
||||
@ToolParam(description = "Short description", required = false)
|
||||
String description) {
|
||||
|
||||
| Component | Before (s03) | After (s04) |
|
||||
|----------------|------------------|---------------------------|
|
||||
| Tools | 5 | 5 (base) + task (parent) |
|
||||
| Context | Single shared | Parent + child isolation |
|
||||
| Subagent | None | `run_subagent()` function |
|
||||
| Return value | N/A | Summary text only |
|
||||
// 新しい ChatClient を作成 -- これが「コンテキスト隔離」のすべて
|
||||
ChatClient subClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("You are a coding subagent. "
|
||||
+ "Complete the task, then summarize findings.")
|
||||
.defaultTools( // 基本ツール、task なし(再帰防止)
|
||||
new BashTool(),
|
||||
new ReadFileTool(),
|
||||
new WriteFileTool(),
|
||||
new EditFileTool()
|
||||
)
|
||||
.build();
|
||||
|
||||
String result = subClient.prompt()
|
||||
.user(prompt)
|
||||
.call()
|
||||
.content();
|
||||
|
||||
// 最終テキストだけを返し、子 Agent のコンテキストは破棄
|
||||
return (result != null) ? result : "(no summary)";
|
||||
}
|
||||
```
|
||||
|
||||
サブエージェントは複数回のツール呼び出しを実行するかもしれないが、メッセージ履歴全体は破棄される。親が受け取るのは要約テキストだけで、通常の `tool_result` として返される。Spring AI の `ChatClient.call()` が内部でツールループを管理するため、手動でイテレーション回数を制限する必要はない。
|
||||
|
||||
## s03 からの変更点
|
||||
|
||||
| コンポーネント | 変更前 (s03) | 変更後 (s04) |
|
||||
|----------------|------------------|---------------------------------------|
|
||||
| Tools | 5 | 5 (基本) + SubagentTool (親側のみ) |
|
||||
| コンテキスト | 単一共有 | 親 + 子隔離 (独立した ChatClient) |
|
||||
| Subagent | なし | `SubagentTool.task()` メソッド |
|
||||
| 戻り値 | 該当なし | 要約テキストのみ |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s04_subagent.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
|
||||
```
|
||||
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Use a subtask to find what testing framework this project uses`
|
||||
2. `Delegate: read all .py files and summarize what each one does`
|
||||
2. `Delegate: read all .java files and summarize what each one does`
|
||||
3. `Use a task to create a new module, then verify it from here`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# s05: Skills
|
||||
# s05: Skills (スキルローディング)
|
||||
|
||||
`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 問題
|
||||
|
||||
エージェントにドメイン固有のワークフローを遵守させたい: gitの規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れると、使われないスキルにトークンを浪費する。10スキル x 2000トークン = 20,000トークン、ほとんどが任意のタスクに無関係だ。
|
||||
エージェントにドメイン固有のワークフローを遵守させたい: git の規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れるとトークンの浪費だ -- 10スキル x 2000トークン = 20,000トークン、大半が当面のタスクとは無関係。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -31,11 +31,11 @@ When model calls load_skill("git"):
|
||||
+--------------------------------------+
|
||||
```
|
||||
|
||||
第1層: スキル*名*をシステムプロンプトに(低コスト)。第2層: スキル*本体*をtool_resultに(オンデマンド)。
|
||||
第1層: スキル名をシステムプロンプトに(低コスト)。第2層: 完全なコンテンツを tool_result でオンデマンド配信。
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. 各スキルは `SKILL.md` ファイルを含むディレクトリとして配置される。
|
||||
1. 各スキルは `SKILL.md` ファイルを含むディレクトリで、YAML frontmatter 付き。
|
||||
|
||||
```
|
||||
skills/
|
||||
@@ -45,63 +45,110 @@ skills/
|
||||
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
|
||||
```
|
||||
|
||||
2. SkillLoaderが `SKILL.md` を再帰的に探索し、ディレクトリ名をスキル識別子として使用する。
|
||||
2. SkillLoader が `SKILL.md` を再帰的にスキャンし、ディレクトリ名をスキル識別子として使用する。
|
||||
|
||||
```python
|
||||
class SkillLoader:
|
||||
def __init__(self, skills_dir: Path):
|
||||
self.skills = {}
|
||||
for f in sorted(skills_dir.rglob("SKILL.md")):
|
||||
text = f.read_text()
|
||||
meta, body = self._parse_frontmatter(text)
|
||||
name = meta.get("name", f.parent.name)
|
||||
self.skills[name] = {"meta": meta, "body": body}
|
||||
```java
|
||||
public class SkillLoader {
|
||||
|
||||
def get_descriptions(self) -> str:
|
||||
lines = []
|
||||
for name, skill in self.skills.items():
|
||||
desc = skill["meta"].get("description", "")
|
||||
lines.append(f" - {name}: {desc}")
|
||||
return "\n".join(lines)
|
||||
private static final Pattern FRONTMATTER_PATTERN =
|
||||
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
|
||||
|
||||
def get_content(self, name: str) -> str:
|
||||
skill = self.skills.get(name)
|
||||
if not skill:
|
||||
return f"Error: Unknown skill '{name}'."
|
||||
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
|
||||
```
|
||||
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
|
||||
|
||||
3. 第1層はシステムプロンプトに配置。第2層は通常のツールハンドラ。
|
||||
record SkillInfo(Map<String, String> meta, String body, String path) {}
|
||||
|
||||
```python
|
||||
SYSTEM = f"""You are a coding agent at {WORKDIR}.
|
||||
Skills available:
|
||||
{SKILL_LOADER.get_descriptions()}"""
|
||||
public SkillLoader(Path skillsDir) {
|
||||
loadAll(skillsDir);
|
||||
}
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
# ...base tools...
|
||||
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
|
||||
/** skills ディレクトリ配下のすべての SKILL.md ファイルを再帰スキャン */
|
||||
private void loadAll(Path skillsDir) {
|
||||
if (!Files.exists(skillsDir)) return;
|
||||
try (Stream<Path> paths = Files.walk(skillsDir)) {
|
||||
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
|
||||
.sorted()
|
||||
.forEach(p -> {
|
||||
String text = Files.readString(p);
|
||||
var parsed = parseFrontmatter(text);
|
||||
String name = parsed.meta().getOrDefault("name",
|
||||
p.getParent().getFileName().toString());
|
||||
skills.put(name, new SkillInfo(
|
||||
parsed.meta(), parsed.body(), p.toString()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Layer 1: 全スキルの短い説明を取得(システムプロンプト注入用) */
|
||||
public String getDescriptions() {
|
||||
if (skills.isEmpty()) return "(no skills available)";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (var entry : skills.entrySet()) {
|
||||
String desc = entry.getValue().meta()
|
||||
.getOrDefault("description", "No description");
|
||||
sb.append(" - ").append(entry.getKey())
|
||||
.append(": ").append(desc).append("\n");
|
||||
}
|
||||
return sb.toString().stripTrailing();
|
||||
}
|
||||
|
||||
/** Layer 2: 指定スキルの完全なコンテンツを読み込む(@Tool メソッドとして) */
|
||||
@Tool(description = "Load specialized knowledge by name.")
|
||||
public String loadSkill(
|
||||
@ToolParam(description = "Skill name to load") String name) {
|
||||
SkillInfo skill = skills.get(name);
|
||||
if (skill == null)
|
||||
return "Error: Unknown skill '" + name + "'. Available: "
|
||||
+ String.join(", ", skills.keySet());
|
||||
return "<skill name=\"" + name + "\">\n"
|
||||
+ skill.body() + "\n</skill>";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
モデルはどのスキルが存在するかを知り(低コスト)、関連する時にだけ読み込む(高コスト)。
|
||||
3. 第1層はシステムプロンプトに配置。第2層は SkillLoader 上の `@Tool` アノテーションメソッドでオンデマンド読み込み。
|
||||
|
||||
## s04からの変更点
|
||||
```java
|
||||
public S05SkillLoading(ChatModel chatModel) {
|
||||
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
|
||||
SkillLoader skillLoader = new SkillLoader(skillsDir);
|
||||
|
||||
| Component | Before (s04) | After (s05) |
|
||||
|----------------|------------------|----------------------------|
|
||||
| Tools | 5 (base + task) | 5 (base + load_skill) |
|
||||
| System prompt | Static string | + skill descriptions |
|
||||
| Knowledge | None | skills/\*/SKILL.md files |
|
||||
| Injection | None | Two-layer (system + result)|
|
||||
// Layer 1: スキルメタデータをシステムプロンプトに注入
|
||||
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
|
||||
+ "Use loadSkill to access specialized knowledge.\n\n"
|
||||
+ "Skills available:\n"
|
||||
+ skillLoader.getDescriptions();
|
||||
|
||||
this.chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem(system)
|
||||
.defaultTools(
|
||||
new BashTool(), new ReadFileTool(),
|
||||
new WriteFileTool(), new EditFileTool(),
|
||||
skillLoader // Layer 2: loadSkill @Tool メソッド
|
||||
)
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
モデルはどのスキルが存在するかを知り(低コスト)、必要な時にだけ完全なコンテンツを読み込む(高コスト)。
|
||||
|
||||
## s04 からの変更点
|
||||
|
||||
| コンポーネント | 変更前 (s04) | 変更後 (s05) |
|
||||
|----------------|------------------|--------------------------------|
|
||||
| Tools | 5 (基本 + task) | 5 (基本 + load_skill) |
|
||||
| システムプロンプト | 静的文字列 | + スキル説明リスト |
|
||||
| 知識ベース | なし | skills/\*/SKILL.md ファイル |
|
||||
| 注入方式 | なし | 二層構造 (システムプロンプト + result) |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s05_skill_loading.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
|
||||
```
|
||||
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `What skills are available?`
|
||||
2. `Load the agent-builder skill and follow its instructions`
|
||||
3. `I need to do a code review -- load the relevant skill first`
|
||||
|
||||
+120
-60
@@ -1,4 +1,4 @@
|
||||
# s06: Context Compact
|
||||
# s06: Context Compact (コンテキスト圧縮)
|
||||
|
||||
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 問題
|
||||
|
||||
コンテキストウィンドウは有限だ。1000行のファイルに対する`read_file`1回で約4000トークンを消費する。30ファイルを読み20回のbashコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模コードベースで作業できない。
|
||||
コンテキストウィンドウは有限だ。1000行のファイルを読むだけで約4000トークンを消費する。30ファイルを読み20回のコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模プロジェクトで作業できない。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -44,82 +44,142 @@ continue [Layer 2: auto_compact]
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. **第1層 -- micro_compact**: 各LLM呼び出しの前に、古いツール結果をプレースホルダーに置換する。
|
||||
1. **第1層 -- コンテキストウィンドウ管理**: Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内に圧縮を挿入できない。Java 版では、システムプロンプトに注入する会話ターン数を制限し(最近の N ターンのみ保持)、コンテンツを切り詰めることで同等の効果を実現する。
|
||||
|
||||
```python
|
||||
def micro_compact(messages: list) -> list:
|
||||
tool_results = []
|
||||
for i, msg in enumerate(messages):
|
||||
if msg["role"] == "user" and isinstance(msg.get("content"), list):
|
||||
for j, part in enumerate(msg["content"]):
|
||||
if isinstance(part, dict) and part.get("type") == "tool_result":
|
||||
tool_results.append((i, j, part))
|
||||
if len(tool_results) <= KEEP_RECENT:
|
||||
return messages
|
||||
for _, _, part in tool_results[:-KEEP_RECENT]:
|
||||
if len(part.get("content", "")) > 100:
|
||||
part["content"] = f"[Previous: used {tool_name}]"
|
||||
return messages
|
||||
```java
|
||||
/** トークン数の推定: 粗い見積もりで 4文字 ≈ 1トークン */
|
||||
public int estimateTokens() {
|
||||
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
|
||||
return chars / 4;
|
||||
}
|
||||
|
||||
/** 会話履歴のサマリーを取得(システムプロンプト注入用、最近数ターンのみ保持) */
|
||||
public String getContextSummary() {
|
||||
if (history.isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
|
||||
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
|
||||
for (int i = start; i < history.size(); i++) {
|
||||
ConversationTurn turn = history.get(i);
|
||||
sb.append("[").append(turn.role()).append("]: ")
|
||||
.append(turn.content(), 0, Math.min(500, turn.content().length()))
|
||||
.append("\n");
|
||||
}
|
||||
sb.append("</conversation-context>");
|
||||
return sb.toString();
|
||||
}
|
||||
```
|
||||
|
||||
2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全なトランスクリプトをディスクに保存し、LLMに要約を依頼する。
|
||||
2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全な会話をディスクに保存し、LLM に要約を依頼する。
|
||||
|
||||
```python
|
||||
def auto_compact(messages: list) -> list:
|
||||
# Save transcript for recovery
|
||||
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
|
||||
with open(transcript_path, "w") as f:
|
||||
for msg in messages:
|
||||
f.write(json.dumps(msg, default=str) + "\n")
|
||||
# LLM summarizes
|
||||
response = client.messages.create(
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content":
|
||||
"Summarize this conversation for continuity..."
|
||||
+ json.dumps(messages, default=str)[:80000]}],
|
||||
max_tokens=2000,
|
||||
)
|
||||
return [
|
||||
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
|
||||
{"role": "assistant", "content": "Understood. Continuing."},
|
||||
]
|
||||
```java
|
||||
public String compact() {
|
||||
// トランスクリプトをディスクに保存(完全な履歴は失われない)
|
||||
Files.createDirectories(transcriptDir);
|
||||
Path transcriptPath = transcriptDir.resolve(
|
||||
"transcript_" + System.currentTimeMillis() + ".jsonl");
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
|
||||
for (ConversationTurn turn : history) {
|
||||
writer.write(objectMapper.writeValueAsString(turn));
|
||||
writer.newLine();
|
||||
}
|
||||
}
|
||||
|
||||
// LLM が要約を生成
|
||||
String conversationText = history.stream()
|
||||
.map(t -> t.role() + ": " + t.content())
|
||||
.reduce("", (a, b) -> a + "\n" + b);
|
||||
if (conversationText.length() > 80000) {
|
||||
conversationText = conversationText.substring(0, 80000);
|
||||
}
|
||||
|
||||
ChatClient summaryClient = ChatClient.builder(chatModel).build();
|
||||
String summary = summaryClient.prompt()
|
||||
.user("Summarize this conversation for continuity. Include: "
|
||||
+ "1) What was accomplished, 2) Current state, "
|
||||
+ "3) Key decisions.\n\n" + conversationText)
|
||||
.call().content();
|
||||
|
||||
// 要約で履歴を置換
|
||||
history.clear();
|
||||
history.add(new ConversationTurn("system",
|
||||
"[Conversation compressed. Transcript: " + transcriptPath
|
||||
+ "]\n\n" + summary));
|
||||
return summary;
|
||||
}
|
||||
```
|
||||
|
||||
3. **第3層 -- manual compact**: `compact`ツールが同じ要約処理をオンデマンドでトリガーする。
|
||||
3. **第3層 -- manual compact**: `CompactTool` ツールが同じ要約メカニズムをオンデマンドでトリガーする。
|
||||
|
||||
4. ループが3層すべてを統合する:
|
||||
```java
|
||||
public class CompactTool {
|
||||
private final ContextCompactor compactor;
|
||||
|
||||
```python
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
micro_compact(messages) # Layer 1
|
||||
if estimate_tokens(messages) > THRESHOLD:
|
||||
messages[:] = auto_compact(messages) # Layer 2
|
||||
response = client.messages.create(...)
|
||||
# ... tool execution ...
|
||||
if manual_compact:
|
||||
messages[:] = auto_compact(messages) # Layer 3
|
||||
public CompactTool(ContextCompactor compactor) {
|
||||
this.compactor = compactor;
|
||||
}
|
||||
|
||||
@Tool(description = "Trigger manual conversation compression to free up context space.")
|
||||
public String compact(
|
||||
@ToolParam(description = "What to preserve in summary",
|
||||
required = false) String focus) {
|
||||
compactor.requestCompact();
|
||||
return "Compression triggered. Context will be summarized.";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
トランスクリプトがディスク上に完全な履歴を保持する。何も真に失われず、アクティブなコンテキストの外に移動されるだけ。
|
||||
4. REPL 層が3層すべてを統合する(Spring AI の ChatClient が内部でツールループを自動管理するため、圧縮はユーザーメッセージレベルでトリガーされる):
|
||||
|
||||
## s05からの変更点
|
||||
```java
|
||||
AgentRunner.interactive("s06", userMessage -> {
|
||||
// Layer 2: 自動圧縮チェック(毎回のユーザー入力前)
|
||||
if (compactor.needsAutoCompact()) {
|
||||
System.out.println("[auto_compact triggered]");
|
||||
compactor.compact();
|
||||
}
|
||||
compactor.addTurn("user", userMessage);
|
||||
|
||||
| Component | Before (s05) | After (s06) |
|
||||
|----------------|------------------|----------------------------|
|
||||
| Tools | 5 | 5 (base + compact) |
|
||||
| Context mgmt | None | Three-layer compression |
|
||||
| Micro-compact | None | Old results -> placeholders|
|
||||
| Auto-compact | None | Token threshold trigger |
|
||||
| Transcripts | None | Saved to .transcripts/ |
|
||||
// 動的システムプロンプト: 会話コンテキストサマリーを含む
|
||||
String system = baseSystem + compactor.getContextSummary();
|
||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem(system)
|
||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
||||
new WriteFileTool(), new EditFileTool(), compactTool)
|
||||
.build();
|
||||
|
||||
String response = chatClient.prompt()
|
||||
.user(userMessage).call().content();
|
||||
compactor.addTurn("assistant", response != null ? response : "");
|
||||
|
||||
// Layer 3: 手動圧縮(Agent が compact ツールを呼び出した場合)
|
||||
if (compactor.isCompactRequested()) {
|
||||
compactor.compact();
|
||||
}
|
||||
return response;
|
||||
});
|
||||
```
|
||||
|
||||
完全な履歴はトランスクリプトとしてディスク上に保存される。情報は真に失われるのではなく、アクティブなコンテキストの外に移動されるだけだ。
|
||||
|
||||
## s05 からの変更点
|
||||
|
||||
| コンポーネント | 変更前 (s05) | 変更後 (s06) |
|
||||
|----------------|------------------|--------------------------------|
|
||||
| Tools | 5 | 5 (基本 + compact) |
|
||||
| コンテキスト管理 | なし | 三層圧縮 |
|
||||
| コンテキストウィンドウ管理 | なし | 注入ターン数制限 + コンテンツ切り詰め |
|
||||
| Auto-compact | なし | トークン閾値トリガー |
|
||||
| Transcripts | なし | .transcripts/ に保存 |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s06_context_compact.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
|
||||
```
|
||||
|
||||
1. `Read every Python file in the agents/ directory one by one` (micro-compactが古い結果を置換するのを観察する)
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Read every Java file in the src/ directory one by one` (コンテキストウィンドウ管理の効果を観察する)
|
||||
2. `Keep reading files until compression triggers automatically`
|
||||
3. `Use the compact tool to manually compress the conversation`
|
||||
|
||||
+103
-60
@@ -1,4 +1,4 @@
|
||||
# s07: Task System
|
||||
# s07: Task System (タスクシステム)
|
||||
|
||||
`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
|
||||
## 問題
|
||||
|
||||
s03のTodoManagerはメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスクBはタスクAに依存し、タスクCとDは並行実行でき、タスクEはCとDの両方を待つ。
|
||||
s03 の TodoManager はメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスク B はタスク A に依存し、タスク C と D は並行実行でき、タスク E は C と D の両方を待つ。
|
||||
|
||||
明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮(s06)で消える。
|
||||
明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮 (s06) で消える。
|
||||
|
||||
## 解決策
|
||||
|
||||
フラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つのJSONファイルで、ステータス・前方依存(`blockedBy`)・後方依存(`blocks`)を持つ。タスクグラフは常に3つの問いに答える:
|
||||
フラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つの JSON ファイルで、ステータス・前方依存 (`blockedBy`)・後方依存 (`blocks`) を持つ。タスクグラフは常に3つの問いに答える:
|
||||
|
||||
- **何が実行可能か?** -- `pending`ステータスで`blockedBy`が空のタスク。
|
||||
- **何が実行可能か?** -- `pending` ステータスで `blockedBy` が空のタスク。
|
||||
- **何がブロックされているか?** -- 未完了の依存を待つタスク。
|
||||
- **何が完了したか?** -- `completed`のタスク。完了時に後続タスクを自動的にアンブロックする。
|
||||
- **何が完了したか?** -- `completed` のタスク。完了時に後続タスクを自動的にアンブロックする。
|
||||
|
||||
```
|
||||
.tasks/
|
||||
@@ -44,72 +44,113 @@ s03のTodoManagerはメモリ上のフラットなチェックリストに過ぎ
|
||||
ステータス: pending -> in_progress -> completed
|
||||
```
|
||||
|
||||
このタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行(s08)、マルチエージェントチーム(s09+)、worktree分離(s12)はすべてこの同じ構造を読み書きする。
|
||||
このタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行 (s08)、マルチエージェントチーム (s09+)、worktree 分離 (s12) はすべてこの同じ構造を読み書きする。
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. **TaskManager**: タスクごとに1つのJSONファイル、依存グラフ付きCRUD。
|
||||
1. **TaskManager**: タスクごとに1つの JSON ファイル、依存グラフ付き CRUD。Jackson `ObjectMapper` で JSON シリアライゼーションを行う。
|
||||
|
||||
```python
|
||||
class TaskManager:
|
||||
def __init__(self, tasks_dir: Path):
|
||||
self.dir = tasks_dir
|
||||
self.dir.mkdir(exist_ok=True)
|
||||
self._next_id = self._max_id() + 1
|
||||
```java
|
||||
public class TaskManager {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private final Path dir;
|
||||
private int nextId;
|
||||
|
||||
def create(self, subject, description=""):
|
||||
task = {"id": self._next_id, "subject": subject,
|
||||
"status": "pending", "blockedBy": [],
|
||||
"blocks": [], "owner": ""}
|
||||
self._save(task)
|
||||
self._next_id += 1
|
||||
return json.dumps(task, indent=2)
|
||||
```
|
||||
public TaskManager(Path tasksDir) {
|
||||
this.dir = tasksDir;
|
||||
Files.createDirectories(dir);
|
||||
this.nextId = maxId() + 1;
|
||||
}
|
||||
|
||||
2. **依存解除**: タスク完了時に、他タスクの`blockedBy`リストから完了IDを除去し、後続タスクをアンブロックする。
|
||||
|
||||
```python
|
||||
def _clear_dependency(self, completed_id):
|
||||
for f in self.dir.glob("task_*.json"):
|
||||
task = json.loads(f.read_text())
|
||||
if completed_id in task.get("blockedBy", []):
|
||||
task["blockedBy"].remove(completed_id)
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
3. **ステータス遷移 + 依存配線**: `update`がステータス変更と依存エッジを担う。
|
||||
|
||||
```python
|
||||
def update(self, task_id, status=None,
|
||||
add_blocked_by=None, add_blocks=None):
|
||||
task = self._load(task_id)
|
||||
if status:
|
||||
task["status"] = status
|
||||
if status == "completed":
|
||||
self._clear_dependency(task_id)
|
||||
self._save(task)
|
||||
```
|
||||
|
||||
4. 4つのタスクツールをディスパッチマップに追加する。
|
||||
|
||||
```python
|
||||
TOOL_HANDLERS = {
|
||||
# ...base tools...
|
||||
"task_create": lambda **kw: TASKS.create(kw["subject"]),
|
||||
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
|
||||
"task_list": lambda **kw: TASKS.list_all(),
|
||||
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
|
||||
@Tool(description = "Create a new task with subject and optional description")
|
||||
public String taskCreate(
|
||||
@ToolParam(description = "Short subject of the task") String subject,
|
||||
@ToolParam(description = "Detailed description", required = false) String description) {
|
||||
Map<String, Object> task = new LinkedHashMap<>();
|
||||
task.put("id", nextId);
|
||||
task.put("subject", subject);
|
||||
task.put("status", "pending");
|
||||
task.put("blockedBy", new ArrayList<>());
|
||||
task.put("blocks", new ArrayList<>());
|
||||
save(task);
|
||||
nextId++;
|
||||
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
s07以降、タスクグラフがマルチステップ作業のデフォルト。s03のTodoは軽量な単一セッション用チェックリストとして残る。
|
||||
2. **依存解除**: タスク完了時に、他タスクの `blockedBy` リストから完了 ID を除去し、後続タスクをアンブロックする。
|
||||
|
||||
## s06からの変更点
|
||||
```java
|
||||
private void clearDependency(int completedId) {
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
|
||||
.forEach(f -> {
|
||||
Map<String, Object> task = MAPPER.readValue(
|
||||
Files.readString(f), new TypeReference<>() {});
|
||||
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
|
||||
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
|
||||
save(task);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| コンポーネント | Before (s06) | After (s07) |
|
||||
3. **ステータス遷移 + 依存配線**: `taskUpdate` がステータス変更と依存エッジを担う。status が `completed` になると自動的に `clearDependency` を呼び出す。`blockedBy`/`blocks` は双方向の関係。
|
||||
|
||||
```java
|
||||
@Tool(description = "Update a task's status or dependencies.")
|
||||
public String taskUpdate(
|
||||
@ToolParam(description = "Task ID") int taskId,
|
||||
@ToolParam(description = "New status", required = false) String status,
|
||||
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
|
||||
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
|
||||
Map<String, Object> task = load(taskId);
|
||||
if (status != null) {
|
||||
task.put("status", status);
|
||||
if ("completed".equals(status)) {
|
||||
clearDependency(taskId);
|
||||
}
|
||||
}
|
||||
// addBlockedBy / addBlocks の双方向依存を処理 ...
|
||||
save(task);
|
||||
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
||||
}
|
||||
```
|
||||
|
||||
4. **Spring AI 自動ツール登録**: `TaskManager` を `defaultTools` として `ChatClient` に渡すと、Spring AI が `@Tool` アノテーションメソッドを自動認識する。手動 dispatch map は不要。
|
||||
|
||||
```java
|
||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
||||
public class S07TaskSystem implements CommandLineRunner {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
|
||||
public S07TaskSystem(ChatModel chatModel) {
|
||||
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
|
||||
TaskManager taskManager = new TaskManager(tasksDir);
|
||||
|
||||
this.chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
|
||||
.defaultTools(
|
||||
new BashTool(), new ReadFileTool(),
|
||||
new WriteFileTool(), new EditFileTool(),
|
||||
taskManager // TaskManager 内の @Tool メソッドが自動登録
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
s07 以降、タスクグラフがマルチステップ作業のデフォルト。s03 の Todo は軽量な単一セッション用チェックリストとして残る。
|
||||
|
||||
## s06 からの変更点
|
||||
|
||||
| コンポーネント | 変更前 (s06) | 変更後 (s07) |
|
||||
|---|---|---|
|
||||
| Tools | 5 | 8 (`task_create/update/list/get`) |
|
||||
| 計画モデル | フラットチェックリスト (メモリ) | 依存関係付きタスクグラフ (ディスク) |
|
||||
| 計画モデル | フラットチェックリスト (メモリのみ) | 依存関係付きタスクグラフ (ディスク) |
|
||||
| 関係 | なし | `blockedBy` + `blocks` エッジ |
|
||||
| ステータス追跡 | 完了か未完了 | `pending` -> `in_progress` -> `completed` |
|
||||
| 永続性 | 圧縮で消失 | 圧縮・再起動後も存続 |
|
||||
@@ -118,9 +159,11 @@ s07以降、タスクグラフがマルチステップ作業のデフォルト
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s07_task_system.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
|
||||
```
|
||||
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
|
||||
2. `List all tasks and show the dependency graph`
|
||||
3. `Complete task 1 and then list tasks to see task 2 unblocked`
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# s08: Background Tasks
|
||||
# s08: Background Tasks (バックグラウンドタスク)
|
||||
|
||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
|
||||
|
||||
> *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* -- デーモンスレッドがコマンド実行、完了後に通知を注入。
|
||||
> *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* -- バックグラウンドスレッドがコマンド実行、完了後に通知を注入。
|
||||
>
|
||||
> **Harness 層**: バックグラウンド実行 -- モデルが考え続ける間、Harness が待つ。
|
||||
|
||||
## 問題
|
||||
|
||||
一部のコマンドは数分かかる: `npm install`、`pytest`、`docker build`。ブロッキングループでは、モデルはサブプロセスの完了を待って座っている。ユーザーが「依存関係をインストールして、その間にconfigファイルを作って」と言っても、エージェントは並列ではなく逐次的に処理する。
|
||||
一部のコマンドは数分かかる: `npm install`、`pytest`、`docker build`。ブロッキングループでは、モデルは待つしかない。ユーザーが「依存関係をインストールして、その間に config ファイルを作って」と言っても、エージェントは1つずつしか処理できない。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -32,78 +32,107 @@ Agent --[spawn A]--[spawn B]--[other work]----
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. BackgroundManagerがスレッドセーフな通知キューでタスクを追跡する。
|
||||
1. BackgroundManager がスレッドセーフな並行コンテナでタスクを追跡する。Java では `ConcurrentHashMap` と `CopyOnWriteArrayList` を使用し、Python の手動ロックを置き換える。
|
||||
|
||||
```python
|
||||
class BackgroundManager:
|
||||
def __init__(self):
|
||||
self.tasks = {}
|
||||
self._notification_queue = []
|
||||
self._lock = threading.Lock()
|
||||
```java
|
||||
public class BackgroundManager {
|
||||
private static final int TIMEOUT_SECONDS = 300;
|
||||
|
||||
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
|
||||
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
record TaskInfo(String status, String result, String command) {}
|
||||
public record Notification(String taskId, String status, String command, String result) {}
|
||||
}
|
||||
```
|
||||
|
||||
2. `run()`がデーモンスレッドを開始し、即座にリターンする。
|
||||
2. `backgroundRun()` が仮想スレッド (Java 21) に投入し、即座にリターンする。Python の `daemon=True` スレッドに比べ、仮想スレッドはより軽量で JVM がスケジュールする。
|
||||
|
||||
```python
|
||||
def run(self, command: str) -> str:
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
self.tasks[task_id] = {"status": "running", "command": command}
|
||||
thread = threading.Thread(
|
||||
target=self._execute, args=(task_id, command), daemon=True)
|
||||
thread.start()
|
||||
return f"Background task {task_id} started"
|
||||
```java
|
||||
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
|
||||
public String backgroundRun(
|
||||
@ToolParam(description = "The shell command to run in background") String command) {
|
||||
String taskId = UUID.randomUUID().toString().substring(0, 8);
|
||||
tasks.put(taskId, new TaskInfo("running", null, command));
|
||||
|
||||
executor.submit(() -> execute(taskId, command));
|
||||
|
||||
return "Background task " + taskId + " started: "
|
||||
+ command.substring(0, Math.min(80, command.length()));
|
||||
}
|
||||
```
|
||||
|
||||
3. サブプロセス完了時に、結果を通知キューへ。
|
||||
3. サブプロセス完了時に、結果が通知キューに入る。`ProcessBuilder` でコマンドを実行し、タイムアウト制御をサポート。
|
||||
|
||||
```python
|
||||
def _execute(self, task_id, command):
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=300)
|
||||
output = (r.stdout + r.stderr).strip()[:50000]
|
||||
except subprocess.TimeoutExpired:
|
||||
output = "Error: Timeout (300s)"
|
||||
with self._lock:
|
||||
self._notification_queue.append({
|
||||
"task_id": task_id, "result": output[:500]})
|
||||
```java
|
||||
private void execute(String taskId, String command) {
|
||||
String status, output;
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
||||
pb.redirectErrorStream(true);
|
||||
Process process = pb.start();
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream()))) {
|
||||
output = reader.lines().collect(Collectors.joining("\n"));
|
||||
}
|
||||
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!finished) { process.destroyForcibly(); status = "timeout"; }
|
||||
else { status = "completed"; }
|
||||
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
|
||||
|
||||
tasks.put(taskId, new TaskInfo(status, output, command));
|
||||
notificationQueue.add(new Notification(taskId, status, command, output));
|
||||
}
|
||||
```
|
||||
|
||||
4. エージェントループが各LLM呼び出しの前に通知をドレインする。
|
||||
4. 毎回のユーザー入力時に通知キューをドレインし、システムプロンプトに注入する。Spring AI の `ChatClient` が内部ツールループを管理するため、毎回のユーザー入力時にドレイン+システムプロンプト構築に変更。核心的なコンセプトは同じ: fire and forget。
|
||||
|
||||
```python
|
||||
def agent_loop(messages: list):
|
||||
while True:
|
||||
notifs = BG.drain_notifications()
|
||||
if notifs:
|
||||
notif_text = "\n".join(
|
||||
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
|
||||
messages.append({"role": "user",
|
||||
"content": f"<background-results>\n{notif_text}\n"
|
||||
f"</background-results>"})
|
||||
messages.append({"role": "assistant",
|
||||
"content": "Noted background results."})
|
||||
response = client.messages.create(...)
|
||||
```java
|
||||
AgentRunner.interactive("s08", userMessage -> {
|
||||
// バックグラウンドタスク通知をドレイン(Python のループ前 drain_notifications に相当)
|
||||
var notifs = bgManager.drainNotifications();
|
||||
String bgContext = "";
|
||||
if (!notifs.isEmpty()) {
|
||||
String notifText = notifs.stream()
|
||||
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
|
||||
.collect(Collectors.joining("\n"));
|
||||
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
|
||||
}
|
||||
|
||||
String system = "You are a coding agent. Use backgroundRun for long-running commands."
|
||||
+ bgContext;
|
||||
|
||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem(system)
|
||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
||||
new WriteFileTool(), new EditFileTool(), bgManager)
|
||||
.build();
|
||||
|
||||
return chatClient.prompt().user(userMessage).call().content();
|
||||
});
|
||||
```
|
||||
|
||||
ループはシングルスレッドのまま。サブプロセスI/Oだけが並列化される。
|
||||
ループはシングルスレッドのまま。サブプロセス I/O だけが並列化される。
|
||||
|
||||
## s07からの変更点
|
||||
## s07 からの変更点
|
||||
|
||||
| Component | Before (s07) | After (s08) |
|
||||
|----------------|------------------|----------------------------|
|
||||
| Tools | 8 | 6 (base + background_run + check)|
|
||||
| Execution | Blocking only | Blocking + background threads|
|
||||
| Notification | None | Queue drained per loop |
|
||||
| Concurrency | None | Daemon threads |
|
||||
| コンポーネント | 変更前 (s07) | 変更後 (s08) |
|
||||
|----------------|------------------|------------------------------------|
|
||||
| Tools | 8 | 6 (基本 + backgroundRun + check) |
|
||||
| 実行方式 | ブロッキングのみ | ブロッキング + 仮想スレッド (Java 21) |
|
||||
| 通知メカニズム | なし | 毎ターンドレインの ConcurrentLinkedQueue |
|
||||
| 並行性 | なし | 仮想スレッド (より軽量、JVM スケジュール) |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s08_background_tasks.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
|
||||
```
|
||||
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Run "sleep 5 && echo done" in the background, then create a file while it runs`
|
||||
2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.`
|
||||
3. `Run pytest in the background and keep working on other things`
|
||||
|
||||
+116
-68
@@ -1,16 +1,16 @@
|
||||
# s09: Agent Teams
|
||||
# s09: Agent Teams (エージェントチーム)
|
||||
|
||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
|
||||
|
||||
> *"一人で終わらないなら、チームメイトに任せる"* -- 永続チームメイト + 非同期メールボックス。
|
||||
> *"一人で終わらないなら、チームメイトに任せる"* -- 永続チームメイト + JSONL メールボックス。
|
||||
>
|
||||
> **Harness 層**: チームメールボックス -- 複数モデルをファイルで協調。
|
||||
|
||||
## 問題
|
||||
|
||||
サブエージェント(s04)は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク(s08)はシェルコマンドを実行するが、LLM誘導の意思決定はできない。
|
||||
サブエージェント (s04) は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク (s08) はシェルコマンドを実行するが、LLM 誘導の意思決定はできない。
|
||||
|
||||
本物のチームワークには: (1)単一プロンプトを超えて存続する永続エージェント、(2)アイデンティティとライフサイクル管理、(3)エージェント間の通信チャネルが必要だ。
|
||||
本物のチームワークには3つが必要: (1) 複数ターンの会話を超えて存続する永続エージェント、(2) アイデンティティとライフサイクル管理、(3) エージェント間の通信チャネル。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -37,91 +37,139 @@ Communication:
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. TeammateManagerがconfig.jsonでチーム名簿を管理する。
|
||||
1. TeammateManager が config.json でチーム名簿を管理する。
|
||||
|
||||
```python
|
||||
class TeammateManager:
|
||||
def __init__(self, team_dir: Path):
|
||||
self.dir = team_dir
|
||||
self.dir.mkdir(exist_ok=True)
|
||||
self.config_path = self.dir / "config.json"
|
||||
self.config = self._load_config()
|
||||
self.threads = {}
|
||||
```java
|
||||
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
|
||||
public class TeammateManager {
|
||||
private final ChatModel chatModel;
|
||||
private final MessageBus bus;
|
||||
private final Path configPath;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private Map<String, Object> config;
|
||||
// Python は threading.Thread + dict を使用、Java は ConcurrentHashMap で天然スレッドセーフ
|
||||
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
|
||||
|
||||
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
|
||||
this.chatModel = chatModel;
|
||||
this.bus = bus;
|
||||
this.configPath = teamDir.resolve("config.json");
|
||||
Files.createDirectories(teamDir);
|
||||
this.config = loadConfig();
|
||||
}
|
||||
```
|
||||
|
||||
2. `spawn()`がチームメイトを作成し、そのエージェントループをスレッドで開始する。
|
||||
2. `spawn()` がチームメイトを作成し、スレッド内でエージェントループを開始する。
|
||||
|
||||
```python
|
||||
def spawn(self, name: str, role: str, prompt: str) -> str:
|
||||
member = {"name": name, "role": role, "status": "working"}
|
||||
self.config["members"].append(member)
|
||||
self._save_config()
|
||||
thread = threading.Thread(
|
||||
target=self._teammate_loop,
|
||||
args=(name, role, prompt), daemon=True)
|
||||
thread.start()
|
||||
return f"Spawned teammate '{name}' (role: {role})"
|
||||
```java
|
||||
// Python は threading.Thread を使用、Java は Thread.startVirtualThread() 仮想スレッドを使用
|
||||
public synchronized String spawn(String name, String role, String prompt) {
|
||||
Map<String, Object> member = new LinkedHashMap<>();
|
||||
member.put("name", name);
|
||||
member.put("role", role);
|
||||
member.put("status", "working");
|
||||
((List<Map<String, Object>>) config.get("members")).add(member);
|
||||
saveConfig();
|
||||
|
||||
// 仮想スレッド: 軽量、JVM スケジュール、OS スレッドを占有しない
|
||||
Thread thread = Thread.startVirtualThread(
|
||||
() -> teammateLoop(name, role, prompt));
|
||||
threads.put(name, thread);
|
||||
return "Spawned '" + name + "' (role: " + role + ")";
|
||||
}
|
||||
```
|
||||
|
||||
3. MessageBus: 追記専用のJSONLインボックス。`send()`がJSON行を追記し、`read_inbox()`がすべて読み取ってドレインする。
|
||||
3. MessageBus: 追記専用の JSONL インボックス。`send()` が1行を追記し、`read_inbox()` がすべて読み取ってドレインする。
|
||||
|
||||
```python
|
||||
class MessageBus:
|
||||
def send(self, sender, to, content, msg_type="message", extra=None):
|
||||
msg = {"type": msg_type, "from": sender,
|
||||
"content": content, "timestamp": time.time()}
|
||||
if extra:
|
||||
msg.update(extra)
|
||||
with open(self.dir / f"{to}.jsonl", "a") as f:
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
```java
|
||||
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
|
||||
// Python は GIL で暗黙的にスレッドセーフ、Java は synchronized で明示的に保証
|
||||
public class MessageBus {
|
||||
private final Path inboxDir;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
def read_inbox(self, name):
|
||||
path = self.dir / f"{name}.jsonl"
|
||||
if not path.exists(): return "[]"
|
||||
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
|
||||
path.write_text("") # drain
|
||||
return json.dumps(msgs, indent=2)
|
||||
public synchronized String send(String sender, String to, String content,
|
||||
String msgType, Map<String, Object> extra) {
|
||||
Map<String, Object> msg = new LinkedHashMap<>();
|
||||
msg.put("type", msgType);
|
||||
msg.put("from", sender);
|
||||
msg.put("content", content);
|
||||
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
|
||||
if (extra != null) msg.putAll(extra);
|
||||
|
||||
Path inbox = inboxDir.resolve(to + ".jsonl");
|
||||
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
return "Sent " + msgType + " to " + to;
|
||||
}
|
||||
|
||||
public synchronized List<Map<String, Object>> readInbox(String name) {
|
||||
Path inbox = inboxDir.resolve(name + ".jsonl");
|
||||
if (!Files.exists(inbox)) return List.of();
|
||||
List<Map<String, Object>> messages = new ArrayList<>();
|
||||
for (String line : Files.readAllLines(inbox)) {
|
||||
if (!line.isBlank())
|
||||
messages.add(mapper.readValue(line, new TypeReference<>() {}));
|
||||
}
|
||||
Files.writeString(inbox, ""); // drain
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 各チームメイトは各LLM呼び出しの前にインボックスを確認し、受信メッセージをコンテキストに注入する。
|
||||
4. 各チームメイトは `call()` 呼び出し間でインボックスをチェックし、メッセージをコンテキストに注入する。ChatClient の `call()` は Python の完全なツールループ(`stop_reason != "tool_use"` まで繰り返す)に相当する。
|
||||
|
||||
```python
|
||||
def _teammate_loop(self, name, role, prompt):
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
for _ in range(50):
|
||||
inbox = BUS.read_inbox(name)
|
||||
if inbox != "[]":
|
||||
messages.append({"role": "user",
|
||||
"content": f"<inbox>{inbox}</inbox>"})
|
||||
messages.append({"role": "assistant",
|
||||
"content": "Noted inbox messages."})
|
||||
response = client.messages.create(...)
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
# execute tools, append results...
|
||||
self._find_member(name)["status"] = "idle"
|
||||
```java
|
||||
// Python のチームメイトは毎回の LLM 呼び出し前にインボックスをチェック、Java は毎回の call() 呼び出し間でチェック
|
||||
protected void teammateLoop(String name, String role, String initialPrompt) {
|
||||
String sysPrompt = String.format(
|
||||
"You are '%s', role: %s. Use send_message to communicate.",
|
||||
name, role);
|
||||
|
||||
var messageTool = new TeammateMessageTool(bus, name);
|
||||
ChatClient client = ChatClient.builder(chatModel)
|
||||
.defaultSystem(sysPrompt)
|
||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
||||
new WriteFileTool(), new EditFileTool(), messageTool)
|
||||
.build();
|
||||
|
||||
// 初期作業(call() = 完全なツールチェーン、Python の stop_reason != "tool_use" までのループに相当)
|
||||
String response = client.prompt(initialPrompt).call().content();
|
||||
|
||||
// 毎回の call() 間でインボックスをチェック(Python の毎回の LLM 呼び出し間ではなく)
|
||||
for (int round = 0; round < 50; round++) {
|
||||
Thread.sleep(2000);
|
||||
var inbox = bus.readInbox(name);
|
||||
if (inbox.isEmpty()) break;
|
||||
String inboxJson = mapper.writeValueAsString(inbox);
|
||||
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
|
||||
}
|
||||
setStatus(name, "idle");
|
||||
}
|
||||
```
|
||||
|
||||
## s08からの変更点
|
||||
## s08 からの変更点
|
||||
|
||||
| Component | Before (s08) | After (s09) |
|
||||
|----------------|------------------|----------------------------|
|
||||
| Tools | 6 | 9 (+spawn/send/read_inbox) |
|
||||
| Agents | Single | Lead + N teammates |
|
||||
| Persistence | None | config.json + JSONL inboxes|
|
||||
| Threads | Background cmds | Full agent loops per thread|
|
||||
| Lifecycle | Fire-and-forget | idle -> working -> idle |
|
||||
| Communication | None | message + broadcast |
|
||||
| コンポーネント | 変更前 (s08) | 変更後 (s09) |
|
||||
|----------------|------------------|------------------------------------|
|
||||
| Tools | 6 | 9 (+spawn/send/read_inbox) |
|
||||
| エージェント数 | 単一 | リーダー + N チームメイト |
|
||||
| 永続化 | なし | config.json + JSONL インボックス |
|
||||
| スレッド | バックグラウンドコマンド | 各スレッドで完全なエージェントループ |
|
||||
| ライフサイクル | 使い捨て | idle -> working -> idle |
|
||||
| 通信 | なし | message + broadcast |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s09_agent_teams.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
|
||||
```
|
||||
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
|
||||
2. `Broadcast "status update: phase 1 complete" to all teammates`
|
||||
3. `Check the lead inbox for any messages`
|
||||
4. `/team`と入力してステータス付きのチーム名簿を確認する
|
||||
5. `/inbox`と入力してリーダーのインボックスを手動確認する
|
||||
4. `/team` と入力してチーム名簿とステータスを確認する
|
||||
5. `/inbox` と入力してリーダーのインボックスを手動確認する
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# s10: Team Protocols
|
||||
# s10: Team Protocols (チームプロトコル)
|
||||
|
||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
## 問題
|
||||
|
||||
s09ではチームメイトが作業し通信するが、構造化された協調がない:
|
||||
s09 ではチームメイトが作業し通信するが、構造化された協調がない:
|
||||
|
||||
**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.jsonが不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認(完了して退出)か拒否(作業継続)する。
|
||||
**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.json が不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認(完了して退出)か拒否(作業継続)する。
|
||||
|
||||
**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にリーダーが計画をレビューすべきだ。
|
||||
**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にレビューすべきだ。
|
||||
|
||||
両方とも同じ構造: 一方がユニークIDを持つリクエストを送り、他方がそのIDで応答する。
|
||||
両方とも同じ構造: 一方がユニーク ID を持つリクエストを送り、他方がその ID で応答する。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -42,65 +42,93 @@ Trackers:
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. リーダーがrequest_idを生成し、インボックス経由でシャットダウンを開始する。
|
||||
1. リーダーが request_id を生成し、インボックス経由でシャットダウンを開始する。
|
||||
|
||||
```python
|
||||
shutdown_requests = {}
|
||||
```java
|
||||
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
|
||||
// Python は辞書 + threading.Lock を使用、Java は ConcurrentHashMap で天然スレッドセーフ
|
||||
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
|
||||
= new ConcurrentHashMap<>();
|
||||
|
||||
def handle_shutdown_request(teammate: str) -> str:
|
||||
req_id = str(uuid.uuid4())[:8]
|
||||
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
|
||||
BUS.send("lead", teammate, "Please shut down gracefully.",
|
||||
"shutdown_request", {"request_id": req_id})
|
||||
return f"Shutdown request {req_id} sent (status: pending)"
|
||||
public String handleShutdownRequest(String teammate) {
|
||||
String reqId = UUID.randomUUID().toString().substring(0, 8);
|
||||
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
|
||||
"target", teammate, "status", "pending")));
|
||||
bus.send("lead", teammate, "Please shut down gracefully.",
|
||||
"shutdown_request", Map.of("request_id", reqId));
|
||||
return "Shutdown request " + reqId + " sent to '" + teammate
|
||||
+ "' (status: pending)";
|
||||
}
|
||||
```
|
||||
|
||||
2. チームメイトがリクエストを受信し、承認または拒否で応答する。
|
||||
|
||||
```python
|
||||
if tool_name == "shutdown_response":
|
||||
req_id = args["request_id"]
|
||||
approve = args["approve"]
|
||||
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
|
||||
BUS.send(sender, "lead", args.get("reason", ""),
|
||||
"shutdown_response",
|
||||
{"request_id": req_id, "approve": approve})
|
||||
```java
|
||||
// TeammateProtocolTool - チームメイトが @Tool アノテーションでシャットダウン要求に応答
|
||||
@Tool(description = "Respond to a shutdown request")
|
||||
public String shutdownResponse(
|
||||
@ToolParam(description = "The request_id") String requestId,
|
||||
@ToolParam(description = "true to approve") boolean approve,
|
||||
@ToolParam(description = "Reason for decision") String reason) {
|
||||
return tracker.respondToShutdown(name, requestId, approve, reason);
|
||||
}
|
||||
|
||||
// ProtocolTracker - トラッカー更新 + レスポンスメッセージ送信
|
||||
public String respondToShutdown(String sender, String requestId,
|
||||
boolean approve, String reason) {
|
||||
var req = shutdownRequests.get(requestId);
|
||||
if (req != null) {
|
||||
req.put("status", approve ? "approved" : "rejected");
|
||||
}
|
||||
bus.send(sender, "lead", reason != null ? reason : "",
|
||||
"shutdown_response",
|
||||
Map.of("request_id", requestId, "approve", approve));
|
||||
return "Shutdown " + (approve ? "approved" : "rejected");
|
||||
}
|
||||
```
|
||||
|
||||
3. プラン承認も同一パターン。チームメイトがプランを提出(request_idを生成)、リーダーがレビュー(同じrequest_idを参照)。
|
||||
3. プラン承認もまったく同じパターン。チームメイトがプランを提出(request_id を生成)、リーダーがレビュー(同じ request_id を参照)。
|
||||
|
||||
```python
|
||||
plan_requests = {}
|
||||
```java
|
||||
// ProtocolTracker - 同じ request_id 関連パターン、2つの用途
|
||||
private final ConcurrentHashMap<String, Map<String, String>> planRequests
|
||||
= new ConcurrentHashMap<>();
|
||||
|
||||
def handle_plan_review(request_id, approve, feedback=""):
|
||||
req = plan_requests[request_id]
|
||||
req["status"] = "approved" if approve else "rejected"
|
||||
BUS.send("lead", req["from"], feedback,
|
||||
"plan_approval_response",
|
||||
{"request_id": request_id, "approve": approve})
|
||||
public String reviewPlan(String requestId, boolean approve, String feedback) {
|
||||
var req = planRequests.get(requestId);
|
||||
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
|
||||
req.put("status", approve ? "approved" : "rejected");
|
||||
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
|
||||
"plan_approval_response",
|
||||
Map.of("request_id", requestId, "approve", approve,
|
||||
"feedback", feedback != null ? feedback : ""));
|
||||
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
|
||||
}
|
||||
```
|
||||
|
||||
1つのFSM、2つの応用。同じ`pending -> approved | rejected`状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。
|
||||
1つの FSM、2つの用途。同じ `pending -> approved | rejected` 状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。
|
||||
|
||||
## s09からの変更点
|
||||
## s09 からの変更点
|
||||
|
||||
| Component | Before (s09) | After (s10) |
|
||||
|----------------|------------------|------------------------------|
|
||||
| Tools | 9 | 12 (+shutdown_req/resp +plan)|
|
||||
| Shutdown | Natural exit only| Request-response handshake |
|
||||
| Plan gating | None | Submit/review with approval |
|
||||
| Correlation | None | request_id per request |
|
||||
| FSM | None | pending -> approved/rejected |
|
||||
| コンポーネント | 変更前 (s09) | 変更後 (s10) |
|
||||
|----------------|------------------|--------------------------------------|
|
||||
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
|
||||
| シャットダウン | 自然終了のみ | リクエスト-レスポンスハンドシェイク |
|
||||
| プランゲーティング | なし | 提出/レビューと承認 |
|
||||
| 関連付け | なし | リクエストごとに request_id |
|
||||
| FSM | なし | pending -> approved/rejected |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s10_team_protocols.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
|
||||
```
|
||||
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Spawn alice as a coder. Then request her shutdown.`
|
||||
2. `List teammates to see alice's status after shutdown approval`
|
||||
3. `Spawn bob with a risky refactoring task. Review and reject his plan.`
|
||||
4. `Spawn charlie, have him submit a plan, then approve it.`
|
||||
5. `/team`と入力してステータスを監視する
|
||||
5. `/team` と入力してステータスを監視する
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# s11: Autonomous Agents
|
||||
# s11: Autonomous Agents (自律エージェント)
|
||||
|
||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
|
||||
|
||||
> *"チームメイトが自らボードを見て、仕事を取る"* -- リーダーが逐一割り振る必要はない。
|
||||
> *"チームメイトが自らボードを見て、仕事を取る"* -- リーダーが逐一割り振る必要はない、自己組織化。
|
||||
>
|
||||
> **Harness 層**: 自律 -- 指示なしで仕事を見つけるモデル。
|
||||
|
||||
## 問題
|
||||
|
||||
s09-s10では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトを特定のプロンプトでspawnしなければならない。タスクボードに未割り当てのタスクが10個あっても、リーダーが手動で各タスクを割り当てる。これはスケールしない。
|
||||
s09-s10 では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトにプロンプトを書き、タスクボード上の10個の未割り当てタスクを手動で割り当てる。これはスケールしない。
|
||||
|
||||
真の自律性とは、チームメイトが自分で作業を見つけること: タスクボードをスキャンし、未確保のタスクを確保し、作業し、完了したら次を探す。
|
||||
真の自律性: チームメイトが自分でタスクボードをスキャンし、未確保のタスクを確保し、完了したら次を探す。
|
||||
|
||||
もう1つの問題: コンテキスト圧縮(s06)後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。
|
||||
もう1つの問題: コンテキスト圧縮 (s06) 後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -40,103 +40,154 @@ Teammate lifecycle with idle cycle:
|
||||
|
|
||||
+---> 60s timeout ----------------------> SHUTDOWN
|
||||
|
||||
Identity re-injection after compression:
|
||||
if len(messages) <= 3:
|
||||
messages.insert(0, identity_block)
|
||||
Identity via system prompt (always present):
|
||||
ChatClient.builder(chatModel)
|
||||
.defaultSystem(identityPrompt) // 毎回の呼び出しで自動付与
|
||||
```
|
||||
|
||||
## 仕組み
|
||||
|
||||
1. チームメイトのループはWORKとIDLEの2フェーズ。LLMがツール呼び出しを止めた時(または`idle`ツールを呼んだ時)、IDLEフェーズに入る。
|
||||
1. チームメイトのループは WORK と IDLE の2フェーズ。LLM がツール呼び出しを止めた時(または `idle` ツールを呼んだ時)、IDLE フェーズに入る。
|
||||
|
||||
```python
|
||||
def _loop(self, name, role, prompt):
|
||||
while True:
|
||||
# -- WORK PHASE --
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
for _ in range(50):
|
||||
response = client.messages.create(...)
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
# execute tools...
|
||||
if idle_requested:
|
||||
break
|
||||
```java
|
||||
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
|
||||
// AutonomousTeammateManager.autonomousLoop()
|
||||
|
||||
# -- IDLE PHASE --
|
||||
self._set_status(name, "idle")
|
||||
resume = self._idle_poll(name, messages)
|
||||
if not resume:
|
||||
self._set_status(name, "shutdown")
|
||||
return
|
||||
self._set_status(name, "working")
|
||||
private void autonomousLoop(String name, String role, String initialPrompt) {
|
||||
// idle フラグ: ツール呼び出し時に設定、外部ループが検出
|
||||
AtomicBoolean idleRequested = new AtomicBoolean(false);
|
||||
var idleTool = new IdleTool(idleRequested);
|
||||
|
||||
ChatClient client = ChatClient.builder(chatModel)
|
||||
.defaultSystem(sysPrompt)
|
||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
||||
new WriteFileTool(), new EditFileTool(),
|
||||
messageTool, protocolTool, idleTool, claimTool)
|
||||
.build();
|
||||
|
||||
while (true) {
|
||||
// -- WORK PHASE --
|
||||
String nextMsg = initialPrompt;
|
||||
for (int round = 0; round < 50 && nextMsg != null; round++) {
|
||||
var inbox = bus.readInbox(name);
|
||||
// ... インボックスメッセージを nextMsg にマージ ...
|
||||
idleRequested.set(false);
|
||||
String response = client.prompt(sb.toString()).call().content();
|
||||
if (idleRequested.get()) break; // idle ツールが呼ばれた
|
||||
nextMsg = null; // 以降のラウンドは inbox 駆動
|
||||
}
|
||||
|
||||
// -- IDLE PHASE --
|
||||
setStatus(name, "idle");
|
||||
// ... インボックス + タスクボードをポーリング(下記参照) ...
|
||||
if (!resume) { setStatus(name, "shutdown"); return; }
|
||||
setStatus(name, "working");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. IDLEフェーズがインボックスとタスクボードをポーリングする。
|
||||
2. IDLE フェーズがインボックスとタスクボードをポーリングする。
|
||||
|
||||
```python
|
||||
def _idle_poll(self, name, messages):
|
||||
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
|
||||
time.sleep(POLL_INTERVAL)
|
||||
inbox = BUS.read_inbox(name)
|
||||
if inbox:
|
||||
messages.append({"role": "user",
|
||||
"content": f"<inbox>{inbox}</inbox>"})
|
||||
return True
|
||||
unclaimed = scan_unclaimed_tasks()
|
||||
if unclaimed:
|
||||
claim_task(unclaimed[0]["id"], name)
|
||||
messages.append({"role": "user",
|
||||
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
|
||||
f"{unclaimed[0]['subject']}</auto-claimed>"})
|
||||
return True
|
||||
return False # timeout -> shutdown
|
||||
```java
|
||||
// IDLE PHASE: インボックス + タスクボードをポーリング
|
||||
setStatus(name, "idle");
|
||||
boolean resume = false;
|
||||
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
|
||||
|
||||
for (int p = 0; p < polls; p++) {
|
||||
Thread.sleep(POLL_INTERVAL * 1000L);
|
||||
|
||||
// インボックスをチェック
|
||||
var inbox = bus.readInbox(name);
|
||||
if (!inbox.isEmpty()) {
|
||||
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
|
||||
resume = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// タスクボードをスキャン
|
||||
var unclaimed = scanUnclaimedTasks(tasksDir);
|
||||
if (!unclaimed.isEmpty()) {
|
||||
var task = unclaimed.get(0);
|
||||
int taskId = ((Number) task.get("id")).intValue();
|
||||
claimTask(tasksDir, taskId, name);
|
||||
initialPrompt = String.format(
|
||||
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
|
||||
taskId, task.get("subject"),
|
||||
task.getOrDefault("description", ""));
|
||||
resume = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resume) { setStatus(name, "shutdown"); return; }
|
||||
setStatus(name, "working");
|
||||
```
|
||||
|
||||
3. タスクボードスキャン: pendingかつ未割り当てかつブロックされていないタスクを探す。
|
||||
3. タスクボードスキャン: pending ステータスかつ owner なしかつブロックされていないタスクを探す。
|
||||
|
||||
```python
|
||||
def scan_unclaimed_tasks() -> list:
|
||||
unclaimed = []
|
||||
for f in sorted(TASKS_DIR.glob("task_*.json")):
|
||||
task = json.loads(f.read_text())
|
||||
if (task.get("status") == "pending"
|
||||
and not task.get("owner")
|
||||
and not task.get("blockedBy")):
|
||||
unclaimed.append(task)
|
||||
return unclaimed
|
||||
```java
|
||||
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
|
||||
if (!Files.exists(tasksDir)) return List.of();
|
||||
List<Map<String, Object>> unclaimed = new ArrayList<>();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
try (var files = Files.list(tasksDir)) {
|
||||
files.filter(f -> f.getFileName().toString().startsWith("task_")
|
||||
&& f.getFileName().toString().endsWith(".json"))
|
||||
.sorted()
|
||||
.forEach(f -> {
|
||||
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
|
||||
if ("pending".equals(task.get("status"))
|
||||
&& (task.get("owner") == null || "".equals(task.get("owner")))
|
||||
&& (task.get("blockedBy") == null
|
||||
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
|
||||
unclaimed.add(task);
|
||||
}
|
||||
});
|
||||
}
|
||||
return unclaimed;
|
||||
}
|
||||
```
|
||||
|
||||
4. アイデンティティ再注入: コンテキストが短すぎる(圧縮が起きた)場合にアイデンティティブロックを挿入する。
|
||||
4. アイデンティティ保持: Java/Spring AI の `ChatClient.defaultSystem()` は毎回の呼び出しで自動的にシステムプロンプトを付与するため、アイデンティティ情報は常に存在する。Python 版のように圧縮後に手動で再注入する必要はない。
|
||||
|
||||
```python
|
||||
if len(messages) <= 3:
|
||||
messages.insert(0, {"role": "user",
|
||||
"content": f"<identity>You are '{name}', role: {role}, "
|
||||
f"team: {team_name}. Continue your work.</identity>"})
|
||||
messages.insert(1, {"role": "assistant",
|
||||
"content": f"I am {name}. Continuing."})
|
||||
```java
|
||||
// アイデンティティ情報は defaultSystem で構築時に注入、毎回の prompt で自動付与
|
||||
String sysPrompt = String.format(
|
||||
"You are '%s', role: %s, team: %s, at %s. "
|
||||
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
|
||||
name, role, teamName, workDir);
|
||||
|
||||
ChatClient client = ChatClient.builder(chatModel)
|
||||
.defaultSystem(sysPrompt) // アイデンティティは常にシステムプロンプトに存在
|
||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
||||
new WriteFileTool(), new EditFileTool(),
|
||||
messageTool, protocolTool, idleTool, claimTool)
|
||||
.build();
|
||||
```
|
||||
|
||||
## s10からの変更点
|
||||
## s10 からの変更点
|
||||
|
||||
| Component | Before (s10) | After (s11) |
|
||||
|----------------|------------------|----------------------------|
|
||||
| Tools | 12 | 14 (+idle, +claim_task) |
|
||||
| Autonomy | Lead-directed | Self-organizing |
|
||||
| Idle phase | None | Poll inbox + task board |
|
||||
| Task claiming | Manual only | Auto-claim unclaimed tasks |
|
||||
| Identity | System prompt | + re-injection after compress|
|
||||
| Timeout | None | 60s idle -> auto shutdown |
|
||||
| コンポーネント | 変更前 (s10) | 変更後 (s11) |
|
||||
|----------------|------------------|----------------------------------|
|
||||
| Tools | 12 | 14 (+idle, +claim_task) |
|
||||
| 自律性 | リーダー指示 | 自己組織化 |
|
||||
| IDLE フェーズ | なし | インボックス + タスクボードをポーリング |
|
||||
| タスク確保 | 手動のみ | 未割り当てタスクの自動確保 |
|
||||
| アイデンティティ | システムプロンプト | + 圧縮後の再注入 |
|
||||
| タイムアウト | なし | 60秒 IDLE → 自動シャットダウン |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s11_autonomous_agents.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
|
||||
```
|
||||
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`
|
||||
2. `Spawn a coder teammate and let it find work from the task board itself`
|
||||
3. `Create tasks with dependencies. Watch teammates respect the blocked order.`
|
||||
4. `/tasks`と入力してオーナー付きのタスクボードを確認する
|
||||
5. `/team`と入力して誰が作業中でアイドルかを監視する
|
||||
4. `/tasks` と入力して owner 付きのタスクボードを確認する
|
||||
5. `/team` と入力して誰が作業中でアイドルかを監視する
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# s12: Worktree + Task Isolation
|
||||
# s12: Worktree + Task Isolation (Worktree タスク隔離)
|
||||
|
||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
|
||||
|
||||
> *"各自のディレクトリで作業し、互いに干渉しない"* -- タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け。
|
||||
> *"各自のディレクトリで作業し、互いに干渉しない"* -- タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。
|
||||
>
|
||||
> **Harness 層**: ディレクトリ隔離 -- 決して衝突しない並列実行レーン。
|
||||
|
||||
## 問題
|
||||
|
||||
s11までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると衝突する: 片方が`config.py`を編集し、もう片方も`config.py`を編集し、未コミットの変更が混ざり合い、どちらもクリーンにロールバックできない。
|
||||
s11 までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると -- A が `Config.java` を編集し、B も `Config.java` を編集し、未コミットの変更が互いに汚染し、どちらもクリーンにロールバックできない。
|
||||
|
||||
タスクボードは*何をやるか*を追跡するが、*どこでやるか*には関知しない。解決策: 各タスクに専用のgit worktreeディレクトリを与える。タスクが目標を管理し、worktreeが実行コンテキストを管理する。タスクIDで紐付ける。
|
||||
タスクボードは「何をやるか」を追跡するが「どこでやるか」には関知しない。解決策: 各タスクに独立した git worktree ディレクトリを与え、タスク ID で両者を関連付ける。
|
||||
|
||||
## 解決策
|
||||
|
||||
@@ -38,51 +38,74 @@ State machines:
|
||||
|
||||
1. **タスクを作成する。** まず目標を永続化する。
|
||||
|
||||
```python
|
||||
TASKS.create("Implement auth refactor")
|
||||
# -> .tasks/task_1.json status=pending worktree=""
|
||||
```java
|
||||
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
|
||||
tasks.create("Implement auth refactor", "");
|
||||
// -> .tasks/task_1.json status=pending worktree=""
|
||||
```
|
||||
|
||||
2. **worktreeを作成してタスクに紐付ける。** `task_id`を渡すと、タスクが自動的に`in_progress`に遷移する。
|
||||
2. **worktree を作成してタスクに紐付ける。** `task_id` を渡すと、タスクが自動的に `in_progress` に遷移する。
|
||||
|
||||
```python
|
||||
WORKTREES.create("auth-refactor", task_id=1)
|
||||
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
|
||||
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
|
||||
```java
|
||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
|
||||
worktrees.create("auth-refactor", 1, "HEAD");
|
||||
// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
|
||||
// -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
|
||||
```
|
||||
|
||||
紐付けは両側に状態を書き込む:
|
||||
|
||||
```python
|
||||
def bind_worktree(self, task_id, worktree):
|
||||
task = self._load(task_id)
|
||||
task["worktree"] = worktree
|
||||
if task["status"] == "pending":
|
||||
task["status"] = "in_progress"
|
||||
self._save(task)
|
||||
```java
|
||||
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
|
||||
public String bindWorktree(int taskId, String worktree, String owner) {
|
||||
var task = load(taskId);
|
||||
task.put("worktree", worktree);
|
||||
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
|
||||
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
|
||||
task.put("updated_at", System.currentTimeMillis() / 1000.0);
|
||||
save(task);
|
||||
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
||||
}
|
||||
```
|
||||
|
||||
3. **worktree内でコマンドを実行する。** `cwd`が分離ディレクトリを指す。
|
||||
3. **worktree 内でコマンドを実行する。** `cwd` が隔離ディレクトリを指す。
|
||||
|
||||
```python
|
||||
subprocess.run(command, shell=True, cwd=worktree_path,
|
||||
capture_output=True, text=True, timeout=300)
|
||||
```java
|
||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
|
||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
|
||||
ProcessBuilder pb = isWindows
|
||||
? new ProcessBuilder("cmd", "/c", command)
|
||||
: new ProcessBuilder("sh", "-c", command);
|
||||
pb.directory(path.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
String out = new String(p.getInputStream().readAllBytes()).trim();
|
||||
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
|
||||
```
|
||||
|
||||
4. **終了処理。** 2つの選択肢:
|
||||
- `worktree_keep(name)` -- ディレクトリを保持する。
|
||||
- `worktree_remove(name, complete_task=True)` -- ディレクトリを削除し、紐付けられたタスクを完了し、イベントを発行する。1回の呼び出しで後片付けと完了を処理する。
|
||||
|
||||
```python
|
||||
def remove(self, name, force=False, complete_task=False):
|
||||
self._run_git(["worktree", "remove", wt["path"]])
|
||||
if complete_task and wt.get("task_id") is not None:
|
||||
self.tasks.update(wt["task_id"], status="completed")
|
||||
self.tasks.unbind_worktree(wt["task_id"])
|
||||
self.events.emit("task.completed", ...)
|
||||
```java
|
||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
|
||||
public String remove(String name, boolean force, boolean completeTask) {
|
||||
var wt = findWorktree(name);
|
||||
events.emit("worktree.remove.before", ...);
|
||||
runGit("worktree", "remove", wt.get("path").toString());
|
||||
if (completeTask && wt.get("task_id") != null) {
|
||||
int taskId = ((Number) wt.get("task_id")).intValue();
|
||||
tasks.update(taskId, "completed", null);
|
||||
tasks.unbindWorktree(taskId);
|
||||
events.emit("task.completed",
|
||||
Map.of("id", taskId, "status", "completed"),
|
||||
Map.of("name", name), null);
|
||||
}
|
||||
// index.json を更新: status -> "removed"
|
||||
}
|
||||
```
|
||||
|
||||
5. **イベントストリーム。** ライフサイクルの各ステップが`.worktrees/events.jsonl`に記録される:
|
||||
5. **イベントストリーム。** ライフサイクルの各ステップが `.worktrees/events.jsonl` に記録される:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -93,27 +116,29 @@ def remove(self, name, force=False, complete_task=False):
|
||||
}
|
||||
```
|
||||
|
||||
発行されるイベント: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`。
|
||||
イベントタイプ: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`。
|
||||
|
||||
クラッシュ後も`.tasks/` + `.worktrees/index.json`から状態を再構築できる。会話メモリは揮発性だが、ファイル状態は永続的だ。
|
||||
クラッシュ後も `.tasks/` + `.worktrees/index.json` から状態を再構築できる。会話メモリは揮発性だが、ディスク状態は永続的だ。
|
||||
|
||||
## s11からの変更点
|
||||
## s11 からの変更点
|
||||
|
||||
| Component | Before (s11) | After (s12) |
|
||||
| コンポーネント | 変更前 (s11) | 変更後 (s12) |
|
||||
|--------------------|----------------------------|----------------------------------------------|
|
||||
| Coordination | Task board (owner/status) | Task board + explicit worktree binding |
|
||||
| Execution scope | Shared directory | Task-scoped isolated directory |
|
||||
| Recoverability | Task status only | Task status + worktree index |
|
||||
| Teardown | Task completion | Task completion + explicit keep/remove |
|
||||
| Lifecycle visibility | Implicit in logs | Explicit events in `.worktrees/events.jsonl` |
|
||||
| 協調 | タスクボード (owner/status) | タスクボード + worktree 明示的紐付け |
|
||||
| 実行スコープ | 共有ディレクトリ | タスクごとの隔離ディレクトリ |
|
||||
| 復旧可能性 | タスクステータスのみ | タスクステータス + worktree インデックス |
|
||||
| 終了処理 | タスク完了 | タスク完了 + 明示的 keep/remove |
|
||||
| ライフサイクル可視性 | ログ内に暗黙的 | `.worktrees/events.jsonl` で明示的イベントストリーム |
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python agents/s12_worktree_task_isolation.py
|
||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
|
||||
```
|
||||
|
||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Create tasks for backend auth and frontend login page, then list tasks.`
|
||||
2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".`
|
||||
3. `Run "git status --short" in worktree "auth-refactor".`
|
||||
|
||||
+263
-63
@@ -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` 是所有课程共用的交互式 REPL(Read-Eval-Print Loop)工具类,对应 Python 版 `if __name__ == "__main__"` 中的 `input()` 循环。
|
||||
|
||||
```java
|
||||
public class AgentRunner {
|
||||
/**
|
||||
* 启动交互式 REPL 循环。
|
||||
* @param prefix 提示符前缀(如 "s01")
|
||||
* @param handler 处理用户输入并返回 Agent 响应的函数
|
||||
*/
|
||||
public static void interactive(String prefix, Function<String, String> handler) {
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
System.out.println("输入 'q' 或 'exit' 退出");
|
||||
while (true) {
|
||||
System.out.print("\033[36m" + prefix + " >> \033[0m"); // 彩色提示符
|
||||
String input;
|
||||
try {
|
||||
if (!scanner.hasNextLine()) break;
|
||||
input = scanner.nextLine().trim();
|
||||
} catch (Exception e) {
|
||||
break;
|
||||
}
|
||||
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
String response = handler.apply(input); // 调用 Agent 处理
|
||||
if (response != null && !response.isBlank()) {
|
||||
System.out.println(response);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error: " + e.getMessage());
|
||||
}
|
||||
System.out.println();
|
||||
}
|
||||
System.out.println("Bye!");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
工作流程:`Scanner` 读取输入 → `handler.apply()` 发给 Agent → 打印响应 → 循环。`handler` 是一个函数式接口,每个课程传入自己的 Agent 调用逻辑。
|
||||
|
||||
### 5. 组装为完整的 Agent 类
|
||||
|
||||
```java
|
||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
||||
public class S01AgentLoop implements CommandLineRunner {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
|
||||
public S01AgentLoop(ChatModel chatModel) {
|
||||
this.chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("You are a coding agent at ...")
|
||||
.defaultTools(new BashTool())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
AgentRunner.interactive("s01", userMessage ->
|
||||
chatClient.prompt()
|
||||
.user(userMessage)
|
||||
.call() // ← 这一个调用 = Python 的整个 while 循环
|
||||
.content()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **TIPS — Python → Java 关键适配点:**
|
||||
> - Python 的 `while True` + `stop_reason` 手动循环 → Spring AI `ChatClient.call()` 内置自动循环
|
||||
> - Python 的 `TOOLS` 数组 + `TOOL_HANDLERS` 字典 → `@Tool` 注解 + `defaultTools()` 自动注册与反射分派
|
||||
> - Python 的 `client = Anthropic()` → Spring Boot 自动配置注入 `ChatModel`
|
||||
> - Python 的 `input()` 交互 → `AgentRunner.interactive()` 封装 Scanner REPL + 函数式接口
|
||||
|
||||
不到 40 行核心代码, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。
|
||||
|
||||
## 变更内容
|
||||
|
||||
| 组件 | 之前 | 之后 |
|
||||
|---------------|------------|--------------------------------|
|
||||
| Agent loop | (无) | `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
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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 效果更好, 也可以用中文):
|
||||
|
||||
@@ -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
@@ -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 效果更好, 也可以用中文):
|
||||
|
||||
@@ -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 效果更好, 也可以用中文):
|
||||
|
||||
@@ -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 效果更好, 也可以用中文):
|
||||
|
||||
@@ -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 效果更好, 也可以用中文):
|
||||
|
||||
Reference in New Issue
Block a user