mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-06-20 20:23:36 +08:00
* feat: s01-s14 docs quality overhaul — tool pipeline, single-agent, knowledge & resilience Rewrite code.py and README (zh/en/ja) for s01-s14, each chapter building incrementally on the previous. Key fixes across chapters: - s01-s04: agent loop, tool dispatch, permission pipeline, hooks - s05-s08: todo write, subagent, skill loading, context compact - s09-s11: memory system, system prompt assembly, error recovery - s12-s14: task graph, background tasks, cron scheduler All chapters CC source-verified. Code inherits fixes forward (PROMPT_SECTIONS, json.dumps cache, real-state context, can_start dep protection, etc.). * feat: s15-s19 docs quality overhaul — multi-agent platform: teams, protocols, autonomy, worktree, MCP tools Rewrite code.py and README (zh/en/ja) for s15-s19, the multi-agent platform chapters. Each chapter inherits all previous fixes and adds one mechanism: - s15: agent teams (TeamCreate, teammate threads, shared task list) - s16: team protocols (plan approval, shutdown handshake, consume_inbox) - s17: autonomous agents (idle polling, auto-claim, consume_lead_inbox) - s18: worktree isolation (git worktree, bind_task, cwd switching, safety) - s19: MCP tools (MCPClient, normalize_mcp_name, assemble_tool_pool, no cache) All appendix source code references verified against CC source. Config priority corrected: claude.ai < plugin < user < project < local. * fix: 5 regressions across s05-s19 — glob safety, todo validation, memory extraction, protocol types, dep crash - s05-s09: glob results now filter with is_relative_to(WORKDIR) (inherited from s02) - s06-s08: todo_write validates content/status required fields (inherited from s05) - s09: extract_memories uses pre-compression snapshot instead of compacted messages - s16: submit_plan docstring clarifies protocol-only (not code-level gate) - s17-s19: match_response restores type mismatch validation (from s16) - s17-s19: claim_task deps list handles missing dep files without crashing * fix: s12 Todo V2 logic reversal, s14/s15 cron range validation, s18/s19 worktree name validation - s12 README (zh/en/ja): fix Todo V2 direction — interactive defaults to Task, non-interactive/SDK defaults to TodoWrite. Fix env var name to CLAUDE_CODE_ENABLE_TASKS (not TODO_V2). - s14/s15: add _validate_cron_field with per-field range checks (minute 0-59, hour 0-23, dom 1-31, month 1-12, dow 0-6), step > 0, range lo <= hi. Replace old try/except validation that only caught exceptions. - s18/s19: add validate_worktree_name() to remove_worktree and keep_worktree, not just create_worktree. * fix: align s16-s19 teaching tool consistency * fix pr265 chapter diagrams * Add comprehensive s20 harness chapter * Fix chapter smoke test regressions * Clarify README tutorial track transition --------- Co-authored-by: Haoran <bill-billion@outlook.com>
This commit is contained in:
271
s17_autonomous_agents/README.en.md
Normal file
271
s17_autonomous_agents/README.en.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# s17: Autonomous Agents — Check the Board, Claim the Task
|
||||
|
||||
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20
|
||||
|
||||
> *"Check the board, claim the task"* — poll when idle, work when found.
|
||||
>
|
||||
> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed.
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
s16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks — with 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done.
|
||||
|
||||
---
|
||||
|
||||
## The Solution
|
||||
|
||||

|
||||
|
||||
Carries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed).
|
||||
|
||||
Teammate lifecycle expands from two phases to three:
|
||||
|
||||
| Phase | Behavior | Exit condition |
|
||||
|-------|----------|----------------|
|
||||
| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` |
|
||||
| IDLE | 5s poll inbox + task board | 60s timeout |
|
||||
| SHUTDOWN | Send summary, exit | — |
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### idle_poll: Idle Polling
|
||||
|
||||
After completing a task, the teammate doesn't exit. It enters the IDLE phase — checking every 5 seconds for new work:
|
||||
|
||||
```python
|
||||
IDLE_POLL_INTERVAL = 5 # seconds
|
||||
IDLE_TIMEOUT = 60 # seconds
|
||||
|
||||
def idle_poll(agent_name, messages, name, role) -> str:
|
||||
"""Return 'work', 'shutdown', or 'timeout'."""
|
||||
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
|
||||
time.sleep(IDLE_POLL_INTERVAL)
|
||||
|
||||
# ① Check inbox (priority)
|
||||
inbox = BUS.read_inbox(agent_name)
|
||||
if inbox:
|
||||
# shutdown_request handled immediately
|
||||
for msg in inbox:
|
||||
if msg.get("type") == "shutdown_request":
|
||||
# ... reply shutdown_response
|
||||
return "shutdown"
|
||||
# Regular messages: inject into context, return to WORK
|
||||
messages.append(...)
|
||||
return "work"
|
||||
|
||||
# ② Scan task board
|
||||
unclaimed = scan_unclaimed_tasks()
|
||||
if unclaimed:
|
||||
task = unclaimed[0]
|
||||
result = claim_task(task["id"], agent_name)
|
||||
if "Claimed" in result:
|
||||
messages.append(...)
|
||||
return "work"
|
||||
return "timeout"
|
||||
```
|
||||
|
||||
Inbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately — no need to wait for the next WORK phase.
|
||||
|
||||
### scan_unclaimed_tasks: Scan the Task Board
|
||||
|
||||
Find tasks that are pending, unowned, with all dependencies completed (`can_start`):
|
||||
|
||||
```python
|
||||
def scan_unclaimed_tasks() -> list[dict]:
|
||||
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 can_start(task["id"])):
|
||||
unclaimed.append(task)
|
||||
return unclaimed
|
||||
```
|
||||
|
||||
Three conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status — having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; CC uses file locks to prevent multiple teammates from claiming the same task.
|
||||
|
||||
### claim_task: Owner Check
|
||||
|
||||
Auto-claim checks the claim result, not treating failure as success:
|
||||
|
||||
```python
|
||||
def claim_task(task_id: str, owner: str = "agent") -> str:
|
||||
task = load_task(task_id)
|
||||
if task.status != "pending":
|
||||
return f"Task {task_id} is {task.status}, cannot claim"
|
||||
if task.owner:
|
||||
return f"Task {task_id} already owned by {task.owner}"
|
||||
if not can_start(task_id):
|
||||
return f"Blocked by: {deps}"
|
||||
task.owner = owner
|
||||
task.status = "in_progress"
|
||||
save_task(task)
|
||||
return f"Claimed {task.id} ({task.subject})"
|
||||
```
|
||||
|
||||
Teaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious "last writer wins" problem. CC uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`).
|
||||
|
||||
### Teammate Lifecycle: WORK → IDLE → SHUTDOWN
|
||||
|
||||
s16's teammates exit after finishing. s17 adds the IDLE phase — teammates cycle through WORK → IDLE in an outer loop:
|
||||
|
||||
```python
|
||||
# Outer loop: WORK → IDLE cycle
|
||||
while True:
|
||||
# WORK phase: inner loop (max 10 LLM rounds)
|
||||
for _ in range(10):
|
||||
# Check inbox, dispatch protocol, call LLM, execute tools
|
||||
...
|
||||
if response.stop_reason != "tool_use":
|
||||
break # WORK phase ends
|
||||
|
||||
# IDLE phase
|
||||
idle_result = idle_poll(name, messages, name, role)
|
||||
if idle_result == "shutdown":
|
||||
break
|
||||
if idle_result == "timeout":
|
||||
break # 60s timeout → SHUTDOWN
|
||||
|
||||
# SHUTDOWN: send summary to Lead
|
||||
BUS.send(name, "lead", summary, "result")
|
||||
```
|
||||
|
||||
Key design:
|
||||
- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request
|
||||
- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops)
|
||||
- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits
|
||||
- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly
|
||||
|
||||
### Identity Re-injection
|
||||
|
||||
After autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check:
|
||||
|
||||
```python
|
||||
if len(messages) <= 3:
|
||||
messages.insert(0, {"role": "user",
|
||||
"content": f"<identity>You are '{name}', role: {role}. "
|
||||
f"Continue your work.</identity>"})
|
||||
```
|
||||
|
||||
Short messages suggest compression happened — re-inject identity. In real CC, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling.
|
||||
|
||||
### consume_lead_inbox: Unified Inbox Consumer
|
||||
|
||||
Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal — Lead's LLM can see them and coordinate next steps.
|
||||
|
||||
### Putting It Together
|
||||
|
||||
```
|
||||
1. Lead: "Build the backend — too many tasks, let teammates self-claim"
|
||||
2. Lead → create_task("Create database schema")
|
||||
3. Lead → create_task("Write API routes")
|
||||
4. Lead → create_task("Write unit tests")
|
||||
5. Lead → spawn_teammate("alice", "backend", "You are a backend developer")
|
||||
6. Lead → spawn_teammate("bob", "backend", "You are a backend developer")
|
||||
|
||||
7. alice thread starts → WORK: no initial inbox → spins → IDLE
|
||||
8. bob thread starts → WORK: no initial inbox → spins → IDLE
|
||||
|
||||
9. alice IDLE poll 1 → scan_unclaimed → finds "Create database schema"
|
||||
10. alice → claim_task → "Create database schema" → back to WORK
|
||||
11. bob IDLE poll 1 → scan_unclaimed → finds "Write API routes"
|
||||
12. bob → claim_task → "Write API routes" → back to WORK
|
||||
|
||||
13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK ends
|
||||
14. alice IDLE → scan → "Write unit tests" → claim → WORK
|
||||
15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK ends
|
||||
16. alice IDLE → 60s no new tasks → SHUTDOWN
|
||||
|
||||
17. bob similar flow → done → SHUTDOWN
|
||||
18. Lead consume_lead_inbox → sees alice and bob's summaries
|
||||
```
|
||||
|
||||
Two teammates claim and work in parallel. Lead only creates tasks and spawns teammates — no manual assignment needed.
|
||||
|
||||
---
|
||||
|
||||
## Changes from s16
|
||||
|
||||
| Component | Before (s16) | After (s17) |
|
||||
|-----------|-------------|-------------|
|
||||
| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) |
|
||||
| Teammate state | WORK or exit | WORK → IDLE (60s poll) → SHUTDOWN |
|
||||
| claim_task | No owner check | Rejects tasks that already have an owner |
|
||||
| IDLE phase shutdown | Doesn't handle shutdown_request | Dispatches shutdown immediately and exits |
|
||||
| Lead inbox | Prints only, not in context | consume_lead_inbox injects into history |
|
||||
| New functions | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox |
|
||||
| Identity persistence | System prompt only | Auto re-inject after compression |
|
||||
| Lead tools | 14 (s16) | 14 (unchanged) |
|
||||
| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) |
|
||||
| Teammate exit | Exit after task done | Exit only after 60s idle timeout |
|
||||
|
||||
---
|
||||
|
||||
## Try It
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python s17_autonomous_agents/code.py
|
||||
```
|
||||
|
||||
Try this prompt:
|
||||
|
||||
`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
|
||||
|
||||
What to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`?
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
Teammates self-organize now. But Alice and Bob both work in the same directory — Alice edits `config.py`, Bob also edits `config.py`, overwriting each other.
|
||||
|
||||
s18 Worktree Isolation → Each task gets its own working directory, no conflicts.
|
||||
|
||||
<details>
|
||||
<summary>Deep Dive into CC Source</summary>
|
||||
|
||||
> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate "find work when idle." CC's actual implementation combines multiple mechanisms, but shares the same goal — reducing Lead's manual assignment burden.
|
||||
|
||||
### 1. CC's Idle Mechanism: Combined Approach, Not Single Polling
|
||||
|
||||
Teaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. CC's actual implementation combines four mechanisms:
|
||||
|
||||
**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown.
|
||||
|
||||
**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages.
|
||||
|
||||
**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies "no incomplete tasks in blockedBy", not "blockedBy is empty".
|
||||
|
||||
**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`) — actively claiming tasks from the task list while waiting. So "teammates don't actively poll for tasks" is inaccurate; CC has both passive notification and active claiming.
|
||||
|
||||
### 2. Task Claiming: File Locks + Atomic Operations
|
||||
|
||||
`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU.
|
||||
|
||||
`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks "all blockedBy completed" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change.
|
||||
|
||||
### 3. Teaching Version vs CC Comparison
|
||||
|
||||
| Dimension | Teaching (s17) | CC |
|
||||
|-----------|----------------|-----|
|
||||
| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher |
|
||||
| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) |
|
||||
| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) |
|
||||
| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock |
|
||||
| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request |
|
||||
| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown |
|
||||
| Identity persistence | Messages length detection | Context compaction preserves system prompt |
|
||||
| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity |
|
||||
|
||||
Teaching version's `idle_poll()` merges CC's four mechanisms into one polling function — a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent.
|
||||
|
||||
</details>
|
||||
|
||||
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
|
||||
271
s17_autonomous_agents/README.ja.md
Normal file
271
s17_autonomous_agents/README.ja.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# s17: Autonomous Agents — ボードを見て、自分で認領
|
||||
|
||||
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20
|
||||
|
||||
> *"ボードを見て、自分で認領"* — 空き時にポーリング、仕事があれば開始。
|
||||
>
|
||||
> **Harness 層**: 自治 — チームメイトが自己組織化、リーダーの割り当て不要。
|
||||
|
||||
---
|
||||
|
||||
## 課題
|
||||
|
||||
s16 のチームメイトは通信でき、シャットダウンハンドシェイクもできる。しかし各チームメイトは Lead がタスクを割り当てるのを待つ——ボードに 10 個の未認領タスクがあれば、Lead は 10 回手動で assign しなければならない。これはスケールしない。チームメイトは自分でタスクボードを見て、未認領のタスクを見つけて認領し、終わったら次を探すべき。
|
||||
|
||||
---
|
||||
|
||||
## ソリューション
|
||||
|
||||

