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:

FieldPurpose
session_id: strUnique chat session id
timeline: list[str]Raw timeline docs left to process
timeline_info: dict[str, …]Parsed timeline entries
policy: str | NoneFull policy text (raw)
policy_info: dict[str, …]Structured policy summary
policy_updated: boolFlag to tell agents summary changed

Helper Methods

  • update_policy, get_policy, check_policy_updates
  • update_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

  1. First arg is the wrapper
  2. Return JSON-serialisable dicts (never raise; send { "error": … })
  3. Use context helper methods for consistency

Out-of-the-box Tools

  • read_policy, save_policy_summary, check_summary_updated
  • check_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:

  1. Bare version – no handoffs yet – so you can reference it while building handoff objects
  2. 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

  1. Create destination agents without handoffs
  2. Build handoff objects pointing to them
  3. Re-create / finalise each agent with its handoff list

Typical Run Loop

The framework-provided run loop works as follows:

  1. Framework prompts the active agent with user msg + tool schema
  2. Agent either:
    • responds normally, or
    • calls a function_tool, or
    • calls a handoff tool
  3. Tool executes Python with shared PolicyContext
  4. Framework feeds tool result back to the LLM
  5. 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

  1. Keep orchestrator instructions explicit about the order in which nested agents should run
  2. The orchestrator simply calls each sub-agent tool and waits for its JSON response before continuing
  3. 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 with json.dumps() / json.loads()
  • uploaded_files_override lets you inject fresh uploads without rewriting DB rows
  • Call context.finalize() once agents have populated summary, policy_review, warnings, etc.

If you swap SQLite for another store, only this class needs modification.


Warning Registry & Severity Enrichment

A warning_registry maps WarningTypeSeverity. The save_data_warnings tool enriches each warning object before saving:

severity = warning_registry.get(warning.type, Severity.INFO)
enriched_warning = {..., 'severity': severity}

Takeaways

  1. Perform post-processing inside the tool so the LLM stays focused on reasoning, not bookkeeping
  2. 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:

  1. Copy PolicyContext; extend as needed
  2. Implement (or import) the agents framework pieces:
    • Agent
    • handoff()
    • Decorators @function_tool, RunContextWrapper
  3. Re-implement tool functions pointing to your context class
  4. Instantiate agents + handoffs via the 3-step circular-reference pattern
  5. Wire the agents into your chat orchestration loop

Was this page helpful?