Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ You can run the following examples:
- [**Cat Lounge**](examples/cat-lounge) - caretaker for a virtual cat that helps improve energy, happiness, and cleanliness stats.
- [**Customer Support**](examples/customer-support) – airline concierge with live itinerary data, timeline syncing, and domain-specific tools.
- [**News Guide**](examples/news-guide) – Foxhollow Dispatch newsroom assistant with article search, @-mentions, and page-aware responses.
- [**Metro Map**](examples/metro-map) – chat-driven metro planner with a React Flow network of lines and stations.

## Quickstart

Expand All @@ -19,6 +20,7 @@ You can run the following examples:
| Cat Lounge | `npm run cat-lounge` | `cd examples/cat-lounge && npm install && npm run start` | http://localhost:5170 |
| Customer Support | `npm run customer-support` | `cd examples/customer-support && npm install && npm start` | http://localhost:5171 |
| News Guide | `npm run news-guide` | `cd examples/news-guide && npm install && npm run start` | http://localhost:5172 |
| Metro Map | `npm run metro-map` | `cd examples/metro-map && npm install && npm run start` | http://localhost:5173 |

## Feature index

Expand All @@ -29,6 +31,8 @@ You can run the following examples:
- **News Guide**:
- The agent leans on a suite of retrieval tools—`list_available_tags_and_keywords`, `get_article_by_id`, `search_articles_by_tags/keywords/exact_text`, and `get_current_page`—before responding, and uses `show_article_list_widget` to present results ([news_agent.py](examples/news-guide/backend/app/agents/news_agent.py)).
- Hidden context such as the featured landing page is normalized into agent input so summaries and recommendations stay grounded ([news_agent.py](examples/news-guide/backend/app/agents/news_agent.py)).
- **Metro Map**:
- The metro agent syncs map data with `get_map` and surfaces line and station details via `list_lines`, `list_stations`, `get_line_route`, and `get_station` before giving directions ([metro_map_agent.py](examples/metro-map/backend/app/agents/metro_map_agent.py)).

### Client tool calls that mutate UI state

Expand All @@ -49,6 +53,8 @@ You can run the following examples:
- **News Guide**:
- Retrieval tools stream `ProgressUpdateEvent` messages while searching tags, authors, keywords, exact text, or loading the current page so the UI surfaces “Searching…”/“Loading…” states ([news_agent.py](examples/news-guide/backend/app/agents/news_agent.py)).
- The event finder emits progress as it scans dates, days of week, or keywords to keep users informed during longer lookups ([event_finder_agent.py](examples/news-guide/backend/app/agents/event_finder_agent.py)).
- **Metro Map**:
- The metro agent emits a quick sync update when it loads the line data via `get_map` ([metro_map_agent.py](examples/metro-map/backend/app/agents/metro_map_agent.py)).

### Widgets without actions

Expand All @@ -72,6 +78,11 @@ You can run the following examples:
- **News Guide**:
- The `view_event_details` action is processed server-side to update the timeline widget with expanded descriptions without a round trip to the model ([server.py](examples/news-guide/backend/app/server.py)).

### Canvas layout

- **Metro Map**:
- The React Flow canvas draws only metro nodes and colored edges—no custom canvas overlay—highlighting stations and line interchanges ([MetroMapCanvas.tsx](examples/metro-map/frontend/src/components/MetroMapCanvas.tsx)).

### Entity tags (@-mentions)

