Agent Architecture
How AI agents work — the planning, execution, and tool-calling lifecycle that streamResource() connects your Angular app to. This page shows you the Python patterns that power modern agents and exactly how each pattern surfaces in Angular through @cacheplane/stream-resource.
Every section below shows the Python backend code first, then the Angular frontend code that consumes it. You need both halves to build a production agent application — LangGraph handles the intelligence, streamResource() handles the reactivity.
The Agent Loop
Every agent follows a five-phase cycle. Understanding this cycle is critical because each phase maps to a specific streamResource() signal in your Angular app.
The user sends a message. On the Angular side, submit() posts input to LangGraph Platform. On the Python side, the message lands in the graph's messages state key.
class AgentState(TypedDict):
messages: Annotated[list, add]
plan: list[str]
tool_results: dictThe LLM examines the full message history plus any accumulated state. It decides what to do next — respond directly, call one or more tools, or delegate to a subagent.
def plan(state: AgentState, config: RunnableConfig) -> dict:
system = """You are a research assistant. Given the conversation,
decide whether to respond directly, search for information,
or analyze data. Use tools when the user needs factual answers."""
response = llm.bind_tools(tools).invoke([
{"role": "system", "content": system},
*state["messages"],
])
return {"messages": [response]}If the LLM decided to call tools, LangGraph routes to the tool node. Tools run — database queries, API calls, code execution — and their results feed back into state as ToolMessage entries.
from langgraph.prebuilt import ToolNode
tool_node = ToolNode(tools)
# LangGraph automatically calls each tool the LLM requested
# and appends ToolMessage results to state["messages"]After tools finish (or if no tools were needed), the agent streams its final response token by token. streamResource() updates the messages() signal in real time so your Angular template re-renders incrementally.
// Angular side — messages update as tokens arrive
@if (agent.isLoading()) {
<app-typing-indicator />
}
@for (msg of agent.messages(); track msg.id) {
<app-message [message]="msg" />
}LangGraph checkpoints the full state — messages, tool results, plan, everything. The agent may loop back to Plan (if tools returned data that needs further reasoning) or finish. The checkpoint is what enables time-travel debugging via history().
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_connection_string(DATABASE_URL)
graph = builder.compile(checkpointer=checkpointer)ReAct Pattern
ReAct (Reason + Act) is the most common agent pattern. The agent reasons about the user's question, decides to call a tool, observes the result, and loops until it has enough information to answer.
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from typing_extensions import TypedDict, Annotated
from operator import add
# --- State ---
class AgentState(TypedDict):
messages: Annotated[list, add]
# --- Tools ---
@tool
def search_docs(query: str) -> str:
"""Search the knowledge base for relevant documents."""
results = vector_store.similarity_search(query, k=3)
return "\n\n".join(doc.page_content for doc in results)
@tool
def query_database(sql: str) -> str:
"""Run a read-only SQL query against the analytics database."""
rows = db.execute(text(sql)).fetchall()
return json.dumps([dict(r) for r in rows])
@tool
def get_weather(city: str) -> str:
"""Get current weather for a city."""
resp = httpx.get(f"https://api.weather.com/v1/{city}")
return resp.json()["summary"]
tools = [search_docs, query_database, get_weather]
# --- LLM with tools bound ---
llm = ChatOpenAI(model="gpt-5-mini")
def call_model(state: AgentState) -> dict:
response = llm.bind_tools(tools).invoke(state["messages"])
return {"messages": [response]}
# --- Routing ---
def should_continue(state: AgentState) -> str:
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tools"
return END
# --- Graph ---
builder = StateGraph(AgentState)
builder.add_node("model", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "model")
builder.add_conditional_edges("model", should_continue)
builder.add_edge("tools", "model") # After tools, reason again
graph = builder.compile()The key insight: should_continue is the decision point. If the LLM's response contains tool_calls, the graph routes to the tools node. If not, it ends. After tools execute, the graph loops back to model so the LLM can reason about the tool results. This loop continues until the LLM responds without requesting any tools.
Tool Calling Deep Dive
Tools are how agents interact with the outside world. Understanding both the Python definition and the Angular consumption is essential.
Defining Tools in Python
Every tool is a Python function decorated with @tool. LangGraph converts the function signature and docstring into the JSON schema that the LLM uses to decide when and how to call it:
from langchain_core.tools import tool
from pydantic import BaseModel, Field
# Simple tool — args inferred from function signature
@tool
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression and return the result."""
return str(eval(expression)) # Use a sandbox in production
# Structured tool — explicit schema with validation
class EmailInput(BaseModel):
to: str = Field(description="Recipient email address")
subject: str = Field(description="Email subject line")
body: str = Field(description="Email body content")
@tool(args_schema=EmailInput)
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email to the specified recipient."""
mail_service.send(to=to, subject=subject, body=body)
return f"Email sent to {to}"The LLM reads the docstring to decide when to call a tool. A vague docstring like "does stuff" means the LLM will not know when to use it. Be specific: what the tool does, what it returns, when to use it.
How Tools Surface in Angular
When the agent calls a tool, streamResource() exposes the execution lifecycle through two signals:
// toolProgress() — tools currently executing
// Updates in real time as tools start and complete
const agent = streamResource<AgentState>({
assistantId: 'react_agent',
});
// Each entry has: name, args, status
const activeTools = computed(() => agent.toolProgress());
// Template usage
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (tool of activeTools(); track tool.id) {
<div class="tool-executing">
<app-spinner />
<span>Running {{ tool.name }}...</span>
<pre>{{ tool.args | json }}</pre>
</div>
}
`,
})
export class ToolProgressComponent {
activeTools = computed(() => this.agent.toolProgress());
}Tool Execution Flow
The full lifecycle from Python tool definition to Angular UI update:
The model returns an AIMessage with a tool_calls array. Each entry specifies the tool name and arguments.
The should_continue conditional edge detects tool_calls and routes to the tools node.
ToolNode calls the Python function. The result is wrapped in a ToolMessage and appended to state.
LangGraph Platform streams the tool call and result as SSE events to the Angular client.
toolProgress() updates during execution. toolCalls() updates when the tool completes. Both trigger OnPush change detection.
Multi-Agent Architecture
When a single agent with tools is not enough, you can compose multiple agents into a supervisor-worker architecture. A supervisor agent receives the user's request, decides which specialist to delegate to, and synthesizes the final answer.
from langgraph.graph import END, START, StateGraph
from langchain_openai import ChatOpenAI
from typing import Literal
from typing_extensions import TypedDict, Annotated
from operator import add
class OrchestratorState(TypedDict):
messages: Annotated[list, add]
next_agent: str
research_output: str
analysis_output: str
llm = ChatOpenAI(model="gpt-5-mini")
# --- Supervisor ---
def supervisor(state: OrchestratorState) -> dict:
response = llm.bind_tools([route_tool]).invoke([
{"role": "system", "content": """You are a supervisor.
Route to 'researcher' for fact-finding,
'analyst' for data analysis,
'writer' for drafting content,
or 'finish' if the task is complete."""},
*state["messages"],
])
destination = response.tool_calls[0]["args"]["agent"]
return {"next_agent": destination, "messages": [response]}
# --- Specialist subagents (each is its own compiled graph) ---
researcher_graph = build_researcher_agent()
analyst_graph = build_analyst_agent()
writer_graph = build_writer_agent()
# --- Routing ---
def route_to_agent(state: OrchestratorState) -> str:
return state["next_agent"]
# --- Orchestrator graph ---
builder = StateGraph(OrchestratorState)
builder.add_node("supervisor", supervisor)
builder.add_node("researcher", researcher_graph)
builder.add_node("analyst", analyst_graph)
builder.add_node("writer", writer_graph)
builder.add_edge(START, "supervisor")
builder.add_conditional_edges("supervisor", route_to_agent, {
"researcher": "researcher",
"analyst": "analyst",
"writer": "writer",
"finish": END,
})
# After each specialist, return to supervisor
builder.add_edge("researcher", "supervisor")
builder.add_edge("analyst", "supervisor")
builder.add_edge("writer", "supervisor")
graph = builder.compile()The subagentToolNames option tells streamResource() which graph nodes are subagents. Without it, subagent execution looks like regular tool calls. With it, activeSubagents() and subagents() provide dedicated tracking with isolated message histories.
Error Handling and Recovery
Agents fail. Tools throw exceptions, APIs time out, LLMs hallucinate invalid tool arguments. A robust architecture handles all of these gracefully.
Python-Side Error Handling
from langchain_core.tools import tool, ToolException
@tool(handle_tool_error=True)
def query_database(sql: str) -> str:
"""Run a read-only SQL query against the analytics database."""
if "DROP" in sql.upper() or "DELETE" in sql.upper():
raise ToolException("Destructive queries are not allowed.")
try:
rows = db.execute(text(sql)).fetchall()
return json.dumps([dict(r) for r in rows])
except Exception as e:
raise ToolException(f"Query failed: {str(e)}")When handle_tool_error=True is set, LangGraph catches ToolException and feeds the error message back to the LLM as a ToolMessage. The LLM sees the error and can retry with corrected arguments or explain the failure to the user.
How Errors Surface in Angular
const agent = streamResource<AgentState>({
assistantId: 'react_agent',
});
// The error() signal captures both transport and agent errors
const error = computed(() => agent.error());
// In your template
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (error()) {
<app-error-banner [error]="error()" (retry)="retry()" />
}
`,
})
export class AgentComponent {
error = computed(() => this.agent.error());
retry() {
// Re-submit the last message to retry
this.agent.submit(this.lastInput);
}
}Error Recovery Strategies
| Error type | Python behavior | Angular signal |
|---|---|---|
| Tool throws ToolException | Error fed back to LLM, agent retries | toolCalls() shows error in result |
| Tool throws unexpected error | LangGraph catches it, marks tool as failed | error() fires with details |
| LLM returns invalid tool args | ToolNode validation fails, error fed to LLM | toolProgress() shows failed status |
| Transport error (network) | N/A | error() fires, status() becomes 'error' |
| Agent exceeds recursion limit | Graph raises GraphRecursionError | error() fires with recursion message |
LangGraph defaults to 25 recursion steps. If your agent loops between model and tools more than 25 times, it stops with a GraphRecursionError. Increase the limit in production with graph.compile(recursion_limit=50) or redesign the agent to converge faster.
Checkpointing and Debugging
Every time a node completes, LangGraph saves a checkpoint — a full snapshot of the agent's state at that moment. streamResource() exposes this checkpoint timeline to Angular, giving you time-travel debugging for free.
How Checkpoints Work
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_connection_string(DATABASE_URL)
graph = builder.compile(checkpointer=checkpointer)
# Every node execution creates a checkpoint:
# checkpoint_1: after "model" (LLM decided to call search_docs)
# checkpoint_2: after "tools" (search_docs returned results)
# checkpoint_3: after "model" (LLM responded with final answer)Exposing Checkpoints in Angular
const agent = streamResource<AgentState>({
assistantId: 'react_agent',
threadId: signal('thread_abc123'),
});
// Full checkpoint timeline — every state snapshot
const timeline = computed(() => agent.history());
// Current branch (for time-travel)
const branch = computed(() => agent.branch());Building a Debug Timeline
@Component({
selector: 'app-debug-timeline',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="timeline">
@for (checkpoint of history(); track checkpoint.id) {
<button
[class.active]="checkpoint.id === currentCheckpoint()"
(click)="timeTravel(checkpoint.id)">
<span class="node">{{ checkpoint.node }}</span>
<span class="time">{{ checkpoint.timestamp | date:'medium' }}</span>
</button>
}
</div>
<div class="state-inspector">
<h3>State at checkpoint</h3>
<pre>{{ selectedState() | json }}</pre>
</div>
`,
})
export class DebugTimelineComponent {
history = computed(() => this.agent.history());
currentCheckpoint = signal<string | null>(null);
selectedState = computed(() => {
const id = this.currentCheckpoint();
return this.history().find(c => c.id === id)?.state;
});
timeTravel(checkpointId: string) {
this.currentCheckpoint.set(checkpointId);
this.agent.submit(null, { checkpoint: checkpointId });
}
}When you submit from a previous checkpoint, LangGraph creates a new branch from that point. The original timeline is preserved. The branch() signal tells you which branch is currently active. See the Time Travel guide for the full walkthrough.
Choosing an Architecture
Not every application needs a multi-agent swarm. Here is a decision guide for picking the right level of complexity.
Single Agent with Tools
Use when: Most applications. The user has a conversation, the agent calls tools as needed, and responds.
# Simple, powerful, covers 80% of use cases
builder = StateGraph(AgentState)
builder.add_node("model", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "model")
builder.add_conditional_edges("model", should_continue)
builder.add_edge("tools", "model")
graph = builder.compile()Angular signals used: messages(), toolCalls(), toolProgress(), status()
Single Agent with Human-in-the-Loop
Use when: The agent takes high-stakes actions (sending emails, modifying data, making purchases) that need human approval.
from langgraph.types import Interrupt
def propose_action(state: AgentState) -> dict:
plan = llm.invoke(state["messages"])
raise Interrupt(value={"action": plan.content, "requires_approval": True})
def execute_action(state: AgentState) -> dict:
# Only runs after human approves
return perform_action(state["pending_action"])Angular signals used: messages(), interrupt(), status() plus submit(null, { resume }) to approve
Multi-Agent Supervisor
Use when: The task naturally decomposes into specialist roles (researcher, analyst, writer), and each specialist needs its own tools, prompts, and reasoning chain.
builder = StateGraph(OrchestratorState)
builder.add_node("supervisor", supervisor)
builder.add_node("researcher", researcher_subgraph)
builder.add_node("analyst", analyst_subgraph)
builder.add_conditional_edges("supervisor", route_to_agent)Angular signals used: messages(), subagents(), activeSubagents(), toolCalls(), status()
Decision Matrix
| Factor | Single agent | Single + approval | Multi-agent | |---|---|---|---| | Tool count | 1-10 | 1-10 | 10+ across specialists | | Task complexity | Single domain | Single domain, high stakes | Cross-domain | | Latency budget | Low | Medium (human wait) | Higher (multiple LLM calls) | | State isolation | Shared | Shared + interrupt | Isolated per subagent | | Angular complexity | Low | Medium | Higher |
Begin with a single agent and tools. Add human-in-the-loop when you need approval flows. Graduate to multi-agent only when a single agent's context window cannot hold all the tools and instructions it needs.
What's Next
Learn the graph, node, and edge primitives that agents are built on.
Stream token-by-token responses with multiple stream modes.
Build human-in-the-loop approval flows that pause and resume agents.
Compose multi-agent systems with orchestrators and specialist workers.
Debug agents by stepping through checkpoint history and branching.
How Signals power the reactive model behind streamResource().