|
||||
|
||||
S16 の教学版 MessageBus とプロトコルツールを踏襲。本章の追加:**idle_poll**(空き時に 5 秒ごとにポーリング)、**scan_unclaimed_tasks**(ボード上の認領可能なタスクをスキャン)、**自動認領**(見つけたら即座に claim、Lead 不要)。
|
||||
|
||||
チームメイトのライフサイクルは 2 フェーズから 3 フェーズに:
|
||||
|
||||
| フェーズ | 動作 | 終了条件 |
|
||||
|----------|------|---------|
|
||||
| WORK | inbox → LLM → ツールループ | `stop_reason != tool_use` |
|
||||
| IDLE | 5s ポーリング inbox + タスクボード | 60s タイムアウト |
|
||||
| SHUTDOWN | summary を送信、終了 | — |
|
||||
|
||||
---
|
||||
|
||||
## 仕組み
|
||||
|
||||
### idle_poll: 空き時ポーリング
|
||||
|
||||
チームメイトはタスク完了後も終了せず、IDLE フェーズに入る——5 秒ごとに新しい仕事がないか確認:
|
||||
|
||||
```python
|
||||
IDLE_POLL_INTERVAL = 5 # seconds
|
||||
IDLE_TIMEOUT = 60 # seconds
|
||||
|
||||
def idle_poll(agent_name, messages, name, role) -> str:
|
||||
"""Return 'work', 'shutdown', or 'timeout'."""
|
||||
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
|
||||
time.sleep(IDLE_POLL_INTERVAL)
|
||||
|
||||
# ① 受信箱確認(優先)
|
||||
inbox = BUS.read_inbox(agent_name)
|
||||
if inbox:
|
||||
# shutdown_request は即座に処理
|
||||
for msg in inbox:
|
||||
if msg.get("type") == "shutdown_request":
|
||||
# ... shutdown_response 返信
|
||||
return "shutdown"
|
||||
# 通常メッセージ:コンテキストに注入、WORK に戻る
|
||||
messages.append(...)
|
||||
return "work"
|
||||
|
||||
# ② タスクボードスキャン
|
||||
unclaimed = scan_unclaimed_tasks()
|
||||
if unclaimed:
|
||||
task = unclaimed[0]
|
||||
result = claim_task(task["id"], agent_name)
|
||||
if "Claimed" in result:
|
||||
messages.append(...)
|
||||
return "work"
|
||||
return "timeout"
|
||||
```
|
||||
|
||||
inbox を優先(shutdown_request 等のプロトコルメッセージの可能性)、タスクボードが次。IDLE フェーズで shutdown_request を受信すると即座に返信して終了し、次の WORK を待つ必要がない。
|
||||
|
||||
### scan_unclaimed_tasks: タスクボードスキャン
|
||||
|
||||
pending 状態、owner なし、全依存関係完了(`can_start`)のタスクを検索:
|
||||
|
||||
```python
|
||||
def scan_unclaimed_tasks() -> list[dict]:
|
||||
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 can_start(task["id"])):
|
||||
unclaimed.append(task)
|
||||
return unclaimed
|
||||
```
|
||||
|
||||
3 つの条件:pending であること、owner がないこと、全 blockedBy 依存が完了していること。`can_start` は依存タスクの状態を確認——依存があるからといってタスクを開始できないわけではなく、未解決の依存のみがブロックする。教学版はファイル名順で最初のものを選択、CC はファイルロックで複数チームメイトの同時認領を防止。
|
||||
|
||||
### claim_task: owner チェック
|
||||
|
||||
自動認領時に claim 結果を確認し、失敗を成功として扱わない:
|
||||
|
||||
```python
|
||||
def claim_task(task_id: str, owner: str = "agent") -> str:
|
||||
task = load_task(task_id)
|
||||
if task.status != "pending":
|
||||
return f"Task {task_id} is {task.status}, cannot claim"
|
||||
if task.owner:
|
||||
return f"Task {task_id} already owned by {task.owner}"
|
||||
if not can_start(task_id):
|
||||
return f"Blocked by: {deps}"
|
||||
task.owner = owner
|
||||
task.status = "in_progress"
|
||||
save_task(task)
|
||||
return f"Claimed {task.id} ({task.subject})"
|
||||
```
|
||||
|
||||
教学版にはファイルロックがないため、並行認領で競合する可能性がある。しかし `task.owner` チェックで最も明白な「後書き上書き」問題を回避。CC は `proper-lockfile` でタスクファイルを保護、`claimTask` はファイルロック内で read-modify-write を実行(`utils/tasks.ts:541-612`)。
|
||||
|
||||
### チームメイトライフサイクル: WORK → IDLE → SHUTDOWN
|
||||
|
||||
s16 のチームメイトはタスク完了後に終了。s17 は IDLE フェーズを追加——外側ループで WORK → IDLE を繰り返す:
|
||||
|
||||
```python
|
||||
# 外側ループ: WORK → IDLE サイクル
|
||||
while True:
|
||||
# WORK フェーズ: 内側ループ(最大 10 ラウンド LLM 呼び出し)
|
||||
for _ in range(10):
|
||||
# inbox 確認、プロトコルメッセージ処理、LLM 呼び出し、ツール実行
|
||||
...
|
||||
if response.stop_reason != "tool_use":
|
||||
break # WORK フェーズ終了
|
||||
|
||||
# IDLE フェーズ
|
||||
idle_result = idle_poll(name, messages, name, role)
|
||||
if idle_result == "shutdown":
|
||||
break
|
||||
if idle_result == "timeout":
|
||||
break # 60s タイムアウト → SHUTDOWN
|
||||
|
||||
# SHUTDOWN: summary を Lead に送信
|
||||
BUS.send(name, "lead", summary, "result")
|
||||
```
|
||||
|
||||
主要設計:
|
||||
- **外側 while True**:WORK と IDLE がタイムアウトまたはシャットダウン要求まで交互に続く
|
||||
- **内側 for 10**:WORK フェーズは最大 10 ラウンドの LLM 呼び出し(無限ループ防止)
|
||||
- **IDLE タイムアウト 60 秒**:12 回ポーリング × 5 秒 = 60 秒。タイムアウト後 summary を送信して終了
|
||||
- **shutdown_request は両フェーズで応答**:WORK フェーズは `handle_inbox_message` でディスパッチ、IDLE フェーズは `idle_poll` が直接確認して返信
|
||||
|
||||
### 身份再注入
|
||||
|
||||
autoCompact(s08)後、チームメイトの messages リストが要約に圧縮される可能性がある。新しい WORK フェーズに入るたびに確認:
|
||||
|
||||
```python
|
||||
if len(messages) <= 3:
|
||||
messages.insert(0, {"role": "user",
|
||||
"content": f"<identity>You are '{name}', role: {role}. "
|
||||
f"Continue your work.</identity>"})
|
||||
```
|
||||
|
||||
メッセージが短い場合、圧縮が発生したことを示す——身份情報を再注入。真实 CC では context compaction が system prompt を保持、教学版の簡略実装は手動処理が必要。
|
||||
|
||||
### consume_lead_inbox: 統一 inbox コンシューマ
|
||||
|
||||
`check_inbox` ツールとメインループ末尾の両方が同じ `consume_lead_inbox()` 関数を呼び出す:プロトコル response を先にルーティングして状態を更新し、全メッセージを Lead の会話履歴に注入。チームメイトからの summary/result は端末に表示されるだけでなく、Lead の LLM も確認して次のステップを調整可能。
|
||||
|
||||
### 組み合わせて実行
|
||||
|
||||
```
|
||||
1. Lead: "バックエンド構築——タスクが多すぎる、チームメイトに自己認領させる"
|
||||
2. Lead → create_task("データベーススキーマを作成")
|
||||
3. Lead → create_task("API ルートを書く")
|
||||
4. Lead → create_task("ユニットテストを書く")
|
||||
5. Lead → spawn_teammate("alice", "backend", "あなたはバックエンド開発者")
|
||||
6. Lead → spawn_teammate("bob", "backend", "あなたはバックエンド開発者")
|
||||
|
||||
7. alice スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE
|
||||
8. bob スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE
|
||||
|
||||
9. alice IDLE ポーリング 1 回目 → scan_unclaimed → "データベーススキーマを作成" を発見
|
||||
10. alice → claim_task → "データベーススキーマを作成" → WORK に戻る
|
||||
11. bob IDLE ポーリング 1 回目 → scan_unclaimed → "API ルートを書く" を発見
|
||||
12. bob → claim_task → "API ルートを書く" → WORK に戻る
|
||||
|
||||
13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK 終了
|
||||
14. alice IDLE → scan → "ユニットテストを書く" → claim → WORK
|
||||
15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK 終了
|
||||
16. alice IDLE → 60s 新しいタスクなし → SHUTDOWN
|
||||
|
||||
17. bob も同様のフロー → 完了 → SHUTDOWN
|
||||
18. Lead consume_lead_inbox → alice と bob の summary を確認
|
||||
```
|
||||
|
||||
2 人のチームメイトが並行して認領・作業。Lead はタスクを作成してチームメイトを起動するだけで、手動割り当て不要。
|
||||
|
||||
---
|
||||
|
||||
## s16 からの変更
|
||||
|
||||
| コンポーネント | 変更前 (s16) | 変更後 (s17) |
|
||||
|--------------|------------|------------|
|
||||
| タスク割り当て | Lead が手動 assign | チームメイトが自動認領(can_start で依存確認) |
|
||||
| チームメイト状態 | WORK または終了 | WORK → IDLE(60s ポーリング) → SHUTDOWN |
|
||||
| claim_task | owner チェックなし | 既に owner があるタスクを拒否 |
|
||||
| IDLE フェーズシャットダウン | shutdown_request を処理しない | 即座にシャットダウンをディスパッチして終了 |
|
||||
| Lead inbox | 印刷のみ、コンテキストに入らない | consume_lead_inbox で history に注入 |
|
||||
| 新規関数 | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox |
|
||||
| 身份保持 | system prompt のみ | 圧縮後に自動再注入 |
|
||||
| Lead ツール | 14 (s16) | 14(変更なし) |
|
||||
| チームメイトツール | 5 | 8(+ list_tasks, claim_task, complete_task) |
|
||||
| チームメイト終了条件 | タスク完了後即終了 | 60s アイドルタイムアウト後のみ終了 |
|
||||
|
||||
---
|
||||
|
||||
## 試してみる
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python s17_autonomous_agents/code.py
|
||||
```
|
||||
|
||||
以下のプロンプトを試してください:
|
||||
|
||||
`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
|
||||
|
||||
観察ポイント:チームメイトは未割り当てのタスクを自動認領したか?blockedBy 依存のあるタスクは依存完了後に正しく認領されたか?アイドルタイムアウトでシャットダウンしたか?IDLE フェーズで shutdown_request に即座に応答したか?`.tasks/` ディレクトリのタスク状態はどう変化したか?
|
||||
|
||||
---
|
||||
|
||||
## 次の章
|
||||
|
||||
チームメイトが自己組織化した。しかし Alice も Bob も同じディレクトリで作業——Alice が `config.py` を編集し、Bob も `config.py` を編集して互いに上書きしてしまう。
|
||||
|
||||
s18 Worktree Isolation → 各タスクに専用の作業ディレクトリ、競合なし。
|
||||
|
||||
<details>
|
||||
<summary>CC ソースコード深掘り</summary>
|
||||
|
||||
> 教学注記:本章の idle_poll + auto-claim 機構は教学設計であり、統一ポーリング関数で「空き時に仕事を探す」をデモ。CC の実際の実装は複数機構の組み合わせだが、目標は同じ——Lead の手動割り当て負担を軽減。
|
||||
|
||||
### 一、CC の空き機構:組み合わせ路径、単一ポーリングではない
|
||||
|
||||
教学版は 1 つの `idle_poll()` で空き時の inbox 確認とタスク認領を統一処理。CC の実際の実装は 4 つの機構の組み合わせ:
|
||||
|
||||
**idle_notification**:チームメイトが 1 ラウンドの作業を完了後、`sendIdleNotification()`(`inProcessRunner.ts:569-589`)が Lead に空き通知を送信。Lead はチームメイトが利用可能であることを知り、新しいタスクを割り当てたりシャットダウンを要求可能。
|
||||
|
||||
**mailbox ポーリング**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)は **500ms ポーリングループ**で、3 つのソースを継続チェック:pending user messages、mailbox ファイルメッセージ、task list。shutdown_request は優先処理(`inProcessRunner.ts:768-804`)、通常メッセージによる飢餓を防止。
|
||||
|
||||
**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)が `fs.watch()` で `.claude/tasks/` ディレクトリの変化を監視、1 秒 debounce で新タスク作成や依存アンロック時にチェックをトリガー。依存判断(`L197-207`)は「blockedBy に未完了タスクがない」で、「blockedBy が空」ではない。
|
||||
|
||||
**能動 claim**:ポーリングループ内でも `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)を呼び出し——待機中に task list から能動的にタスクを認領。したがって「チームメイトは能動的にタスクをポーリングしない」は不正確、CC は受動通知と能動認領の両方を持つ。
|
||||
|
||||
### 二、タスク認領:ファイルロック + 原子操作
|
||||
|
||||
`claimTask()`(`utils/tasks.ts:541-612`)は `proper-lockfile` のタスクファイルロックを使用、ロック内で read-check-modify-write を実行。チェック項目:owner が既に存在(`L575-576`)、完了済み(`L580-581`)、blockedBy に未完了タスクがあるか(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)はタスクリストレベルロックを使用、busy check と claim を原子操作にして TOCTOU を回避。
|
||||
|
||||
`findAvailableTask()`(`inProcessRunner.ts:595-604`)の依存判断も「全 blockedBy 完了」で、`task.blockedBy.every(id => !unresolvedTaskIds.has(id))` で実装。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)は認領後 status を `in_progress` に更新、UI に即座に反映。
|
||||
|
||||
### 三、教学版 vs CC 対比
|
||||
|
||||
| 次元 | 教学版 (s17) | CC |
|
||||
|------|-------------|-----|
|
||||
| 空き機構 | idle_poll 統一ポーリング(5s) | idle_notification + 500ms mailbox ポーリング + task watcher |
|
||||
| タスク発見 | scan_unclaimed_tasks(ポーリング) | useTaskListWatcher(ファイル監視)+ tryClaimNextTask(能動ポーリング) |
|
||||
| 依存チェック | can_start(全 blockedBy 完了) | findAvailableTask(同じセマンティクス) |
|
||||
| 並行安全性 | owner チェック(ファイルロックなし) | proper-lockfile タスクロック + タスクリストロック |
|
||||
| shutdown 処理 | IDLE 直接ディスパッチ、WORK は handle_inbox_message | 500ms ポーリングループで shutdown_request を優先 |
|
||||
| タイムアウト終了 | 60s 新しいタスクなし | 固定タイムアウトなし、Lead 手動 shutdown |
|
||||
| 身份保持 | messages 長さ検出 | context compaction が system prompt を保持 |
|
||||
| claim 失敗処理 | 戻り値を確認、失敗時はスキップ | ファイルロックで原子性を保証 |
|
||||
|
||||
教学版の `idle_poll()` は CC の 4 つの機構を 1 つのポーリング関数に統合——核心セマンティクス(空き時に仕事を探す、依存アンロック後に認領、shutdown 優先)が一致するため、合理的な簡略化。
|
||||
|
||||
</details>
|
||||
|
||||
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
|
||||
271
s17_autonomous_agents/README.md
Normal file
271
s17_autonomous_agents/README.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# s17: Autonomous Agents — 自己看板,自己认领
|
||||
|
||||
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20
|
||||
|
||||
> *"自己看板,自己认领"* — 空闲时轮询,有活就干。
|
||||
>
|
||||
> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。
|
||||
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
s16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务——如果任务看板上有 10 个未认领任务,Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||

|
||||
|
||||
沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim,不用 Lead 操心)。
|
||||
|
||||
队友生命周期从两阶段变成三阶段:
|
||||
|
||||
| 阶段 | 行为 | 退出条件 |
|
||||
|------|------|---------|
|
||||
| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` |
|
||||
| IDLE | 每 5s 轮询 inbox + 任务板 | 60s 超时 |
|
||||
| SHUTDOWN | 发 summary,退出 | — |
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
|
||||
### idle_poll: 空闲轮询
|
||||
|
||||
队友完成当前任务后不退出,进入 IDLE 阶段——每 5 秒检查一次有没有新工作:
|
||||
|
||||
```python
|
||||
IDLE_POLL_INTERVAL = 5 # seconds
|
||||
IDLE_TIMEOUT = 60 # seconds
|
||||
|
||||
def idle_poll(agent_name, messages, name, role) -> str:
|
||||
"""Return 'work', 'shutdown', or 'timeout'."""
|
||||
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
|
||||
time.sleep(IDLE_POLL_INTERVAL)
|
||||
|
||||
# ① 检查收件箱(优先)
|
||||
inbox = BUS.read_inbox(agent_name)
|
||||
if inbox:
|
||||
# shutdown_request 立即处理
|
||||
for msg in inbox:
|
||||
if msg.get("type") == "shutdown_request":
|
||||
# ... 回复 shutdown_response
|
||||
return "shutdown"
|
||||
# 普通消息注入上下文,回到 WORK
|
||||
messages.append(...)
|
||||
return "work"
|
||||
|
||||
# ② 扫描任务看板
|
||||
unclaimed = scan_unclaimed_tasks()
|
||||
if unclaimed:
|
||||
task = unclaimed[0]
|
||||
result = claim_task(task["id"], agent_name)
|
||||
if "Claimed" in result:
|
||||
messages.append(...)
|
||||
return "work"
|
||||
return "timeout"
|
||||
```
|
||||
|
||||
inbox 优先(可能包含 shutdown_request 等协议消息),任务板其次。IDLE 阶段收到 shutdown_request 会直接回复并退出,不等到下一轮 WORK。
|
||||
|
||||
### scan_unclaimed_tasks: 扫描任务看板
|
||||
|
||||
找 pending 状态、无 owner、所有依赖已完成(`can_start`)的任务:
|
||||
|
||||
```python
|
||||
def scan_unclaimed_tasks() -> list[dict]:
|
||||
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 can_start(task["id"])):
|
||||
unclaimed.append(task)
|
||||
return unclaimed
|
||||
```
|
||||
|
||||
三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态——有依赖不代表不能做,只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个;CC 用文件锁防止多个队友同时认领同一个任务。
|
||||
|
||||
### claim_task: owner 检查
|
||||
|
||||
自动认领时检查 claim 结果,不把失败当成功:
|
||||
|
||||
```python
|
||||
def claim_task(task_id: str, owner: str = "agent") -> str:
|
||||
task = load_task(task_id)
|
||||
if task.status != "pending":
|
||||
return f"Task {task_id} is {task.status}, cannot claim"
|
||||
if task.owner:
|
||||
return f"Task {task_id} already owned by {task.owner}"
|
||||
if not can_start(task_id):
|
||||
return f"Blocked by: {deps}"
|
||||
task.owner = owner
|
||||
task.status = "in_progress"
|
||||
save_task(task)
|
||||
return f"Claimed {task.id} ({task.subject})"
|
||||
```
|
||||
|
||||
教学版没有文件锁,并发认领可能出现竞争。但至少 `task.owner` 检查避免了最明显的"后写覆盖"问题。CC 用 `proper-lockfile` 保护任务文件,`claimTask` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。
|
||||
|
||||
### 队友生命周期: WORK → IDLE → SHUTDOWN
|
||||
|
||||
s16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE:
|
||||
|
||||
```python
|
||||
# Outer loop: WORK → IDLE cycle
|
||||
while True:
|
||||
# WORK phase: 内层循环(最多 10 轮 LLM 调用)
|
||||
for _ in range(10):
|
||||
# 检查 inbox、处理协议消息、调 LLM、执行工具
|
||||
...
|
||||
if response.stop_reason != "tool_use":
|
||||
break # WORK 阶段结束
|
||||
|
||||
# IDLE phase
|
||||
idle_result = idle_poll(name, messages, name, role)
|
||||
if idle_result == "shutdown":
|
||||
break
|
||||
if idle_result == "timeout":
|
||||
break # 60s 超时 → SHUTDOWN
|
||||
|
||||
# SHUTDOWN: 发 summary 给 Lead
|
||||
BUS.send(name, "lead", summary, "result")
|
||||
```
|
||||
|
||||
关键设计:
|
||||
- **外层 while True**:WORK 和 IDLE 交替进行,直到超时或收到关机请求
|
||||
- **内层 for 10**:WORK 阶段最多 10 轮 LLM 调用(防止无限循环)
|
||||
- **IDLE 超时 60 秒**:12 次轮询 × 5 秒 = 60 秒。超时后发送 summary 并退出
|
||||
- **shutdown_request 两阶段都能响应**:WORK 阶段通过 `handle_inbox_message` 分发;IDLE 阶段 `idle_poll` 直接检查并回复
|
||||
|
||||
### 身份重注入
|
||||
|
||||
autoCompact(s08)之后,队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查:
|
||||
|
||||
```python
|
||||
if len(messages) <= 3:
|
||||
messages.insert(0, {"role": "user",
|
||||
"content": f"<identity>You are '{name}', role: {role}. "
|
||||
f"Continue your work.</identity>"})
|
||||
```
|
||||
|
||||
消息过短说明发生了压缩,此时重新注入身份信息。真实 CC 中 context compaction 会保留 system prompt,教学版的简化实现需要手动处理。
|
||||
|
||||
### consume_lead_inbox: 统一 inbox 消费
|
||||
|
||||
`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数:先路由协议 response 更新状态,再把所有消息注入 Lead 的对话历史。队友发来的 summary/result 不会只打印在终端,Lead 的 LLM 能看到并协调下一步。
|
||||
|
||||
### 合起来跑
|
||||
|
||||
```
|
||||
1. Lead: "搭建后端——任务太多,让队友自己认领"
|
||||
2. Lead → create_task("创建数据库 schema")
|
||||
3. Lead → create_task("写 API 路由")
|
||||
4. Lead → create_task("写单元测试")
|
||||
5. Lead → spawn_teammate("alice", "backend", "你是后端开发者")
|
||||
6. Lead → spawn_teammate("bob", "backend", "你是后端开发者")
|
||||
|
||||
7. alice 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
|
||||
8. bob 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
|
||||
|
||||
9. alice IDLE 第 1 次轮询 → scan_unclaimed → 发现"创建数据库 schema"
|
||||
10. alice → claim_task → "创建数据库 schema" → 回到 WORK
|
||||
11. bob IDLE 第 1 次轮询 → scan_unclaimed → 发现"写 API 路由"
|
||||
12. bob → claim_task → "写 API 路由" → 回到 WORK
|
||||
|
||||
13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK 结束
|
||||
14. alice IDLE → scan → "写单元测试" → claim → WORK
|
||||
15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK 结束
|
||||
16. alice IDLE → 60s 无新任务 → SHUTDOWN
|
||||
|
||||
17. bob 类似流程 → 做完 → SHUTDOWN
|
||||
18. Lead consume_lead_inbox → 看到 alice 和 bob 的 summary
|
||||
```
|
||||
|
||||
两个队友并行认领、并行工作。Lead 只需要创建任务和启动队友,不需要手动分配。
|
||||
|
||||
---
|
||||
|
||||
## 相对 s16 的变更
|
||||
|
||||
| 组件 | 之前 (s16) | 之后 (s17) |
|
||||
|------|-----------|-----------|
|
||||
| 任务分配 | Lead 手动 assign | 队友自动认领(can_start 检查依赖) |
|
||||
| 队友状态 | WORK 或退出 | WORK → IDLE(轮询 60s) → SHUTDOWN |
|
||||
| claim_task | 无 owner 检查 | 拒绝已有 owner 的任务 |
|
||||
| IDLE 阶段关机 | 不处理 shutdown_request | 直接 dispatch shutdown 并退出 |
|
||||
| Lead inbox | 只打印,不进上下文 | consume_lead_inbox 统一注入 history |
|
||||
| 新函数 | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox |
|
||||
| 身份保持 | 仅 system prompt | 压缩后自动重注入 |
|
||||
| Lead 工具 | 14 (s16) | 14(不变) |
|
||||
| 队友工具 | 5 | 8(+ list_tasks, claim_task, complete_task) |
|
||||
| 队友退出条件 | 完成任务即退出 | 60s 无新任务才退出 |
|
||||
|
||||
---
|
||||
|
||||
## 试一下
|
||||
|
||||
```sh
|
||||
cd learn-claude-code
|
||||
python s17_autonomous_agents/code.py
|
||||
```
|
||||
|
||||
试试这个 prompt:
|
||||
|
||||
`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
|
||||
|
||||
观察重点:队友是否自动认领了未分配的任务?有 blockedBy 依赖的任务是否在前置完成后被正确认领?空闲超时后是否自动关机?IDLE 阶段收到 shutdown_request 是否立即响应?`.tasks/` 目录下的任务状态如何变化?
|
||||
|
||||
---
|
||||
|
||||
## 接下来
|
||||
|
||||
队友自组织了。但 Alice 和 Bob 都在同一个目录下工作——Alice 改 `config.py`,Bob 也改 `config.py`,互相覆盖。
|
||||
|
||||
s18 Worktree Isolation → 每个任务有自己的工作目录,互不干扰。
|
||||
|
||||
<details>
|
||||
<summary>深入 CC 源码</summary>
|
||||
|
||||
> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示"空闲后找活干"。CC 的实际实现是多个机制的组合,但目标一致——减少 Lead 的手动分配负担。
|
||||
|
||||
### 一、CC 的空闲机制:组合路径,不是单一轮询
|
||||
|
||||
教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。CC 的实际实现是四个机制的组合:
|
||||
|
||||
**idle_notification**:队友完成一轮工作后,`sendIdleNotification()`(`inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。
|
||||
|
||||
**mailbox 轮询**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**,持续检查三类来源:pending user messages、mailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。
|
||||
|
||||
**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)用 `fs.watch()` 监听 `.claude/tasks/` 目录变化,1 秒 debounce,当新任务创建或依赖解锁时触发检查。依赖判断(`L197-207`)是"blockedBy 中没有未完成的任务",不是"blockedBy 为空"。
|
||||
|
||||
**主动 claim**:轮询循环内部也会调用 `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)——在等待期间主动从 task list 领取任务。所以"队友不主动轮询任务"不准确,CC 同时有被动通知和主动认领。
|
||||
|
||||
### 二、任务认领:文件锁 + 原子操作
|
||||
|
||||
`claimTask()`(`utils/tasks.ts:541-612`)用 `proper-lockfile` 的任务文件锁,在锁内完成读-检查-改-写。检查项:owner 是否已存在(`L575-576`)、是否已完成(`L580-581`)、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check 和 claim 做成原子操作,避免 TOCTOU。
|
||||
|
||||
`findAvailableTask()`(`inProcessRunner.ts:595-604`)的依赖判断也是"所有 blockedBy 已完成",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)在认领后把状态更新为 `in_progress`,让 UI 立即反映变化。
|
||||
|
||||
### 三、教学版 vs CC 对比
|
||||
|
||||
| 维度 | 教学版 (s17) | CC |
|
||||
|------|-------------|-----|
|
||||
| 空闲机制 | idle_poll 统一轮询(5s) | idle_notification + 500ms mailbox 轮询 + task watcher |
|
||||
| 任务发现 | scan_unclaimed_tasks(轮询) | useTaskListWatcher(文件监听)+ tryClaimNextTask(主动轮询) |
|
||||
| 依赖判断 | can_start(所有 blockedBy 已完成) | findAvailableTask(同样语义) |
|
||||
| 并发安全 | owner 检查(无文件锁) | proper-lockfile 任务锁 + task-list 锁 |
|
||||
| shutdown 处理 | IDLE 直接分发,WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request |
|
||||
| 超时退出 | 60s 无新任务 | 无固定超时,Lead 手动 shutdown |
|
||||
| 身份保持 | messages 长度检测 | context compaction 保留 system prompt |
|
||||
| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 |
|
||||
|
||||
教学版的 `idle_poll()` 把 CC 的四个机制合并成一个轮询函数——简化合理,因为核心语义(空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。
|
||||
|
||||
</details>
|
||||
|
||||
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
|
||||
812
s17_autonomous_agents/code.py
Normal file
812
s17_autonomous_agents/code.py
Normal file
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
s17: Autonomous Agents — idle poll + auto-claim + WORK/IDLE lifecycle.
|
||||
|
||||
Run: python s17_autonomous_agents/code.py
|
||||
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
|
||||
|
||||
Changes from s16:
|
||||
- scan_unclaimed_tasks: find pending, unowned tasks with deps completed
|
||||
- idle_poll: 60s polling loop (inbox + task board), dispatches shutdown in IDLE
|
||||
- claim_task: owner check + return value verification
|
||||
- Teammate lifecycle: WORK → IDLE → SHUTDOWN
|
||||
- Teammate tools: + list_tasks, claim_task, complete_task (5→8)
|
||||
- consume_lead_inbox: unified inbox consumer for protocol + context injection
|
||||
- Identity re-injection after context compression
|
||||
|
||||
ASCII lifecycle:
|
||||
WORK: inbox → LLM → tools → (tool_use? loop) → (done? → IDLE)
|
||||
IDLE: 5s poll → inbox? → WORK / unclaimed? → claim → WORK / 60s? → SHUTDOWN
|
||||
"""
|
||||
|
||||
import os, subprocess, json, time, random, threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, asdict, field
|
||||
|
||||
try:
|
||||
import readline
|
||||
readline.parse_and_bind('set bind-tty-special-chars off')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from anthropic import Anthropic
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
if os.getenv("ANTHROPIC_BASE_URL"):
|
||||
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
||||
|
||||
WORKDIR = Path.cwd()
|
||||
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
|
||||
MODEL = os.environ["MODEL_ID"]
|
||||
|
||||
# ── Task System (from s12) ──
|
||||
|
||||
TASKS_DIR = WORKDIR / ".tasks"
|
||||
TASKS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
id: str
|
||||
subject: str
|
||||
description: str
|
||||
status: str
|
||||
owner: str | None
|
||||
blockedBy: list[str]
|
||||
|
||||
|
||||
def _task_path(task_id: str) -> Path:
|
||||
return TASKS_DIR / f"{task_id}.json"
|
||||
|
||||
|
||||
def create_task(subject: str, description: str = "",
|
||||
blockedBy: list[str] | None = None) -> Task:
|
||||
task = Task(
|
||||
id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}",
|
||||
subject=subject, description=description,
|
||||
status="pending", owner=None,
|
||||
blockedBy=blockedBy or [],
|
||||
)
|
||||
save_task(task)
|
||||
return task
|
||||
|
||||
|
||||
def save_task(task: Task):
|
||||
_task_path(task.id).write_text(json.dumps(asdict(task), indent=2))
|
||||
|
||||
|
||||
def load_task(task_id: str) -> Task:
|
||||
return Task(**json.loads(_task_path(task_id).read_text()))
|
||||
|
||||
|
||||
def list_tasks() -> list[Task]:
|
||||
return [Task(**json.loads(p.read_text()))
|
||||
for p in sorted(TASKS_DIR.glob("task_*.json"))]
|
||||
|
||||
|
||||
def get_task(task_id: str) -> str:
|
||||
task = load_task(task_id)
|
||||
return json.dumps(asdict(task), indent=2)
|
||||
|
||||
|
||||
def can_start(task_id: str) -> bool:
|
||||
task = load_task(task_id)
|
||||
for dep_id in task.blockedBy:
|
||||
if not _task_path(dep_id).exists():
|
||||
return False
|
||||
if load_task(dep_id).status != "completed":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def claim_task(task_id: str, owner: str = "agent") -> str:
|
||||
task = load_task(task_id)
|
||||
if task.status != "pending":
|
||||
return f"Task {task_id} is {task.status}, cannot claim"
|
||||
if task.owner:
|
||||
return f"Task {task_id} already owned by {task.owner}"
|
||||
if not can_start(task_id):
|
||||
deps = [d for d in task.blockedBy
|
||||
if _task_path(d).exists() and load_task(d).status != "completed"]
|
||||
missing = [d for d in task.blockedBy if not _task_path(d).exists()]
|
||||
parts = []
|
||||
if deps: parts.append(f"blocked by: {deps}")
|
||||
if missing: parts.append(f"missing deps: {missing}")
|
||||
return "Cannot start — " + ", ".join(parts)
|
||||
task.owner = owner
|
||||
task.status = "in_progress"
|
||||
save_task(task)
|
||||
print(f" \033[36m[claim] {task.subject} → in_progress\033[0m")
|
||||
return f"Claimed {task.id} ({task.subject})"
|
||||
|
||||
|
||||
def complete_task(task_id: str) -> str:
|
||||
task = load_task(task_id)
|
||||
if task.status != "in_progress":
|
||||
return f"Task {task_id} is {task.status}, cannot complete"
|
||||
task.status = "completed"
|
||||
save_task(task)
|
||||
unblocked = [t.subject for t in list_tasks()
|
||||
if t.status == "pending" and t.blockedBy and can_start(t.id)]
|
||||
print(f" \033[32m[complete] {task.subject} ✓\033[0m")
|
||||
msg = f"Completed {task.id} ({task.subject})"
|
||||
if unblocked:
|
||||
msg += f"\nUnblocked: {', '.join(unblocked)}"
|
||||
return msg
|
||||
|
||||
|
||||
# ── Prompt Assembly (from s10) ──
|
||||
|
||||
PROMPT_SECTIONS = {
|
||||
"identity": "You are a coding agent. Act, don't explain.",
|
||||
"tools": "Available tools: bash, read_file, write_file, "
|
||||
"create_task, list_tasks, get_task, claim_task, complete_task, "
|
||||
"spawn_teammate, send_message, check_inbox, "
|
||||
"request_shutdown, request_plan, review_plan.",
|
||||
"workspace": f"Working directory: {WORKDIR}",
|
||||
"memory": "Relevant memories are injected below when available.",
|
||||
}
|
||||
|
||||
|
||||
def assemble_system_prompt(context: dict) -> str:
|
||||
sections = [PROMPT_SECTIONS["identity"],
|
||||
PROMPT_SECTIONS["tools"],
|
||||
PROMPT_SECTIONS["workspace"]]
|
||||
if context.get("memories"):
|
||||
sections.append(f"Relevant memories:\n{context['memories']}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
_last_context_hash, _last_prompt = None, None
|
||||
|
||||
|
||||
def get_system_prompt(context: dict) -> str:
|
||||
global _last_context_hash, _last_prompt
|
||||
h = json.dumps(context, sort_keys=True)
|
||||
if h == _last_context_hash and _last_prompt:
|
||||
return _last_prompt
|
||||
_last_context_hash, _last_prompt = h, assemble_system_prompt(context)
|
||||
return _last_prompt
|
||||
|
||||
|
||||
# ── Tools (from s15) ──
|
||||
|
||||
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
|
||||
|
||||
|
||||
def run_bash(command: str) -> str:
|
||||
try:
|
||||
r = subprocess.run(command, shell=True, cwd=WORKDIR,
|
||||
capture_output=True, text=True, timeout=120)
|
||||
out = (r.stdout + r.stderr).strip()
|
||||
return out[:50000] if out else "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Error: Timeout (120s)"
|
||||
|
||||
|
||||
def run_read(path: str, limit: int | None = None) -> str:
|
||||
try:
|
||||
lines = safe_path(path).read_text().splitlines()
|
||||
if limit and limit < len(lines):
|
||||
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def run_write(path: str, content: str) -> str:
|
||||
try:
|
||||
fp = safe_path(path)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content)
|
||||
return f"Wrote {len(content)} bytes to {path}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ── MessageBus (from s15) ──
|
||||
|
||||
MAILBOX_DIR = WORKDIR / ".mailboxes"
|
||||
MAILBOX_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
class MessageBus:
|
||||
def send(self, from_agent: str, to_agent: str, content: str,
|
||||
msg_type: str = "message", metadata: dict = None):
|
||||
msg = {"from": from_agent, "to": to_agent,
|
||||
"content": content, "type": msg_type,
|
||||
"ts": time.time(), "metadata": metadata or {}}
|
||||
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
|
||||
with open(inbox, "a") as f:
|
||||
f.write(json.dumps(msg) + "\n")
|
||||
print(f" \033[33m[bus] {from_agent} → {to_agent}: "
|
||||
f"({msg_type}) {content[:50]}\033[0m")
|
||||
|
||||
def read_inbox(self, agent: str) -> list[dict]:
|
||||
inbox = MAILBOX_DIR / f"{agent}.jsonl"
|
||||
if not inbox.exists():
|
||||
return []
|
||||
msgs = [json.loads(line) for line in inbox.read_text().splitlines()
|
||||
if line.strip()]
|
||||
inbox.unlink()
|
||||
return msgs
|
||||
|
||||
|
||||
BUS = MessageBus()
|
||||
active_teammates: dict[str, bool] = {}
|
||||
|
||||
|
||||
# ── Protocol State (from s16) ──
|
||||
|
||||
@dataclass
|
||||
class ProtocolState:
|
||||
request_id: str
|
||||
type: str
|
||||
sender: str
|
||||
target: str
|
||||
status: str
|
||||
payload: str
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
pending_requests: dict[str, ProtocolState] = {}
|
||||
|
||||
|
||||
def new_request_id() -> str:
|
||||
return f"req_{random.randint(0, 999999):06d}"
|
||||
|
||||
|
||||
def match_response(response_type: str, request_id: str, approve: bool):
|
||||
"""Correlate a response to the original request via request_id."""
|
||||
state = pending_requests.get(request_id)
|
||||
if not state:
|
||||
print(f" \033[31m[protocol] unknown request_id: {request_id}\033[0m")
|
||||
return
|
||||
if state.type == "shutdown" and response_type != "shutdown_response":
|
||||
print(f" \033[31m[protocol] type mismatch: expected shutdown_response, "
|
||||
f"got {response_type}\033[0m")
|
||||
return
|
||||
if state.type == "plan_approval" and response_type != "plan_approval_response":
|
||||
print(f" \033[31m[protocol] type mismatch: expected plan_approval_response, "
|
||||
f"got {response_type}\033[0m")
|
||||
return
|
||||
state.status = "approved" if approve else "rejected"
|
||||
icon = "✓" if approve else "✗"
|
||||
color = "32" if approve else "31"
|
||||
print(f" \033[{color}m[protocol] {state.type} {icon} "
|
||||
f"({request_id}: {state.status})\033[0m")
|
||||
|
||||
|
||||
# ── Autonomous Agent (s17 new) ──
|
||||
|
||||
IDLE_POLL_INTERVAL = 5 # seconds
|
||||
IDLE_TIMEOUT = 60 # seconds
|
||||
|
||||
|
||||
def scan_unclaimed_tasks() -> list[dict]:
|
||||
"""Find pending, unowned tasks with all dependencies completed."""
|
||||
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 can_start(task["id"])):
|
||||
unclaimed.append(task)
|
||||
return unclaimed
|
||||
|
||||
|
||||
def idle_poll(agent_name: str, messages: list,
|
||||
name: str, role: str) -> str:
|
||||
"""Poll for 60s. Return 'work', 'shutdown', or 'timeout'."""
|
||||
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
|
||||
time.sleep(IDLE_POLL_INTERVAL)
|
||||
|
||||
# Check inbox — dispatch protocol messages first
|
||||
inbox = BUS.read_inbox(agent_name)
|
||||
if inbox:
|
||||
# Check for shutdown_request
|
||||
for msg in inbox:
|
||||
if msg.get("type") == "shutdown_request":
|
||||
req_id = msg.get("metadata", {}).get("request_id", "")
|
||||
BUS.send(name, "lead", "Shutting down gracefully.",
|
||||
"shutdown_response",
|
||||
{"request_id": req_id, "approve": True})
|
||||
print(f" \033[35m[protocol] {name} approved shutdown "
|
||||
f"in idle ({req_id})\033[0m")
|
||||
return "shutdown"
|
||||
|
||||
# Non-protocol inbox: inject and resume work
|
||||
messages.append({"role": "user",
|
||||
"content": "<inbox>" + json.dumps(inbox) + "</inbox>"})
|
||||
print(f" \033[36m[idle] {name} found inbox messages\033[0m")
|
||||
return "work"
|
||||
|
||||
# Scan task board
|
||||
unclaimed = scan_unclaimed_tasks()
|
||||
if unclaimed:
|
||||
task = unclaimed[0]
|
||||
result = claim_task(task["id"], agent_name)
|
||||
if "Claimed" in result:
|
||||
messages.append({"role": "user",
|
||||
"content": f"<auto-claimed>Task {task['id']}: "
|
||||
f"{task['subject']}</auto-claimed>"})
|
||||
print(f" \033[32m[idle] {name} auto-claimed: "
|
||||
f"{task['subject']}\033[0m")
|
||||
return "work"
|
||||
print(f" \033[33m[idle] {name} claim failed: "
|
||||
f"{result}\033[0m")
|
||||
|
||||
print(f" \033[31m[idle] {name} timeout ({IDLE_TIMEOUT}s)\033[0m")
|
||||
return "timeout"
|
||||
|
||||
|
||||
# ── Teammate Thread (from s15 + s16 + s17) ──
|
||||
|
||||
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
|
||||
if name in active_teammates:
|
||||
return f"Teammate '{name}' already exists"
|
||||
|
||||
system = (f"You are '{name}', a {role}. "
|
||||
f"Use tools to complete tasks. "
|
||||
f"You can list and claim tasks from the board. "
|
||||
f"Check inbox for protocol messages.")
|
||||
|
||||
def handle_inbox_message(name: str, msg: dict, messages: list):
|
||||
"""Dispatch incoming protocol messages by type."""
|
||||
msg_type = msg.get("type", "message")
|
||||
meta = msg.get("metadata", {})
|
||||
req_id = meta.get("request_id", "")
|
||||
|
||||
if msg_type == "shutdown_request":
|
||||
BUS.send(name, "lead", "Shutting down gracefully.",
|
||||
"shutdown_response",
|
||||
{"request_id": req_id, "approve": True})
|
||||
print(f" \033[35m[protocol] {name} approved shutdown "
|
||||
f"({req_id})\033[0m")
|
||||
return True
|
||||
|
||||
if msg_type == "plan_approval_response":
|
||||
approve = meta.get("approve", False)
|
||||
if approve:
|
||||
messages.append({"role": "user",
|
||||
"content": "[Plan approved] Proceed with the task."})
|
||||
else:
|
||||
messages.append({"role": "user",
|
||||
"content": f"[Plan rejected] Feedback: {msg['content']}"})
|
||||
return False
|
||||
|
||||
def run():
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
sub_tools = [
|
||||
{"name": "bash", "description": "Run a shell command.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"command": {"type": "string"}},
|
||||
"required": ["command"]}},
|
||||
{"name": "read_file", "description": "Read file.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"]}},
|
||||
{"name": "write_file", "description": "Write file.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"path": {"type": "string"},
|
||||
"content": {"type": "string"}},
|
||||
"required": ["path", "content"]}},
|
||||
{"name": "send_message",
|
||||
"description": "Send message to another agent.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"to": {"type": "string"},
|
||||
"content": {"type": "string"}},
|
||||
"required": ["to", "content"]}},
|
||||
{"name": "submit_plan",
|
||||
"description": "Submit a plan for Lead approval.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"plan": {"type": "string"}},
|
||||
"required": ["plan"]}},
|
||||
# s17 new: teammates can list, claim, and complete tasks
|
||||
{"name": "list_tasks",
|
||||
"description": "List all tasks on the board.",
|
||||
"input_schema": {"type": "object", "properties": {},
|
||||
"required": []}},
|
||||
{"name": "claim_task",
|
||||
"description": "Claim a pending task.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"task_id": {"type": "string"}},
|
||||
"required": ["task_id"]}},
|
||||
{"name": "complete_task",
|
||||
"description": "Mark an in-progress task as completed.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"task_id": {"type": "string"}},
|
||||
"required": ["task_id"]}},
|
||||
]
|
||||
|
||||
def _run_list_tasks():
|
||||
tasks = list_tasks()
|
||||
if not tasks:
|
||||
return "No tasks."
|
||||
return "\n".join(
|
||||
f" {t.id}: {t.subject} [{t.status}]"
|
||||
for t in tasks)
|
||||
|
||||
def _run_claim_task(task_id: str):
|
||||
return claim_task(task_id, owner=name)
|
||||
|
||||
def _run_complete_task(task_id: str):
|
||||
return complete_task(task_id)
|
||||
|
||||
sub_handlers = {
|
||||
"bash": run_bash, "read_file": run_read, "write_file": run_write,
|
||||
"send_message": lambda to, content: (BUS.send(name, to, content),
|
||||
"Sent")[1],
|
||||
"submit_plan": lambda plan: _teammate_submit_plan(name, plan),
|
||||
"list_tasks": _run_list_tasks,
|
||||
"claim_task": _run_claim_task,
|
||||
"complete_task": _run_complete_task,
|
||||
}
|
||||
|
||||
# Outer loop: WORK → IDLE cycle
|
||||
while True:
|
||||
# Identity re-injection (s17)
|
||||
if len(messages) <= 3:
|
||||
messages.insert(0, {"role": "user",
|
||||
"content": f"<identity>You are '{name}', role: {role}. "
|
||||
f"Continue your work.</identity>"})
|
||||
|
||||
# WORK phase
|
||||
should_shutdown = False
|
||||
for _ in range(10):
|
||||
inbox = BUS.read_inbox(name)
|
||||
for msg in inbox:
|
||||
stopped = handle_inbox_message(name, msg, messages)
|
||||
if stopped:
|
||||
should_shutdown = True
|
||||
break
|
||||
if should_shutdown:
|
||||
break
|
||||
if inbox and not should_shutdown:
|
||||
non_protocol = [m for m in inbox
|
||||
if m.get("type") == "message"]
|
||||
if non_protocol:
|
||||
messages.append({"role": "user",
|
||||
"content": f"<inbox>{json.dumps(non_protocol)}</inbox>"})
|
||||
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=system, messages=messages[-20:],
|
||||
tools=sub_tools, max_tokens=8000)
|
||||
except Exception:
|
||||
break
|
||||
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 = sub_handlers.get(block.name)
|
||||
output = handler(**block.input) if handler else "Unknown"
|
||||
results.append({"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": str(output)})
|
||||
messages.append({"role": "user", "content": results})
|
||||
|
||||
if should_shutdown:
|
||||
break
|
||||
|
||||
# IDLE phase (s17 new)
|
||||
idle_result = idle_poll(name, messages, name, role)
|
||||
if idle_result == "shutdown":
|
||||
break
|
||||
if idle_result == "timeout":
|
||||
break
|
||||
|
||||
# Summary
|
||||
summary = "Done."
|
||||
for msg in reversed(messages):
|
||||
if msg["role"] == "assistant" and isinstance(msg["content"], list):
|
||||
for b in msg["content"]:
|
||||
if getattr(b, "type", None) == "text":
|
||||
summary = b.text
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
BUS.send(name, "lead", summary, "result")
|
||||
active_teammates.pop(name, None)
|
||||
print(f" \033[32m[teammate] {name} finished\033[0m")
|
||||
|
||||
active_teammates[name] = True
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
print(f" \033[36m[teammate] {name} spawned as {role}\033[0m")
|
||||
return f"Teammate '{name}' spawned as {role} (autonomous)"
|
||||
|
||||
|
||||
def _teammate_submit_plan(from_name: str, plan: str) -> str:
|
||||
"""Teammate submits a plan to Lead for approval."""
|
||||
req_id = new_request_id()
|
||||
pending_requests[req_id] = ProtocolState(
|
||||
request_id=req_id, type="plan_approval",
|
||||
sender=from_name, target="lead",
|
||||
status="pending", payload=plan)
|
||||
BUS.send(from_name, "lead", plan,
|
||||
"plan_approval_request",
|
||||
{"request_id": req_id})
|
||||
return f"Plan submitted ({req_id}). Waiting for approval..."
|
||||
|
||||
|
||||
# ── Lead Protocol Tools (from s16) ──
|
||||
|
||||
def run_request_shutdown(teammate: str) -> str:
|
||||
req_id = new_request_id()
|
||||
pending_requests[req_id] = ProtocolState(
|
||||
request_id=req_id, type="shutdown",
|
||||
sender="lead", target=teammate,
|
||||
status="pending", payload="")
|
||||
BUS.send("lead", teammate, "Please shut down gracefully.",
|
||||
"shutdown_request",
|
||||
{"request_id": req_id})
|
||||
print(f" \033[35m[protocol] shutdown_request → {teammate} "
|
||||
f"({req_id})\033[0m")
|
||||
return f"Shutdown request sent to {teammate} (req: {req_id})"
|
||||
|
||||
|
||||
def run_request_plan(teammate: str, task: str) -> str:
|
||||
"""Lead asks a teammate to submit a plan."""
|
||||
BUS.send("lead", teammate, f"Please submit a plan for: {task}",
|
||||
"message")
|
||||
return f"Asked {teammate} to submit a plan"
|
||||
|
||||
|
||||
def run_review_plan(request_id: str, approve: bool,
|
||||
feedback: str = "") -> str:
|
||||
state = pending_requests.get(request_id)
|
||||
if not state:
|
||||
return f"Request {request_id} not found"
|
||||
if state.status != "pending":
|
||||
return f"Request {request_id} already {state.status}"
|
||||
state.status = "approved" if approve else "rejected"
|
||||
BUS.send("lead", state.sender,
|
||||
feedback or ("Approved" if approve else "Rejected"),
|
||||
"plan_approval_response",
|
||||
{"request_id": request_id, "approve": approve})
|
||||
icon = "✓" if approve else "✗"
|
||||
print(f" \033[32m[protocol] plan {icon} ({request_id})\033[0m")
|
||||
return f"Plan {'approved' if approve else 'rejected'} ({request_id})"
|
||||
|
||||
|
||||
# ── Basic tool handlers ──
|
||||
|
||||
def run_create_task(subject: str, description: str = "",
|
||||
blockedBy: list[str] | None = None) -> str:
|
||||
task = create_task(subject, description, blockedBy)
|
||||
deps = f" (blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
|
||||
print(f" \033[34m[create] {task.subject}{deps}\033[0m")
|
||||
return f"Created {task.id}: {task.subject}{deps}"
|
||||
|
||||
|
||||
def run_list_tasks() -> str:
|
||||
tasks = list_tasks()
|
||||
if not tasks:
|
||||
return "No tasks."
|
||||
return "\n".join(
|
||||
f" {t.id}: {t.subject} [{t.status}]"
|
||||
for t in tasks)
|
||||
|
||||
|
||||
def run_get_task(task_id: str) -> str:
|
||||
return get_task(task_id)
|
||||
|
||||
|
||||
def run_claim_task(task_id: str) -> str:
|
||||
return claim_task(task_id, owner="agent")
|
||||
|
||||
|
||||
def run_complete_task(task_id: str) -> str:
|
||||
return complete_task(task_id)
|
||||
|
||||
|
||||
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
|
||||
return spawn_teammate_thread(name, role, prompt)
|
||||
|
||||
|
||||
def run_send_message(to: str, content: str) -> str:
|
||||
BUS.send("lead", to, content)
|
||||
return f"Sent to {to}"
|
||||
|
||||
|
||||
def consume_lead_inbox(route_protocol=True) -> list[dict]:
|
||||
"""Read Lead inbox: route protocol responses, return all messages."""
|
||||
msgs = BUS.read_inbox("lead")
|
||||
if route_protocol:
|
||||
for msg in msgs:
|
||||
meta = msg.get("metadata", {})
|
||||
req_id = meta.get("request_id", "")
|
||||
msg_type = msg.get("type", "")
|
||||
if req_id and msg_type.endswith("_response"):
|
||||
match_response(msg_type, req_id, meta.get("approve", False))
|
||||
return msgs
|
||||
|
||||
|
||||
def run_check_inbox() -> str:
|
||||
msgs = consume_lead_inbox(route_protocol=True)
|
||||
if not msgs:
|
||||
return "(inbox empty)"
|
||||
lines = []
|
||||
for m in msgs:
|
||||
meta = m.get("metadata", {})
|
||||
req_id = meta.get("request_id", "")
|
||||
tag = f" [{m['type']} req:{req_id}]" if req_id else f" [{m['type']}]"
|
||||
lines.append(f" [{m['from']}]{tag} {m['content'][:200]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Tool Definitions ──
|
||||
|
||||
TOOLS = [
|
||||
{"name": "bash", "description": "Run a shell command.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"command": {"type": "string"}},
|
||||
"required": ["command"]}},
|
||||
{"name": "read_file", "description": "Read file contents.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"path": {"type": "string"},
|
||||
"limit": {"type": "integer"}},
|
||||
"required": ["path"]}},
|
||||
{"name": "write_file", "description": "Write content to a file.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"path": {"type": "string"},
|
||||
"content": {"type": "string"}},
|
||||
"required": ["path", "content"]}},
|
||||
{"name": "create_task",
|
||||
"description": "Create a task.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"subject": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"blockedBy": {"type": "array",
|
||||
"items": {"type": "string"}}},
|
||||
"required": ["subject"]}},
|
||||
{"name": "list_tasks",
|
||||
"description": "List all tasks.",
|
||||
"input_schema": {"type": "object", "properties": {}, "required": []}},
|
||||
{"name": "get_task",
|
||||
"description": "Get full details of a specific task.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"task_id": {"type": "string"}},
|
||||
"required": ["task_id"]}},
|
||||
{"name": "claim_task",
|
||||
"description": "Claim a pending task.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"task_id": {"type": "string"}},
|
||||
"required": ["task_id"]}},
|
||||
{"name": "complete_task",
|
||||
"description": "Complete an in-progress task.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"task_id": {"type": "string"}},
|
||||
"required": ["task_id"]}},
|
||||
{"name": "spawn_teammate",
|
||||
"description": "Spawn an autonomous teammate agent.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"name": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"prompt": {"type": "string"}},
|
||||
"required": ["name", "role", "prompt"]}},
|
||||
{"name": "send_message",
|
||||
"description": "Send message to a teammate.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"to": {"type": "string"},
|
||||
"content": {"type": "string"}},
|
||||
"required": ["to", "content"]}},
|
||||
{"name": "check_inbox",
|
||||
"description": "Check inbox for messages and protocol responses.",
|
||||
"input_schema": {"type": "object", "properties": {}, "required": []}},
|
||||
{"name": "request_shutdown",
|
||||
"description": "Request a teammate to shut down gracefully.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"teammate": {"type": "string"}},
|
||||
"required": ["teammate"]}},
|
||||
{"name": "request_plan",
|
||||
"description": "Ask a teammate to submit a plan for review.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {"teammate": {"type": "string"},
|
||||
"task": {"type": "string"}},
|
||||
"required": ["teammate", "task"]}},
|
||||
{"name": "review_plan",
|
||||
"description": "Approve or reject a submitted plan.",
|
||||
"input_schema": {"type": "object",
|
||||
"properties": {
|
||||
"request_id": {"type": "string"},
|
||||
"approve": {"type": "boolean"},
|
||||
"feedback": {"type": "string"}},
|
||||
"required": ["request_id", "approve"]}},
|
||||
]
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": run_bash, "read_file": run_read, "write_file": run_write,
|
||||
"create_task": run_create_task, "list_tasks": run_list_tasks,
|
||||
"get_task": run_get_task,
|
||||
"claim_task": run_claim_task, "complete_task": run_complete_task,
|
||||
"spawn_teammate": run_spawn_teammate,
|
||||
"send_message": run_send_message, "check_inbox": run_check_inbox,
|
||||
"request_shutdown": run_request_shutdown,
|
||||
"request_plan": run_request_plan, "review_plan": run_review_plan,
|
||||
}
|
||||
|
||||
|
||||
# ── Context ──
|
||||
|
||||
MEMORY_DIR = WORKDIR / ".memory"
|
||||
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
|
||||
|
||||
|
||||
def update_context(context: dict, messages: list) -> dict:
|
||||
memories = ""
|
||||
if MEMORY_INDEX.exists():
|
||||
memories = MEMORY_INDEX.read_text()[:2000]
|
||||
return {"memories": memories}
|
||||
|
||||
|
||||
# ── Agent Loop ──
|
||||
|
||||
def agent_loop(messages: list, context: dict):
|
||||
system = get_system_prompt(context)
|
||||
while True:
|
||||
try:
|
||||
response = client.messages.create(
|
||||
model=MODEL, system=system, messages=messages,
|
||||
tools=TOOLS, max_tokens=8000)
|
||||
except Exception as e:
|
||||
messages.append({"role": "assistant", "content": [
|
||||
{"type": "text", "text": f"[Error] {type(e).__name__}: {e}"}]})
|
||||
return
|
||||
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
if response.stop_reason != "tool_use":
|
||||
return
|
||||
|
||||
results = []
|
||||
for block in response.content:
|
||||
if block.type != "tool_use":
|
||||
continue
|
||||
print(f"\033[36m> {block.name}\033[0m")
|
||||
handler = TOOL_HANDLERS.get(block.name)
|
||||
output = handler(**block.input) if handler else "Unknown"
|
||||
print(str(output)[:300])
|
||||
results.append({"type": "tool_result",
|
||||
"tool_use_id": block.id, "content": output})
|
||||
messages.append({"role": "user", "content": results})
|
||||
context = update_context(context, messages)
|
||||
system = get_system_prompt(context)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("s17: autonomous agents")
|
||||
print("Enter a question, press Enter to send. Type q to quit.\n")
|
||||
history = []
|
||||
context = {"memories": ""}
|
||||
while True:
|
||||
try:
|
||||
query = input("\033[36ms17 >> \033[0m")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
if query.strip().lower() in ("q", "exit", ""):
|
||||
break
|
||||
history.append({"role": "user", "content": query})
|
||||
agent_loop(history, context)
|
||||
context = update_context(context, history)
|
||||
for block in history[-1]["content"]:
|
||||
if getattr(block, "type", None) == "text":
|
||||
print(block.text)
|
||||
|
||||
# Consume lead inbox: route protocol + inject into history
|
||||
inbox = consume_lead_inbox(route_protocol=True)
|
||||
if inbox:
|
||||
inbox_text = "\n".join(
|
||||
f"From {m['from']} [{m.get('type', 'message')}]: "
|
||||
f"{m['content'][:200]}" for m in inbox)
|
||||
history.append({"role": "user",
|
||||
"content": f"[Inbox]\n{inbox_text}"})
|
||||
print()
|
||||
109
s17_autonomous_agents/images/autonomous-agents-overview.en.svg
Normal file
109
s17_autonomous_agents/images/autonomous-agents-overview.en.svg
Normal file
@@ -0,0 +1,109 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 470" font-family="system-ui, -apple-system, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#059669"/>
|
||||
</linearGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
|
||||
</marker>
|
||||
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#059669"/>
|
||||
</marker>
|
||||
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="760" height="470" fill="#fafbfc" rx="8"/>
|
||||
|
||||
<!-- Title -->
|
||||
<rect x="0" y="0" width="760" height="44" fill="url(#header)" rx="8"/>
|
||||
<rect x="0" y="36" width="760" height="8" fill="url(#header)"/>
|
||||
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Autonomous Agents — Idle Loop + Auto-Claim + WORK/IDLE Lifecycle</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
|
||||
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s16 Preserved</text>
|
||||
<rect x="160" y="56" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
|
||||
<text x="178" y="66" fill="#059669" font-size="10" font-weight="600">s17 New</text>
|
||||
|
||||
<!-- ===== Row 1: Lead Loop (s16 preserved) ===== -->
|
||||
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
|
||||
<text x="55" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">turn</text>
|
||||
|
||||
<line x1="90" y1="110" x2="104" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="107" y="90" width="70" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="142" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
|
||||
|
||||
<line x1="177" y1="110" x2="191" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="194" y="86" width="80" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="234" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt</text>
|
||||
|
||||
<line x1="274" y1="110" x2="288" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="291" y="86" width="70" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="326" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM</text>
|
||||
|
||||
<line x1="361" y1="110" x2="375" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="378" y="80" width="356" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="556" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (all s16 preserved)</text>
|
||||
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · send · inbox</text>
|
||||
<text x="394" y="128" fill="#7c3aed" font-size="8" font-weight="700">★ request_shutdown · request_plan · review_plan</text>
|
||||
|
||||
<!-- Loop back -->
|
||||
<path d="M 734 110 L 748 110 L 748 150 L 55 150 L 55 130" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
|
||||
|
||||
<!-- Connector: preserved inner loop is wrapped by the s17 lifecycle -->
|
||||
<path d="M 326 134 L 326 160 L 170 160 L 170 210" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
|
||||
<text x="248" y="156" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">same inner LLM/tool loop inside WORK</text>
|
||||
|
||||
<!-- ===== Row 2: Teammate Lifecycle (s17 new) ===== -->
|
||||
<rect x="30" y="172" width="700" height="195" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="2"/>
|
||||
<text x="380" y="194" fill="#065f46" font-size="11" font-weight="700" text-anchor="middle">Teammate Lifecycle (s17 new: WORK → IDLE → SHUTDOWN)</text>
|
||||
|
||||
<!-- WORK box -->
|
||||
<rect x="55" y="210" width="230" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
|
||||
<text x="170" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">WORK Phase</text>
|
||||
<text x="70" y="248" fill="#374151" font-size="8">inner loop: inbox → LLM → bash / read / write</text>
|
||||
<text x="70" y="262" fill="#374151" font-size="8">stop_reason == tool_use → loop</text>
|
||||
<text x="70" y="276" fill="#374151" font-size="8">stop_reason != tool_use → IDLE</text>
|
||||
<text x="70" y="298" fill="#6b7280" font-size="7">Max 10 rounds / interruptible by shutdown_request</text>
|
||||
|
||||
<!-- Arrow: WORK → IDLE -->
|
||||
<line x1="285" y1="260" x2="415" y2="260" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)"/>
|
||||
<text x="350" y="253" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">task done</text>
|
||||
|
||||
<!-- Arrow: IDLE → WORK (curved, above) -->
|
||||
<path d="M 415 232 C 375 200, 320 200, 285 232" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
|
||||
<text x="350" y="208" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">work found</text>
|
||||
|
||||
<!-- IDLE box -->
|
||||
<rect x="418" y="210" width="295" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
|
||||
<text x="565" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">IDLE Phase (poll every 5s)</text>
|
||||
<text x="433" y="248" fill="#374151" font-size="8">├ Check inbox → has message → back to WORK</text>
|
||||
<text x="433" y="264" fill="#374151" font-size="8">├ scan_unclaimed_tasks → claim → back to WORK</text>
|
||||
<text x="433" y="280" fill="#374151" font-size="8">└ 60s timeout → SHUTDOWN ↓</text>
|
||||
<text x="433" y="298" fill="#6b7280" font-size="7">idle_poll() + claim_task()</text>
|
||||
|
||||
<!-- SHUTDOWN box -->
|
||||
<rect x="515" y="335" width="130" height="24" rx="6" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
|
||||
<text x="580" y="351" fill="#991b1b" font-size="9" font-weight="700" text-anchor="middle">SHUTDOWN</text>
|
||||
|
||||
<!-- Arrow: IDLE → SHUTDOWN -->
|
||||
<line x1="580" y1="310" x2="580" y2="335" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
|
||||
<text x="598" y="326" fill="#dc2626" font-size="7">60s timeout</text>
|
||||
|
||||
<!-- ===== Row 3: Bottom notes ===== -->
|
||||
<rect x="30" y="388" width="700" height="42" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<rect x="50" y="400" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
|
||||
<text x="70" y="410" fill="#475569" font-size="10">s16: MessageBus + protocols + request_shutdown + plan approval</text>
|
||||
<rect x="50" y="414" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
|
||||
<text x="70" y="424" fill="#475569" font-size="10">s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection</text>
|
||||
|
||||
<!-- ===== Row 4: Autonomous note ===== -->
|
||||
<rect x="30" y="442" width="700" height="22" rx="4" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
|
||||
<text x="380" y="457" fill="#065f46" font-size="9" text-anchor="middle">Lead tools unchanged (14) · Teammate tools 5 → 8 (+3 task tools) · Teammates self-claim, Lead only creates tasks</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
109
s17_autonomous_agents/images/autonomous-agents-overview.ja.svg
Normal file
109
s17_autonomous_agents/images/autonomous-agents-overview.ja.svg
Normal file
@@ -0,0 +1,109 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 470" font-family="system-ui, -apple-system, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#059669"/>
|
||||
</linearGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
|
||||
</marker>
|
||||
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#059669"/>
|
||||
</marker>
|
||||
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="760" height="470" fill="#fafbfc" rx="8"/>
|
||||
|
||||
<!-- Title -->
|
||||
<rect x="0" y="0" width="760" height="44" fill="url(#header)" rx="8"/>
|
||||
<rect x="0" y="36" width="760" height="8" fill="url(#header)"/>
|
||||
<text x="380" y="28" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Autonomous Agents — アイドルポーリング + 自動認領 + WORK/IDLE ライフサイクル</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
|
||||
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s16 保持</text>
|
||||
<rect x="130" y="56" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
|
||||
<text x="148" y="66" fill="#059669" font-size="10" font-weight="600">s17 新規</text>
|
||||
|
||||
<!-- ===== Row 1: Lead Loop ===== -->
|
||||
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
|
||||
<text x="55" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">turn</text>
|
||||
|
||||
<line x1="90" y1="110" x2="104" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="107" y="90" width="70" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="142" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
|
||||
|
||||
<line x1="177" y1="110" x2="191" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="194" y="86" width="80" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="234" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt</text>
|
||||
|
||||
<line x1="274" y1="110" x2="288" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="291" y="86" width="70" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="326" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM</text>
|
||||
|
||||
<line x1="361" y1="110" x2="375" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="378" y="80" width="356" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="556" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH(s16 全保持)</text>
|
||||
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · send · inbox</text>
|
||||
<text x="394" y="128" fill="#7c3aed" font-size="8" font-weight="700">★ request_shutdown · request_plan · review_plan</text>
|
||||
|
||||
<!-- Loop back -->
|
||||
<path d="M 734 110 L 748 110 L 748 150 L 55 150 L 55 130" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
|
||||
|
||||
<!-- Connector: preserved inner loop is wrapped by the s17 lifecycle -->
|
||||
<path d="M 326 134 L 326 160 L 170 160 L 170 210" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
|
||||
<text x="248" y="156" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">同じ内側 LLM/tool loop を WORK に入れる</text>
|
||||
|
||||
<!-- ===== Row 2: Teammate Lifecycle ===== -->
|
||||
<rect x="30" y="172" width="700" height="195" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="2"/>
|
||||
<text x="380" y="194" fill="#065f46" font-size="11" font-weight="700" text-anchor="middle">チームメイトライフサイクル(s17 新規:WORK → IDLE → SHUTDOWN)</text>
|
||||
|
||||
<!-- WORK box -->
|
||||
<rect x="55" y="210" width="230" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
|
||||
<text x="170" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">WORK フェーズ</text>
|
||||
<text x="70" y="248" fill="#374151" font-size="8">内側ループ:inbox → LLM → bash / read / write</text>
|
||||
<text x="70" y="262" fill="#374151" font-size="8">stop_reason == tool_use → ループ</text>
|
||||
<text x="70" y="276" fill="#374151" font-size="8">stop_reason != tool_use → IDLE</text>
|
||||
<text x="70" y="298" fill="#6b7280" font-size="7">最大 10 ラウンド / shutdown_request で中断可能</text>
|
||||
|
||||
<!-- Arrow: WORK → IDLE -->
|
||||
<line x1="285" y1="260" x2="415" y2="260" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)"/>
|
||||
<text x="350" y="253" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">タスク完了</text>
|
||||
|
||||
<!-- Arrow: IDLE → WORK -->
|
||||
<path d="M 415 232 C 375 200, 320 200, 285 232" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
|
||||
<text x="350" y="208" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">仕事を発見</text>
|
||||
|
||||
<!-- IDLE box -->
|
||||
<rect x="418" y="210" width="295" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
|
||||
<text x="565" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">IDLE フェーズ(5 秒ごとにポーリング)</text>
|
||||
<text x="433" y="248" fill="#374151" font-size="8">├ inbox チェック → メッセージあり → WORK に戻る</text>
|
||||
<text x="433" y="264" fill="#374151" font-size="8">├ scan_unclaimed_tasks → 認領 → WORK に戻る</text>
|
||||
<text x="433" y="280" fill="#374151" font-size="8">└ 60 秒タイムアウト → SHUTDOWN ↓</text>
|
||||
<text x="433" y="298" fill="#6b7280" font-size="7">idle_poll() + claim_task()</text>
|
||||
|
||||
<!-- SHUTDOWN box -->
|
||||
<rect x="515" y="335" width="130" height="24" rx="6" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
|
||||
<text x="580" y="351" fill="#991b1b" font-size="9" font-weight="700" text-anchor="middle">SHUTDOWN</text>
|
||||
|
||||
<!-- Arrow: IDLE → SHUTDOWN -->
|
||||
<line x1="580" y1="310" x2="580" y2="335" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
|
||||
<text x="598" y="326" fill="#dc2626" font-size="7">60 秒タイムアウト</text>
|
||||
|
||||
<!-- ===== Row 3: Bottom notes ===== -->
|
||||
<rect x="30" y="388" width="700" height="42" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<rect x="50" y="400" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
|
||||
<text x="70" y="410" fill="#475569" font-size="10">s16: MessageBus + protocols + request_shutdown + plan approval</text>
|
||||
<rect x="50" y="414" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
|
||||
<text x="70" y="424" fill="#475569" font-size="10">s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection</text>
|
||||
|
||||
<!-- ===== Row 4 ===== -->
|
||||
<rect x="30" y="442" width="700" height="22" rx="4" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
|
||||
<text x="380" y="457" fill="#065f46" font-size="9" text-anchor="middle">Lead ツール不変(14) · チームメイトツール 5 → 8(+3 task tools) · チームメイトが自己認領、Lead はタスク作成のみ</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
109
s17_autonomous_agents/images/autonomous-agents-overview.svg
Normal file
109
s17_autonomous_agents/images/autonomous-agents-overview.svg
Normal file
@@ -0,0 +1,109 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 470" font-family="system-ui, -apple-system, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#059669"/>
|
||||
</linearGradient>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
|
||||
</marker>
|
||||
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#059669"/>
|
||||
</marker>
|
||||
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="760" height="470" fill="#fafbfc" rx="8"/>
|
||||
|
||||
<!-- Title -->
|
||||
<rect x="0" y="0" width="760" height="44" fill="url(#header)" rx="8"/>
|
||||
<rect x="0" y="36" width="760" height="8" fill="url(#header)"/>
|
||||
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Autonomous Agents — 空闲循环 + 自动认领 + WORK/IDLE 生命周期</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
|
||||
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s16 保留</text>
|
||||
<rect x="140" y="56" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
|
||||
<text x="158" y="66" fill="#059669" font-size="10" font-weight="600">s17 新增</text>
|
||||
|
||||
<!-- ===== Row 1: Lead Loop (s16 preserved) ===== -->
|
||||
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
|
||||
<text x="55" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">turn</text>
|
||||
|
||||
<line x1="90" y1="110" x2="104" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="107" y="90" width="70" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="142" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
|
||||
|
||||
<line x1="177" y1="110" x2="191" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="194" y="86" width="80" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="234" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt</text>
|
||||
|
||||
<line x1="274" y1="110" x2="288" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="291" y="86" width="70" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="326" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM</text>
|
||||
|
||||
<line x1="361" y1="110" x2="375" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="378" y="80" width="356" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="556" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (s16 全保留)</text>
|
||||
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · send · inbox</text>
|
||||
<text x="394" y="128" fill="#7c3aed" font-size="8" font-weight="700">★ request_shutdown · request_plan · review_plan</text>
|
||||
|
||||
<!-- Loop back -->
|
||||
<path d="M 734 110 L 748 110 L 748 150 L 55 150 L 55 130" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
|
||||
|
||||
<!-- Connector: preserved inner loop is wrapped by the s17 lifecycle -->
|
||||
<path d="M 326 134 L 326 160 L 170 160 L 170 210" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
|
||||
<text x="248" y="156" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">同一个内层 LLM/tool loop 放进 WORK</text>
|
||||
|
||||
<!-- ===== Row 2: Teammate Lifecycle (s17 new) ===== -->
|
||||
<rect x="30" y="172" width="700" height="195" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="2"/>
|
||||
<text x="380" y="194" fill="#065f46" font-size="11" font-weight="700" text-anchor="middle">队友生命周期(s17 新增:WORK → IDLE → SHUTDOWN)</text>
|
||||
|
||||
<!-- WORK box -->
|
||||
<rect x="55" y="210" width="230" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
|
||||
<text x="170" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">WORK 阶段</text>
|
||||
<text x="70" y="248" fill="#374151" font-size="8">内层循环:inbox → LLM → bash / read / write</text>
|
||||
<text x="70" y="262" fill="#374151" font-size="8">stop_reason == tool_use → loop</text>
|
||||
<text x="70" y="276" fill="#374151" font-size="8">stop_reason != tool_use → IDLE</text>
|
||||
<text x="70" y="298" fill="#6b7280" font-size="7">最多 10 轮 / 可被 shutdown_request 中断</text>
|
||||
|
||||
<!-- Arrow: WORK → IDLE -->
|
||||
<line x1="285" y1="260" x2="415" y2="260" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)"/>
|
||||
<text x="350" y="253" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">任务完成</text>
|
||||
|
||||
<!-- Arrow: IDLE → WORK (curved, above) -->
|
||||
<path d="M 415 232 C 375 200, 320 200, 285 232" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
|
||||
<text x="350" y="208" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">发现新任务</text>
|
||||
|
||||
<!-- IDLE box -->
|
||||
<rect x="418" y="210" width="295" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
|
||||
<text x="565" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">IDLE 阶段(每 5s 轮询)</text>
|
||||
<text x="433" y="248" fill="#374151" font-size="8">├ 检查 inbox → 有消息 → 回 WORK</text>
|
||||
<text x="433" y="264" fill="#374151" font-size="8">├ scan_unclaimed_tasks → 认领 → 回 WORK</text>
|
||||
<text x="433" y="280" fill="#374151" font-size="8">└ 60s 超时 → SHUTDOWN ↓</text>
|
||||
<text x="433" y="298" fill="#6b7280" font-size="7">idle_poll() + claim_task()</text>
|
||||
|
||||
<!-- SHUTDOWN box -->
|
||||
<rect x="515" y="335" width="130" height="24" rx="6" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
|
||||
<text x="580" y="351" fill="#991b1b" font-size="9" font-weight="700" text-anchor="middle">SHUTDOWN</text>
|
||||
|
||||
<!-- Arrow: IDLE → SHUTDOWN -->
|
||||
<line x1="580" y1="310" x2="580" y2="335" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
|
||||
<text x="598" y="326" fill="#dc2626" font-size="7">60s 超时</text>
|
||||
|
||||
<!-- ===== Row 3: Bottom notes ===== -->
|
||||
<rect x="30" y="388" width="700" height="42" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
|
||||
<rect x="50" y="400" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
|
||||
<text x="70" y="410" fill="#475569" font-size="10">s16: MessageBus + protocols + request_shutdown + plan approval</text>
|
||||
<rect x="50" y="414" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
|
||||
<text x="70" y="424" fill="#475569" font-size="10">s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection</text>
|
||||
|
||||
<!-- ===== Row 4: Autonomous note ===== -->
|
||||
<rect x="30" y="442" width="700" height="22" rx="4" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
|
||||
<text x="380" y="457" fill="#065f46" font-size="9" text-anchor="middle">Lead 工具不变(14) · 队友工具 5 → 8(+3 task tools) · 队友自主认领,Lead 只创建任务</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
Reference in New Issue
Block a user