mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-03-22 02:15:42 +08:00
- 11 sessions from basic agent loop to autonomous teams - Python MVP implementations for each session - Mental-model-first docs in en/zh/ja - Interactive web platform with step-through visualizations - Incremental architecture: each session adds one mechanism
234 lines
8.6 KiB
Markdown
234 lines
8.6 KiB
Markdown
# s09: Agent Teams
|
|
|
|
> Persistent teammates with JSONL inboxes turn isolated agents into a communicating team -- spawn, message, broadcast, and drain.
|
|
|
|
## The Problem
|
|
|
|
Subagents (s04) are disposable: spawn, work, return summary, die. They
|
|
have no identity, no memory between invocations, and no way to receive
|
|
follow-up instructions. Background tasks (s08) run shell commands but
|
|
cannot make LLM-guided decisions or communicate findings.
|
|
|
|
For real teamwork you need three things: (1) persistent agents that
|
|
survive beyond a single prompt, (2) identity and lifecycle management,
|
|
and (3) a communication channel between agents. Without messaging, even
|
|
persistent teammates are deaf and mute -- they can work in parallel but
|
|
never coordinate.
|
|
|
|
The solution combines a TeammateManager for spawning persistent named
|
|
agents with a MessageBus using JSONL inbox files. Each teammate runs
|
|
its own agent loop in a thread, checks its inbox before every LLM call,
|
|
and can send messages to any other teammate or the lead.
|
|
|
|
Note on the s06-to-s07 bridge: TodoManager items from s03 die with
|
|
compression (s06). File-based tasks (s07) survive compression because
|
|
they live on disk. Teams build on this same principle -- config.json and
|
|
inbox files persist outside the context window.
|
|
|
|
## The Solution
|
|
|
|
```
|
|
Teammate lifecycle:
|
|
spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN
|
|
|
|
Communication:
|
|
.team/
|
|
config.json <- team roster + statuses
|
|
inbox/
|
|
alice.jsonl <- append-only, drain-on-read
|
|
bob.jsonl
|
|
lead.jsonl
|
|
|
|
+--------+ send("alice","bob","...") +--------+
|
|
| alice | -----------------------------> | bob |
|
|
| loop | bob.jsonl << {json_line} | loop |
|
|
+--------+ +--------+
|
|
^ |
|
|
| BUS.read_inbox("alice") |
|
|
+---- alice.jsonl -> read + drain ---------+
|
|
|
|
5 message types:
|
|
+-------------------------+------------------------------+
|
|
| message | Normal text between agents |
|
|
| broadcast | Sent to all teammates |
|
|
| shutdown_request | Request graceful shutdown |
|
|
| shutdown_response | Approve/reject shutdown |
|
|
| plan_approval_response | Approve/reject plan |
|
|
+-------------------------+------------------------------+
|
|
```
|
|
|
|
## How It Works
|
|
|
|
1. The TeammateManager maintains config.json with the team roster.
|
|
Each member has a name, role, and status.
|
|
|
|
```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 = {}
|
|
```
|
|
|
|
2. `spawn()` creates a teammate and starts its agent loop in a thread.
|
|
Re-spawning an idle teammate reactivates it.
|
|
|
|
```python
|
|
def spawn(self, name: str, role: str, prompt: str) -> str:
|
|
member = self._find_member(name)
|
|
if member:
|
|
if member["status"] not in ("idle", "shutdown"):
|
|
return f"Error: '{name}' is currently {member['status']}"
|
|
member["status"] = "working"
|
|
else:
|
|
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)
|
|
self.threads[name] = thread
|
|
thread.start()
|
|
return f"Spawned teammate '{name}' (role: {role})"
|
|
```
|
|
|
|
3. The MessageBus handles JSONL inbox files. `send()` appends a JSON
|
|
line; `read_inbox()` reads all lines and drains the file.
|
|
|
|
```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")
|
|
return f"Sent {msg_type} to {to}"
|
|
|
|
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)
|
|
```
|
|
|
|
4. Each teammate checks its inbox before every LLM call and injects
|
|
received messages into the conversation context.
|
|
|
|
```python
|
|
def _teammate_loop(self, name, role, prompt):
|
|
sys_prompt = f"You are '{name}', role: {role}, at {WORKDIR}."
|
|
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(
|
|
model=MODEL, system=sys_prompt,
|
|
messages=messages, tools=TOOLS)
|
|
messages.append({"role": "assistant",
|
|
"content": response.content})
|
|
if response.stop_reason != "tool_use":
|
|
break
|
|
# execute tools, append results...
|
|
self._find_member(name)["status"] = "idle"
|
|
self._save_config()
|
|
```
|
|
|
|
5. `broadcast()` sends the same message to all teammates except the
|
|
sender.
|
|
|
|
```python
|
|
def broadcast(self, sender, content, teammates):
|
|
count = 0
|
|
for name in teammates:
|
|
if name != sender:
|
|
self.send(sender, name, content, "broadcast")
|
|
count += 1
|
|
return f"Broadcast to {count} teammates"
|
|
```
|
|
|
|
## Key Code
|
|
|
|
The TeammateManager + MessageBus core (from `agents/s09_agent_teams.py`):
|
|
|
|
```python
|
|
class TeammateManager:
|
|
def spawn(self, name, role, prompt):
|
|
member = self._find_member(name) or {
|
|
"name": name, "role": role, "status": "working"
|
|
}
|
|
member["status"] = "working"
|
|
self._save_config()
|
|
thread = threading.Thread(
|
|
target=self._teammate_loop,
|
|
args=(name, role, prompt), daemon=True)
|
|
thread.start()
|
|
return f"Spawned '{name}'"
|
|
|
|
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")
|
|
|
|
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("")
|
|
return json.dumps(msgs, indent=2)
|
|
```
|
|
|
|
## 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 | 5 message types + broadcast|
|
|
|
|
Teaching simplification: this implementation does not use lock files
|
|
for inbox access. In production, concurrent append from multiple writers
|
|
would need file locking or atomic rename. The single-writer-per-inbox
|
|
pattern used here is safe for the teaching scenario.
|
|
|
|
## Design Rationale
|
|
|
|
File-based mailboxes (append-only JSONL) provide concurrency-safe inter-agent communication. Append is atomic on most filesystems, avoiding lock contention. The "drain on read" pattern (read all, truncate) gives batch delivery. This is simpler and more robust than shared memory or socket-based IPC for agent coordination. The tradeoff is latency -- messages are only seen at the next poll -- but for LLM-driven agents where each turn takes seconds, polling latency is negligible compared to inference time.
|
|
|
|
## Try It
|
|
|
|
```sh
|
|
cd learn-claude-code
|
|
python agents/s09_agent_teams.py
|
|
```
|
|
|
|
Example prompts to try:
|
|
|
|
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
|
|
2. `Broadcast "status update: phase 1 complete" to all teammates`
|
|
3. `Check the lead inbox for any messages`
|
|
4. Type `/team` to see the team roster with statuses
|
|
5. Type `/inbox` to manually check the lead's inbox
|