Agent Architecture
This document explains how Finn's chat subsystem wires together context management, tool execution, agent runs, and handoffs.
Session / Agent Context
The PolicyContext (a @dataclass) persists all per-conversation state:
| Field | Purpose |
|---|---|
session_id: str | Unique chat session id |
timeline: list[str] | Raw timeline docs left to process |
timeline_info: dict[str, …] | Parsed timeline entries |
policy: str | None | Full policy text (raw) |
policy_info: dict[str, …] | Structured policy summary |
policy_updated: bool | Flag to tell agents summary changed |
Helper Methods
update_policy,get_policy,check_policy_updatesupdate_timeline,get_timeline,check_timeline
Because the class is pure Python + async, you can swap the backing store (e.g. Redis) or extend it without touching agents/tools.
Run-time Context Wrapper
Each tool receives a RunContextWrapper[PolicyContext] injected by the agents framework:
async def some_tool(ctx: RunContextWrapper[PolicyContext], …):
# read
data = ctx.context.policy_info
# write
await ctx.context.update_policy(summary)
This keeps tools stateless & easy to test while still giving them shared memory.
Tool Functions
All tools are decorated with @function_tool; the framework exposes them to the LLM.
@function_tool
async def read_policy(ctx: RunContextWrapper[PolicyContext]) -> dict:
if not await ctx.context.check_policy_object():
return {"error": "No file found"}
return {"text": ctx.context.policy}
Tool Guidelines
- First arg is the wrapper
- Return JSON-serialisable dicts (never raise; send
{ "error": … }) - Use context helper methods for consistency
Out-of-the-box Tools
read_policy,save_policy_summary,check_summary_updatedcheck_timeline_items,get_timeline_item_for_processing,add_new_timeline_info
Agents
Agents are created with this pattern:
aida = Agent(
name="Aida",
instructions="…", # System prompt
tools=[ … ], # Function tools it can invoke
handoffs=[ … ], # Other agents it can delegate to
model="gpt-4o" # (optional) model override
)
You typically instantiate each persona twice:
- Bare version – no handoffs yet – so you can reference it while building handoff objects
- Final version – includes inbound/outbound handoffs
Handoffs
A handoff is an auto-generated tool (via agents.handoff()). When an agent calls it, control — and the same PolicyContext — transfers to the target agent.
to_lise = handoff(
agent=lise,
tool_name_override="transfer_to_lise",
tool_description_override="Pass to Lise so they can review a policy document"
)
aida = Agent(…, handoffs=[to_lise, to_timo])
Handoff Properties
agent=is the destination agent- Name/description overrides give the originating LLM a clear function schema
- Framework handles state switch; you write no extra code
Circular-reference Trick
- Create destination agents without handoffs
- Build handoff objects pointing to them
- Re-create / finalise each agent with its handoff list
Typical Run Loop
The framework-provided run loop works as follows:
- Framework prompts the active agent with user msg + tool schema
- Agent either:
- responds normally, or
- calls a
function_tool, or - calls a
handofftool
- Tool executes Python with shared
PolicyContext - Framework feeds tool result back to the LLM
- On handoff, step 1 restarts with the new agent
Everything is async and stateless outside of PolicyContext, so you can scale horizontally or persist to DBs easily.
Orchestrators & Nested Agents
You can expose another agent as a callable tool using other_agent.as_tool(). This lets an "orchestrator" agent sequence multiple specialists in a single run.
summary_tool = summary_agent.as_tool(
tool_name="summary_agent_create_summary",
tool_description="Ask the summary agent to write the summary of the claim"
)
aco = Agent(
name="AddClaimOrchestrator",
tools=[summary_tool, …]
)
Orchestrator Guidelines
- Keep orchestrator instructions explicit about the order in which nested agents should run
- The orchestrator simply calls each sub-agent tool and waits for its JSON response before continuing
- Because each nested agent still receives the same context, data saved by earlier agents is immediately visible to later ones
Persistence with ClaimContext
Unlike the chat PolicyContext, ClaimContext persists to SQLite via finalize() and can be re-hydrated with from_db().
Key Points
- JSON fields (
policy_summary,warnings) are serialised withjson.dumps()/json.loads() uploaded_files_overridelets you inject fresh uploads without rewriting DB rows- Call
context.finalize()once agents have populatedsummary,policy_review,warnings, etc.
If you swap SQLite for another store, only this class needs modification.
Warning Registry & Severity Enrichment
A warning_registry maps WarningType → Severity. The save_data_warnings tool enriches each warning object before saving:
severity = warning_registry.get(warning.type, Severity.INFO)
enriched_warning = {..., 'severity': severity}
Takeaways
- Perform post-processing inside the tool so the LLM stays focused on reasoning, not bookkeeping
- Centralise mappings in a module-level constant for easy tuning
Enforcing a "Final Tool Call"
Currently the guarantee is soft—agent instructions explicitly tell the LLM to call save_* tools at the end of its turn. If you need a hard guarantee:
- Wrap
Agent.run()in middleware that inspects the last model message for a tool call; if none is found, automatically append a system prompt like "Please call one of the required save tools now." - Alternatively, add a helper
agent.require_final_tool(tool_names: list[str])that injects a guard-rail system message every turn
This enhancement is not present in the codebase but is straightforward to layer on top.
Porting Checklist
When porting this architecture to another project:
- Copy
PolicyContext; extend as needed - Implement (or import) the
agentsframework pieces:Agenthandoff()- Decorators
@function_tool,RunContextWrapper
- Re-implement tool functions pointing to your context class
- Instantiate agents + handoffs via the 3-step circular-reference pattern
- Wire the agents into your chat orchestration loop