- **News Guide**:
Expand Down
14 changes: 3 additions & 11 deletions examples/cat-lounge/backend/app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@
Attachment,
HiddenContextItem,
ThreadItemDoneEvent,
ThreadItemUpdated,
ThreadItemReplacedEvent,
ThreadMetadata,
ThreadStreamEvent,
UserMessageItem,
WidgetItem,
WidgetRootUpdated,
)
from openai.types.responses import ResponseInputContentParam
from pydantic import ValidationError
Expand Down Expand Up @@ -148,15 +147,8 @@ async def _handle_select_name_action(
selection = current_state.name if is_already_named else name
widget = build_name_suggestions_widget(options, selected=selection)

# Save the updated widget so that if the user views the thread again, they will
# see the updated version of the widget.
updated_widget_item = sender.model_copy(update={"widget": widget})
await self.store.save_item(thread.id, updated_widget_item, context=context)

# Stream back the update so that chatkit can render the updated widget,
yield ThreadItemUpdated(
item_id=sender.id,
update=WidgetRootUpdated(widget=widget),
yield ThreadItemReplacedEvent(
item=sender.model_copy(update={"widget": widget}),
)

if is_already_named:
Expand Down
9 changes: 9 additions & 0 deletions examples/metro-map/backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.env
.ruff_cache/
.pytest_cache/
.coverage/
*.log
Empty file.
227 changes: 227 additions & 0 deletions examples/metro-map/backend/app/agents/metro_map_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
from __future__ import annotations

from datetime import datetime
from typing import Annotated

from agents import Agent, RunContextWrapper, StopAtTools, function_tool
from chatkit.agents import AgentContext, ClientToolCall
from chatkit.types import (
AssistantMessageContent,
AssistantMessageItem,
ProgressUpdateEvent,
ThreadItemDoneEvent,
)
from pydantic import BaseModel, ConfigDict, Field

from ..data.metro_map_store import Line, MetroMap, MetroMapStore, Station
from ..memory_store import MemoryStore
from ..request_context import RequestContext
from ..widgets.line_select_widget import build_line_select_widget

INSTRUCTIONS = """
You are a concise metro planner helping city planners update the Orbital Transit map.
Give short answers, list 2–3 options, and highlight the lines or interchanges involved.

Before recommending a route, sync the latest map with the provided tools. Cite line
colors when helpful (e.g., "take Red then Blue at Central Exchange").

When the user asks what to do next, reply with 2 concise follow-up ideas and pick one to lead with.
Default to actionable options like adding another station on the same line or explaining how to travel
from the newly added station to a nearby destination.

When the user mentions a station, always call the `get_map` tool to sync the latest map before responding.

When a user wants to add a station (e.g. "I would like to add a new metro station." or "Add another station"):
- If the user did not specify a line, you MUST call `show_line_selector` with a message prompting them to choose one
from the list of lines. You must NEVER ask the user to choose a line without calling `show_line_selector` first.
This applies even if you just added a station—treat each new "add a station" turn as needing a fresh line selection
unless the user explicitly included the line in that same turn or in the latest message via <LINE_SELECTED>.
- If the user replies with a number to pick one of your follow-up options AND that option involves adding a station,
treat this as a fresh station-add request and immediately call `show_line_selector` before asking anything else.
- If the user did not specify a station name, ask them to enter a name.
- If the user did not specify whether to add the station to the end of the line or the beginning, ask them to choose one.
- When you have all the information you need, call the `add_station` tool with the station name, line id, and append flag.

Describing:
- After a new station has been added, describe it to the user in a whimsical and poetic sentence.
- When describing a station to the user, omit the station id and coordinates.
- When describing a line to the user, omit the line id and color.

When a user wants to plan a route:
- If the user did not specify a starting or detination station, ask them to choose them from the list of stations.
- Provide a one-sentence route, the estimated travel time, and points of interest along the way.
- Avoid over-explaining and stay within the given station list.

Custom tags:
- <LINE_SELECTED>{line_id}</LINE_SELECTED> - when the user has selected a line, you can use this tag to reference the line id.
When this is the latest message, acknowledge the selection.
- <STATION_TAG>...</STATION_TAG> - contains full station details (id, name, description, coordinates, and served lines with ids/colors/orientations).
Use the data inside the tag directly; do not call `get_station` just to resolve a tagged station.
"""


class MetroAgentContext(AgentContext):
model_config = ConfigDict(arbitrary_types_allowed=True)
store: Annotated[MemoryStore, Field(exclude=True)]
metro: Annotated[MetroMapStore, Field(exclude=True)]
request_context: Annotated[RequestContext, Field(exclude=True)]


class MapResult(BaseModel):
map: MetroMap


class LineListResult(BaseModel):
lines: list[Line]


class StationListResult(BaseModel):
stations: list[Station]


class LineDetailResult(BaseModel):
line: Line
stations: list[Station]


class StationDetailResult(BaseModel):
station: Station
lines: list[Line]


@function_tool(description_override="Show a clickable widget listing metro lines.")
async def show_line_selector(ctx: RunContextWrapper[MetroAgentContext], message: str):
widget = build_line_select_widget(ctx.context.metro.list_lines())
await ctx.context.stream(
ThreadItemDoneEvent(
item=AssistantMessageItem(
thread_id=ctx.context.thread.id,
id=ctx.context.generate_id("message"),
created_at=datetime.now(),
content=[AssistantMessageContent(text=message)],
),
)
)
await ctx.context.stream_widget(widget)


@function_tool(description_override="Load the latest metro map with lines and stations.")
async def get_map(ctx: RunContextWrapper[MetroAgentContext]) -> MapResult:
print("[TOOL CALL] get_map")
metro_map = ctx.context.metro.get_map()
await ctx.context.stream(ProgressUpdateEvent(text="Retrieving the latest metro map..."))
return MapResult(map=metro_map)


@function_tool(description_override="List all metro lines with their colors and endpoints.")
async def list_lines(ctx: RunContextWrapper[MetroAgentContext]) -> LineListResult:
print("[TOOL CALL] list_lines")
return LineListResult(lines=ctx.context.metro.list_lines())


@function_tool(description_override="List all stations and which lines serve them.")
async def list_stations(ctx: RunContextWrapper[MetroAgentContext]) -> StationListResult:
print("[TOOL CALL] list_stations")
return StationListResult(stations=ctx.context.metro.list_stations())


@function_tool(description_override="Get the ordered stations for a specific line.")
async def get_line_route(
ctx: RunContextWrapper[MetroAgentContext],
line_id: str,
) -> LineDetailResult:
print("[TOOL CALL] get_line_route", line_id)
line = ctx.context.metro.find_line(line_id)
if not line:
raise ValueError(f"Line '{line_id}' was not found.")
stations = ctx.context.metro.stations_for_line(line_id)
return LineDetailResult(line=line, stations=stations)


@function_tool(description_override="Look up a single station and the lines serving it.")
async def get_station(
ctx: RunContextWrapper[MetroAgentContext],
station_id: str,
) -> StationDetailResult:
print("[TOOL CALL] get_station", station_id)
station = ctx.context.metro.find_station(station_id)
if not station:
raise ValueError(f"Station '{station_id}' was not found.")
lines = [ctx.context.metro.find_line(line_id) for line_id in station.lines]
return StationDetailResult(
station=station,
lines=[line for line in lines if line],
)


@function_tool(
description_override=(
"""Add a new station to the metro map.
- `station_name`: The name of the station to add.
- `line_id`: The id of the line to add the station to. Should be one of the ids returned by list_lines.
- `append`: Whether to add the station to the end of the line or the beginning. Defaults to True.
"""
)
)
async def add_station(
ctx: RunContextWrapper[MetroAgentContext],
station_name: str,
line_id: str,
append: bool = True,
) -> MapResult:
station_name = station_name.strip().title()
print(f"[TOOL CALL] add_station: {station_name} to {line_id}")
await ctx.context.stream(ProgressUpdateEvent(text="Adding station..."))
try:
updated_map, new_station = ctx.context.metro.add_station(station_name, line_id, append)
ctx.context.client_tool_call = ClientToolCall(
name="add_station",
arguments={
"stationId": new_station.id,
"map": updated_map.model_dump(mode="json"),
},
)
return MapResult(map=updated_map)
except Exception as e:
print(f"[ERROR] add_station: {e}")
await ctx.context.stream(
ThreadItemDoneEvent(
item=AssistantMessageItem(
thread_id=ctx.context.thread.id,
id=ctx.context.generate_id("message"),
created_at=datetime.now(),
content=[
AssistantMessageContent(
text=f"There was an error adding **{station_name}**"
)
],
),
)
)
raise


metro_map_agent = Agent[MetroAgentContext](
name="metro_map",
instructions=INSTRUCTIONS,
model="gpt-4o-mini",
tools=[
# Retrieve map data
get_map,
list_lines,
list_stations,
get_line_route,
get_station,
# Respond with a widget
show_line_selector,
# Update the metro map
add_station,
],
# Stop inference after client tool call or widget output
tool_use_behavior=StopAtTools(
stop_at_tool_names=[
add_station.name,
show_line_selector.name,
]
),
)
12 changes: 12 additions & 0 deletions examples/metro-map/backend/app/agents/title_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from agents import Agent
from chatkit.agents import AgentContext

title_agent = Agent[AgentContext](
model="gpt-5-nano",
name="Title generator",
instructions="""
Generate a short conversation title for a metro planning assistant chatting with a user.
The first user message in the thread is included below to provide context. Use your own
words, respond with 2-5 words, and avoid punctuation.
""",
)
27 changes: 27 additions & 0 deletions examples/metro-map/backend/app/data/metro_map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"id": "orbital-transit",
"name": "Orbital Transit",
"summary": "Three stellar corridors linking planets and systems with crossovers at Vega Reach and Orionis Gate for easy transfers.",
"stations": [
{ "id": "helios-prime", "name": "Helios Prime", "x": 0, "y": -1, "lines": ["blue"], "description": "A sun-warmed hub where travelers gather beneath golden light that never quite fades." },
{ "id": "cinderia", "name": "Cinderia", "x": 1, "y": -1, "lines": ["blue"], "description": "A drifting ember-world whose platforms glow like the last sparks of a dying star." },
{ "id": "vega-reach", "name": "Vega Reach", "x": 2, "y": 0, "lines": ["blue", "purple"], "description": "A crystalline crossing where starlight refracts into prismatic paths for wandering souls." },
{ "id": "orionis-gate", "name": "Orionis Gate", "x": 3, "y": 0, "lines": ["blue", "purple", "orange"], "description": "A grand celestial threshold where every corridor feels like the beginning of an old legend." },
{ "id": "titan-border", "name": "Titan Border", "x": 4, "y": 0, "lines": ["blue"], "description": "A frontier station perched on the hush between vast ice fields and silent cosmic tides." },
{ "id": "cygnus-way", "name": "Cygnus Way", "x": 5, "y": 0, "lines": ["blue"], "description": "A gentle outpost where gull-wing nebulae drift lazily above the platform rails." },
{ "id": "kepler-forge", "name": "Kepler Forge", "x": -1, "y": 1, "lines": ["purple"], "description": "An industrious orbit-workshop where constellations seem hammered into shape each dusk." },
{ "id": "arcturus-dorange", "name": "Arcturus Dorange", "x": 0, "y": 1, "lines": ["purple"], "description": "A warm, fragrant stop known for amber skies that smell faintly of citrus and stardust." },
{ "id": "sagan-halo", "name": "Sagan Halo", "x": 4, "y": 1, "lines": ["purple"], "description": "A luminous ring-station that hums softly with the quiet wonder of distant worlds." },
{ "id": "lyra-verge", "name": "Lyra Verge", "x": 5, "y": 1, "lines": ["purple"], "description": "A melodic crossing where cosmic winds carry the echo of unseen harps." },
{ "id": "lumen-cradle", "name": "Lumen Cradle", "x": 3, "y": -3, "lines": ["orange"], "description": "A glowing sanctuary nestled in deep space where light itself seems to rest and dream." },
{ "id": "proxima-step", "name": "Proxima Step", "x": 3, "y": -2, "lines": ["orange"], "description": "A humble midpoint marked by wayfarers’ footprints glittering like distant promises." },
{ "id": "zephyr-system", "name": "Zephyr System", "x": 3, "y": -1, "lines": ["orange"], "description": "A breezy orbital junction where gentle solar winds sigh through its floating arches." },
{ "id": "altair-rim", "name": "Altair Rim", "x": 3, "y": 1, "lines": ["orange"], "description": "A bright ridge-station where shimmering pathways skim the edge of luminous voids." },
{ "id": "farpoint-prime", "name": "Farpoint Prime", "x": 3, "y": 2, "lines": ["orange"], "description": "A serene apex of the line where travelers pause to watch galaxies bloom in silence." }
],
"lines": [
{ "id": "blue", "name": "Blue Line", "color": "#06b6d4", "orientation": "horizontal", "stations": ["helios-prime", "cinderia", "vega-reach", "orionis-gate", "titan-border", "cygnus-way"] },
{ "id": "purple", "name": "Purple Line", "color": "#a855f7", "orientation": "horizontal", "stations": ["kepler-forge", "arcturus-dorange", "vega-reach", "orionis-gate", "sagan-halo", "lyra-verge"] },
{ "id": "orange", "name": "Orange Line", "color": "#f59e0b", "orientation": "vertical", "stations": ["lumen-cradle", "proxima-step", "zephyr-system", "orionis-gate", "altair-rim", "farpoint-prime"] }
]
}
Loading