Skip to main content
AI Agents advanced Lesson 8 of 9

Autonomous Agent Workflows

Build agents that run long multi-step tasks autonomously — task decomposition, parallel execution, checkpointing, and graceful failure recovery.

Real-World Scenario

A research analyst asks an agent to “research our top 5 competitors and produce a SWOT analysis for each.” This requires 30+ tool calls, intermediate synthesis, parallel research, and structured output — far beyond a simple ReAct loop. An autonomous workflow agent decomposes the task, runs competitor research in parallel, synthesizes findings, and produces a formatted report — recovering gracefully if one research step fails.

Workflow Agent with Checkpointing

import anthropic
import json
import sqlite3
from dataclasses import dataclass, asdict, field
from datetime import datetime
from typing import Callable, Any
from pathlib import Path

client = anthropic.Anthropic()

@dataclass
class WorkflowStep:
    id:          str
    description: str
    status:      str  = "pending"   # pending, running, done, failed, skipped
    result:      Any  = None
    error:       str  = ""
    started_at:  str  = ""
    finished_at: str  = ""

@dataclass
class WorkflowState:
    workflow_id: str
    task:        str
    steps:       list[WorkflowStep] = field(default_factory=list)
    created_at:  str = ""
    final_result: str = ""

class WorkflowCheckpointer:
    """Persist workflow state to SQLite for resumability."""

    def __init__(self, db_path: str = "./workflow_state.db"):
        self.conn = sqlite3.connect(db_path)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS workflows (
                id      TEXT PRIMARY KEY,
                state   TEXT NOT NULL,
                updated TEXT NOT NULL
            )
        """)
        self.conn.commit()

    def save(self, state: WorkflowState) -> None:
        now = datetime.now().isoformat()
        self.conn.execute(
            "INSERT OR REPLACE INTO workflows (id, state, updated) VALUES (?, ?, ?)",
            (state.workflow_id, json.dumps(asdict(state)), now)
        )
        self.conn.commit()

    def load(self, workflow_id: str) -> WorkflowState | None:
        row = self.conn.execute(
            "SELECT state FROM workflows WHERE id = ?", (workflow_id,)
        ).fetchone()
        if not row:
            return None
        data = json.loads(row[0])
        data["steps"] = [WorkflowStep(**s) for s in data["steps"]]
        return WorkflowState(**data)


class AutonomousWorkflowAgent:
    def __init__(
        self,
        tools: list[dict],
        tool_implementations: dict[str, Callable],
        system: str = "",
        checkpointer: WorkflowCheckpointer | None = None,
    ):
        self.tools          = tools
        self.impls          = tool_implementations
        self.system         = system
        self.checkpointer   = checkpointer or WorkflowCheckpointer()

    def _plan_steps(self, task: str) -> list[WorkflowStep]:
        """Use the LLM to decompose the task into concrete steps."""
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            system="You are a workflow planner. Decompose tasks into 3-8 concrete, sequential steps.",
            messages=[{
                "role": "user",
                "content": f"""Decompose this task into concrete steps:
{task}

Available tools: {[t['name'] for t in self.tools]}

Return JSON: {{"steps": [{{"id": "step_1", "description": "..."}}]}}"""
            }]
        )
        text = response.content[0].text
        try:
            start = text.index("{")
            data  = json.loads(text[start:text.rindex("}") + 1])
            return [WorkflowStep(id=s["id"], description=s["description"])
                    for s in data.get("steps", [])]
        except Exception:
            return [WorkflowStep(id="step_1", description=task)]

    def _execute_step(self, step: WorkflowStep, context: str) -> str:
        """Execute a single workflow step."""
        messages = [{
            "role": "user",
            "content": f"""Complete this step:
Step: {step.description}
Context from previous steps: {context[:2000] if context else "None"}

