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".`
|
||||
|
||||
Reference in New Issue
Block a user