analysis_claude_code/docs/en/s11-autonomous-agents.md
CrazyBoyM c6a27ef1d7 feat: build an AI agent from 0 to 1 -- 11 progressive sessions
- 11 sessions from basic agent loop to autonomous teams
- Python MVP implementations for each session
- Mental-model-first docs in en/zh/ja
- Interactive web platform with step-through visualizations
- Incremental architecture: each session adds one mechanism
2026-02-21 17:02:43 +08:00

7.9 KiB

s11: Autonomous Agents

An idle cycle with task board polling lets teammates find and claim work themselves, with identity re-injection after context compression.

The Problem

In s09-s10, teammates only work when explicitly told to. The lead must spawn each teammate with a specific prompt. If the task board has 10 unclaimed tasks, the lead must manually assign each one. This does not scale.

True autonomy means teammates find work themselves. When a teammate finishes its current task, it should scan the task board for unclaimed work, claim a task, and start working -- without any instruction from the lead.

But autonomous agents face a subtlety: after context compression, the agent might forget who it is. If the messages are summarized, the original system prompt identity ("you are alice, role: coder") fades. Identity re-injection solves this by inserting an identity block at the start of compressed contexts.

Teaching simplification: the token estimation used here is rough (characters / 4). Production systems use proper tokenizer libraries. The nag threshold of 3 rounds (from s03) is set low for teaching visibility; production agents typically use a higher threshold around 10.

The Solution

Teammate lifecycle with idle cycle:

+-------+
| spawn |
+---+---+
    |
    v
+-------+   tool_use     +-------+
| WORK  | <------------- |  LLM  |
+---+---+                +-------+
    |
    | stop_reason != tool_use
    | (or idle tool called)
    v
+--------+
|  IDLE  |  poll every 5s for up to 60s
+---+----+
    |
    +---> check inbox --> message? ----------> WORK
    |
    +---> scan .tasks/ --> unclaimed? -------> claim -> WORK
    |
    +---> 60s timeout ----------------------> SHUTDOWN

Identity re-injection after compression:
  if len(messages) <= 3:
    messages.insert(0, identity_block)
    "You are 'alice', role: coder, team: my-team"

How It Works

  1. The teammate loop has two phases: WORK and IDLE. WORK runs the standard agent loop. When the LLM stops calling tools (or calls the idle tool), the teammate enters the IDLE phase.
def _loop(self, name, role, prompt):
    while True:
        # -- WORK PHASE --
        messages = [{"role": "user", "content": prompt}]
        for _ in range(50):
            inbox = BUS.read_inbox(name)
            for msg in inbox:
                if msg.get("type") == "shutdown_request":
                    self._set_status(name, "shutdown")
                    return
                messages.append(...)
            response = client.messages.create(...)
            if response.stop_reason != "tool_use":
                break
            # execute tools...
            if idle_requested:
                break

        # -- IDLE PHASE --
        self._set_status(name, "idle")
        resume = self._idle_poll(name, messages)
        if not resume:
            self._set_status(name, "shutdown")
            return
        self._set_status(name, "working")
  1. The idle phase polls the inbox and task board in a loop.
def _idle_poll(self, name, messages):
    polls = IDLE_TIMEOUT // POLL_INTERVAL  # 60s / 5s = 12
    for _ in range(polls):
        time.sleep(POLL_INTERVAL)
        # Check inbox for new messages
        inbox = BUS.read_inbox(name)
        if inbox:
            messages.append({"role": "user",
                "content": f"<inbox>{inbox}</inbox>"})
            return True
        # Scan task board for unclaimed tasks
        unclaimed = scan_unclaimed_tasks()
        if unclaimed:
            task = unclaimed[0]
            claim_task(task["id"], name)
            messages.append({"role": "user",
                "content": f"<auto-claimed>Task #{task['id']}: "
                           f"{task['subject']}</auto-claimed>"})
            return True
    return False  # timeout -> shutdown
  1. Task board scanning looks for pending, unowned, unblocked tasks.
def scan_unclaimed_tasks() -> list:
    TASKS_DIR.mkdir(exist_ok=True)
    unclaimed = []
    for f in sorted(TASKS_DIR.glob("task_*.json")):
        task = json.loads(f.read_text())
        if (task.get("status") == "pending"
                and not task.get("owner")
                and not task.get("blockedBy")):
            unclaimed.append(task)
    return unclaimed

def claim_task(task_id: int, owner: str):
    path = TASKS_DIR / f"task_{task_id}.json"
    task = json.loads(path.read_text())
    task["status"] = "in_progress"
    task["owner"] = owner
    path.write_text(json.dumps(task, indent=2))
  1. Identity re-injection inserts an identity block when the context is too short, indicating compression has occurred.
def make_identity_block(name, role, team_name):
    return {"role": "user",
            "content": f"<identity>You are '{name}', "
                       f"role: {role}, team: {team_name}. "
                       f"Continue your work.</identity>"}

# Before resuming work after idle:
if len(messages) <= 3:
    messages.insert(0, make_identity_block(
        name, role, team_name))
    messages.insert(1, {"role": "assistant",
        "content": f"I am {name}. Continuing."})
  1. The idle tool lets the teammate explicitly signal it has no more work, entering the idle polling phase early.
{"name": "idle",
 "description": "Signal that you have no more work. "
                "Enters idle polling phase.",
 "input_schema": {"type": "object", "properties": {}}},

Key Code

The autonomous loop (from agents/s11_autonomous_agents.py):

def _loop(self, name, role, prompt):
    while True:
        # WORK PHASE
        for _ in range(50):
            response = client.messages.create(...)
            if response.stop_reason != "tool_use":
                break
            for block in response.content:
                if block.name == "idle":
                    idle_requested = True
            if idle_requested:
                break

        # IDLE PHASE
        self._set_status(name, "idle")
        for _ in range(IDLE_TIMEOUT // POLL_INTERVAL):
            time.sleep(POLL_INTERVAL)
            inbox = BUS.read_inbox(name)
            if inbox: resume = True; break
            unclaimed = scan_unclaimed_tasks()
            if unclaimed:
                claim_task(unclaimed[0]["id"], name)
                resume = True; break
        if not resume:
            self._set_status(name, "shutdown")
            return
        self._set_status(name, "working")

What Changed From s10

Component Before (s10) After (s11)
Tools 12 14 (+idle, +claim_task)
Autonomy Lead-directed Self-organizing
Idle phase None Poll inbox + task board
Task claiming Manual only Auto-claim unclaimed tasks
Identity System prompt + re-injection after compress
Timeout None 60s idle -> auto shutdown

Design Rationale

Polling + timeout makes agents self-organizing without a central coordinator. Each agent independently polls the task board, claims unclaimed work, and returns to idle when done. The timeout triggers the poll cycle, and if no work appears within the window, the agent shuts itself down. This is the same pattern as work-stealing thread pools -- distributed, no single point of failure. Identity re-injection after compression ensures agents maintain their role even when conversation history is summarized away.

Try It

cd learn-claude-code
python agents/s11_autonomous_agents.py

Example prompts to try:

  1. Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.
  2. Spawn a coder teammate and let it find work from the task board itself
  3. Create tasks with dependencies. Watch teammates respect the blocked order.
  4. Type /tasks to see the task board with owners
  5. Type /team to monitor who is working vs idle