Use tools as needed. When done, provide a concise summary of what you did and found."""
        }]

        for _ in range(10):  # max iterations per step
            response = client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=1024,
                system=self.system,
                tools=self.tools,
                messages=messages,
            )

            text_blocks = [b.text for b in response.content if hasattr(b, "text")]

            if response.stop_reason == "end_turn":
                return text_blocks[0] if text_blocks else "Step complete."

            messages.append({"role": "assistant", "content": response.content})
            tool_results = []

            for block in response.content:
                if block.type != "tool_use":
                    continue
                impl   = self.impls.get(block.name)
                result = impl(block.input) if impl else f"Tool {block.name} not found"
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result,
                })

            messages.append({"role": "user", "content": tool_results})

        return "Step timed out."

    def run(
        self,
        task: str,
        workflow_id: str | None = None,
        resume: bool = False,
    ) -> str:
        import uuid
        workflow_id = workflow_id or str(uuid.uuid4())[:8]

        # Try to resume from checkpoint
        state = self.checkpointer.load(workflow_id) if resume else None

        if state is None:
            print(f"[{workflow_id}] Planning steps...")
            steps = self._plan_steps(task)
            state = WorkflowState(
                workflow_id=workflow_id,
                task=task,
                steps=steps,
                created_at=datetime.now().isoformat(),
            )
            self.checkpointer.save(state)
            print(f"[{workflow_id}] {len(steps)} steps planned")
        else:
            print(f"[{workflow_id}] Resuming from checkpoint "
                  f"({sum(s.status == 'done' for s in state.steps)} steps already done)")

        # Execute each pending step
        context_parts = []
        for step in state.steps:
            if step.status == "done":
                context_parts.append(f"{step.description}: {step.result}")
                continue

            print(f"[{workflow_id}] Running: {step.description}")
            step.status     = "running"
            step.started_at = datetime.now().isoformat()
            self.checkpointer.save(state)

            try:
                result = self._execute_step(step, "\n".join(context_parts))
                step.status      = "done"
                step.result      = result
                step.finished_at = datetime.now().isoformat()
                context_parts.append(f"{step.description}: {result}")
                print(f"[{workflow_id}] Done: {result[:100]}...")
            except Exception as e:
                step.status = "failed"
                step.error  = str(e)
                print(f"[{workflow_id}] FAILED: {e}")

            self.checkpointer.save(state)

        # Synthesize final result
        print(f"[{workflow_id}] Synthesizing final result...")
        synthesis = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            messages=[{
                "role": "user",
                "content": f"""Original task: {task}

Step results:
{chr(10).join(context_parts)}

Synthesize a comprehensive final answer."""
            }]
        )
        state.final_result = synthesis.content[0].text
        self.checkpointer.save(state)
        return state.final_result

Parallel Sub-Agent Execution

import anthropic
import concurrent.futures
import json
from dataclasses import dataclass

client = anthropic.Anthropic()

@dataclass
class SubAgentResult:
    agent_id: str
    task:     str
    result:   str
    success:  bool
    error:    str = ""

def run_sub_agent(agent_id: str, task: str, system: str, max_tokens: int = 1024) -> SubAgentResult:
    """Run a single sub-agent and return its result."""
    try:
        response = client.messages.create(
            model="claude-haiku-4-5-20251001",  # cheaper model for sub-agents
            max_tokens=max_tokens,
            system=system,
            messages=[{"role": "user", "content": task}],
        )
        return SubAgentResult(
            agent_id=agent_id,
            task=task,
            result=response.content[0].text,
            success=True,
        )
    except Exception as e:
        return SubAgentResult(
            agent_id=agent_id, task=task, result="", success=False, error=str(e)
        )


def parallel_research_agent(
    main_task: str,
    subtasks: list[dict],  # [{"id": "...", "task": "...", "system": "..."}]
    max_workers: int = 5,
) -> str:
    """Run multiple research sub-agents in parallel, then synthesize."""
    print(f"Dispatching {len(subtasks)} parallel sub-agents...")

    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {
            executor.submit(
                run_sub_agent,
                st["id"],
                st["task"],
                st.get("system", "You are a research assistant. Be concise and factual."),
            ): st["id"]
            for st in subtasks
        }

        results = []
        for future in concurrent.futures.as_completed(futures):
            result = future.result()
            status = "✓" if result.success else "✗"
            print(f"  {status} [{result.agent_id}] completed")
            results.append(result)

    # Sort by original order
    result_map = {r.agent_id: r for r in results}
    ordered    = [result_map[st["id"]] for st in subtasks if st["id"] in result_map]

    # Synthesize
    context = "\n\n".join(
        f"[{r.agent_id}] {r.task}\n{r.result if r.success else f'FAILED: {r.error}'}"
        for r in ordered
    )

    synthesis = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        messages=[{
            "role": "user",
            "content": f"""Main task: {main_task}

Research findings from parallel agents:
{context}

Synthesize a comprehensive answer. Note any conflicting or missing information."""
        }]
    )
    return synthesis.content[0].text


# Example: analyze competing AI assistants in parallel
result = parallel_research_agent(
    main_task="Compare the top 3 cloud providers for ML workloads",
    subtasks=[
        {
            "id": "aws",
            "task": "Summarize AWS ML services: SageMaker, Bedrock, key strengths and weaknesses for production ML.",
        },
        {
            "id": "gcp",
            "task": "Summarize GCP ML services: Vertex AI, TPUs, key strengths and weaknesses for production ML.",
        },
        {
            "id": "azure",
            "task": "Summarize Azure ML services: Azure ML, OpenAI integration, key strengths and weaknesses.",
        },
    ],
)
print(result[:500])

Human-in-the-Loop with Async Approval

import anthropic
import json
from typing import Callable

client = anthropic.Anthropic()

RISKY_ACTIONS = {
    "delete_file":      "HIGH",
    "send_email":       "HIGH",
    "update_database":  "MEDIUM",
    "create_file":      "LOW",
    "read_file":        "LOW",
}

def requires_approval(tool_name: str, risk_level: str = "MEDIUM") -> bool:
    action_risk = RISKY_ACTIONS.get(tool_name, "LOW")
    levels = {"LOW": 1, "MEDIUM": 2, "HIGH": 3}
    return levels.get(action_risk, 0) >= levels.get(risk_level, 2)


def get_human_approval(tool_name: str, inputs: dict) -> bool:
    """Prompt for human approval (sync version — in production use async/queue)."""
    print(f"\n⚠️  APPROVAL REQUIRED")
    print(f"   Action: {tool_name}")
    print(f"   Inputs: {json.dumps(inputs, indent=4)}")
    response = input("   Approve? (y/n): ").strip().lower()
    return response == "y"


def human_in_loop_agent(
    task: str,
    tools: list[dict],
    tool_implementations: dict[str, Callable],
    approval_threshold: str = "MEDIUM",
    auto_approve_callback: Callable | None = None,
) -> str:
    messages = [{"role": "user", "content": task}]

    for _ in range(20):
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            tools=tools,
            messages=messages,
        )

        if response.stop_reason == "end_turn":
            return next((b.text for b in response.content if hasattr(b, "text")), "Done.")

        messages.append({"role": "assistant", "content": response.content})
        tool_results = []

        for block in response.content:
            if block.type != "tool_use":
                continue

            # Check if this action needs approval
            if requires_approval(block.name, approval_threshold):
                if auto_approve_callback:
                    approved = auto_approve_callback(block.name, block.input)
                else:
                    approved = get_human_approval(block.name, block.input)

                if not approved:
                    result = f"Action '{block.name}' was rejected by the user. Please suggest an alternative approach."
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    })
                    continue

            impl   = tool_implementations.get(block.name)
            result = impl(block.input) if impl else "Tool not found"
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": result,
            })

        messages.append({"role": "user", "content": tool_results})

    return "Max iterations reached."

Frequently Asked Questions

How do I make an agent resume after a failure?
Checkpoint the agent's state (completed steps, gathered context, intermediate results) to a persistent store after each successful step. On restart, load the checkpoint and skip already-completed steps. This is the same principle as workflow engines like Airflow or Prefect — each task is idempotent and the engine tracks which ones have completed.
When should a long-running agent stop and ask for human input?
Build explicit 'decision gates' into the workflow: before irreversible actions (deleting data, sending emails, spending money), when confidence falls below a threshold, when multiple valid paths exist and the choice is ambiguous, and when the task has taken more than N steps without reaching the goal. Silence is not always consent — explicit approval is safer.