Skip to content

Commit a3d9326

Browse files
tylerslatonclaude
andcommitted
fix(showcase/langgraph-python): correctness fixes for demos
Real demo-time bugs in the langgraph-python integration: - `interrupt-headless` was rendering hardcoded stale slot dates from a `DEFAULT_SLOTS` constant instead of reading `payload.slots` that the backend `interrupt(...)` already supplies. Sibling `gen-ui-interrupt` does this correctly. Headless variant now matches. - `_shared/interrupt-fallback-slots.ts` (new) — the JS fallback for when the backend returns no `slots` array. Generates relative to Date.now() so the picker never shows past dates. Used by both `interrupt-headless` and `gen-ui-interrupt`. Deletes the old stale- literal `gen-ui-interrupt/fallback-slots.ts` (dates from April that had already decayed). - `interrupt_agent.py` — replaced hardcoded `timezone(timedelta(-7))` PDT with `zoneinfo.ZoneInfo("America/Los_Angeles")` so the demo doesn't lie about offsets in winter. Also fixed a Sunday edge case where `next_monday` collapsed to the same date as `tomorrow` (both Python and the JS fallback) — added a `<= 1` skip-a-week guard. - `package.json#dev` ran `uvicorn agent_server:app --port 8000` but `src/agent_server.py` was a 3-line stub with no `app` symbol AND the API route pointed at port 8123. Replaced with the canonical `langgraph_cli dev --port 8123` invocation; deleted the dead stub. - `entrypoint.sh:47` smoke-checked `src/agents/tools.py` (a file that never existed) so every Railway boot logged a phantom ERROR. Dropped. - `reasoning_agent.py` and `tool_rendering_reasoning_chain_agent.py` intentionally omit `CopilotKitMiddleware` (they exercise only reasoning-token streaming, no frontend tools / app context). Added a one-line comment so a future maintainer doesn't cargo-cult it back in. - `subagents._invoke_sub_agent` text-block walker had a regression where a `{"type":"text","text":null}` payload (a known provider quirk) would crash `"".join(parts)` with `TypeError: sequence item N: expected str instance, NoneType found`. Restored the `isinstance(block.get("text"), str)` guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2f6816f commit a3d9326

11 files changed

Lines changed: 121 additions & 75 deletions

File tree

showcase/integrations/langgraph-python/entrypoint.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ fi
4444
echo "[entrypoint] Checking files..."
4545
ls -la langgraph.json 2>/dev/null && echo "[entrypoint] langgraph.json: OK" || echo "[entrypoint] ERROR: langgraph.json missing!"
4646
ls -la src/agents/main.py 2>/dev/null && echo "[entrypoint] src/agents/main.py: OK" || echo "[entrypoint] ERROR: src/agents/main.py missing!"
47-
ls -la src/agents/tools.py 2>/dev/null && echo "[entrypoint] src/agents/tools.py: OK" || echo "[entrypoint] ERROR: src/agents/tools.py missing!"
4847
ls -la .next/server 2>/dev/null > /dev/null && echo "[entrypoint] .next/server: OK" || echo "[entrypoint] ERROR: .next build missing!"
4948

5049
echo "[entrypoint] langgraph.json contents:"

showcase/integrations/langgraph-python/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"version": "0.1.0",
44
"private": true,
55
"scripts": {
6-
"dev": "concurrently \"next dev --turbopack\" \"PYTHONPATH=. python -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 --reload\"",
6+
"dev": "concurrently \"next dev --turbopack\" \"python -u -m langgraph_cli dev --config langgraph.json --host 0.0.0.0 --port 8123 --no-browser\"",
77
"build": "next build",
88
"start": "next start",
99
"lint": "next lint",

showcase/integrations/langgraph-python/src/agent_server.py

Lines changed: 0 additions & 3 deletions
This file was deleted.

showcase/integrations/langgraph-python/src/agents/interrupt_agent.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010

1111
from __future__ import annotations
1212

13-
from datetime import datetime, timedelta, timezone
13+
from datetime import datetime, time, timedelta
1414
from typing import Any, List, Optional
15+
from zoneinfo import ZoneInfo
1516

1617
from langchain.agents import create_agent
1718
from langchain_core.tools import tool
@@ -29,31 +30,35 @@
2930
"cancelled."
3031
)
3132

33+
# Demo-only fixed timezone. A real app would use the user's calendar +
34+
# locale (e.g. zoneinfo.ZoneInfo(user.timezone) and Google Calendar /
35+
# Outlook availability); we hardcode Pacific so screenshots are stable.
36+
_DEMO_TZ = ZoneInfo("America/Los_Angeles")
3237

33-
def _candidate_slots() -> List[dict]:
34-
"""Generate a small set of upcoming candidate time slots.
3538

36-
Returned slots are surfaced to the frontend as part of the interrupt
37-
payload so the picker UI shows times relative to "now", not stale
38-
hardcoded dates baked into the frontend.
39-
"""
40-
# Pacific Time offset (PDT). Showcase only — a real app would use a
41-
# proper timezone library and the user's calendar availability.
42-
tz = timezone(timedelta(hours=-7))
43-
now = datetime.now(tz)
39+
def _candidate_slots() -> List[dict]:
40+
"""Upcoming candidate slots, relative to "now" so the picker never
41+
shows stale dates."""
42+
now = datetime.now(_DEMO_TZ)
4443
tomorrow = (now + timedelta(days=1)).date()
45-
next_monday = (now + timedelta(days=(7 - now.weekday()) % 7 or 7)).date()
46-
47-
def at(d, hour: int, minute: int = 0) -> datetime:
48-
return datetime(d.year, d.month, d.day, hour, minute, tzinfo=tz)
49-
44+
# Skip a week when the result would collide with `tomorrow` — i.e.
45+
# today is Mon (0 days away, picker would show two slots both
46+
# labelled "Monday") or Sun (1 day away, picker would show
47+
# "Tomorrow" and "Monday" both pointing at the same date).
48+
days_to_monday = (7 - now.weekday()) % 7
49+
if days_to_monday <= 1:
50+
days_to_monday += 7
51+
next_monday = (now + timedelta(days=days_to_monday)).date()
5052
candidates = [
51-
("Tomorrow 10:00 AM", at(tomorrow, 10)),
52-
("Tomorrow 2:00 PM", at(tomorrow, 14)),
53-
("Monday 9:00 AM", at(next_monday, 9)),
54-
("Monday 3:30 PM", at(next_monday, 15, 30)),
53+
("Tomorrow 10:00 AM", tomorrow, time(10, 0)),
54+
("Tomorrow 2:00 PM", tomorrow, time(14, 0)),
55+
("Monday 9:00 AM", next_monday, time(9, 0)),
56+
("Monday 3:30 PM", next_monday, time(15, 30)),
57+
]
58+
return [
59+
{"label": label, "iso": datetime.combine(d, t, _DEMO_TZ).isoformat()}
60+
for label, d, t in candidates
5561
]
56-
return [{"label": label, "iso": dt.isoformat()} for label, dt in candidates]
5762

5863

5964
# @region[backend-interrupt-tool]

showcase/integrations/langgraph-python/src/agents/reasoning_agent.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626

2727
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5.4")
2828

29+
# No CopilotKitMiddleware — this demo exercises only reasoning-token streaming
30+
# through the OpenAI Responses API and doesn't consume frontend tools or app
31+
# context. The other showcase agents that *do* expose CopilotKit features add
32+
# `middleware=[CopilotKitMiddleware()]` explicitly.
2933
graph = create_deep_agent(
3034
model=init_chat_model(
3135
f"openai:{REASONING_MODEL}",

showcase/integrations/langgraph-python/src/agents/subagents.py

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -113,45 +113,33 @@ class AgentState(BaseAgentState):
113113

114114

115115
def _invoke_sub_agent(agent, task: str) -> str:
116-
"""Run a sub-agent on `task` and return its final prose message.
117-
118-
Walks the sub-agent's returned messages list newest → oldest and
119-
returns the first AIMessage with non-empty string content. This
120-
avoids two failure modes:
121-
122-
1. The final message may be an empty AIMessage that only carries
123-
`tool_calls` (no text content) — falling back to `messages[-1]`
124-
would surface an empty string.
125-
2. If the supervisor's chat history leaks into the sub-agent's
126-
result list, we still pick the AI's actual prose answer for
127-
this task instead of a stale assistant intro.
128-
"""
116+
"""Run a sub-agent on `task` and return its final prose message."""
129117
result = agent.invoke({"messages": [HumanMessage(content=task)]})
130118
messages = result.get("messages", [])
119+
# Walk newest -> oldest so we pick the answer for THIS task, not a stale
120+
# intro. Skip empty AIMessages that only carry tool_calls.
131121
for msg in reversed(messages):
132122
if isinstance(msg, AIMessage):
133123
content = msg.content
134124
if isinstance(content, str) and content.strip():
135125
return content
136126
# Some providers stream content as a list of content blocks
137-
# (e.g. {"type": "text", "text": "..."}). Concatenate any
138-
# text blocks we find.
127+
# (e.g. {"type": "text", "text": "..."}); concatenate the text.
128+
# The `isinstance(block.get("text"), str)` guard rejects
129+
# `{"type": "text", "text": null}` payloads — a known provider
130+
# quirk — that would otherwise crash `"".join(...)` with
131+
# `TypeError: sequence item N: expected str instance, NoneType found`.
139132
if isinstance(content, list):
140-
parts: list[str] = []
141-
for block in content:
142-
if isinstance(block, dict) and block.get("type") == "text":
143-
text = block.get("text")
144-
if isinstance(text, str):
145-
parts.append(text)
133+
parts = [
134+
block["text"]
135+
for block in content
136+
if isinstance(block, dict)
137+
and block.get("type") == "text"
138+
and isinstance(block.get("text"), str)
139+
]
146140
joined = "".join(parts).strip()
147141
if joined:
148142
return joined
149-
# Last-resort fallback: surface an explicit sentinel rather than ""
150-
# or a Python repr like "[{'type': 'text', ...}]" leaking from a
151-
# block-list `content`. The d5-subagents probe asserts this exact
152-
# sentinel against its boilerplate-marker list so an empty/garbled
153-
# sub-agent result fails the genuine-pass test instead of silently
154-
# rendering an empty card.
155143
return SUB_AGENT_EMPTY_SENTINEL
156144

157145

showcase/integrations/langgraph-python/src/agents/tool_rendering_reasoning_chain_agent.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ def roll_dice(sides: int = 6) -> dict:
6464

6565
REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-5.4")
6666

67+
# No CopilotKitMiddleware — this demo combines reasoning-token streaming with
68+
# backend tool rendering, but doesn't consume any frontend tools or app context.
69+
# The frontend renders the tool calls via `useRenderTool`, which works off the
70+
# AG-UI tool-call event stream and doesn't require server-side middleware.
6771
graph = create_deep_agent(
6872
model=init_chat_model(
6973
f"openai:{REASONING_MODEL}",
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Shared fallback time-slot generator for the interrupt demos
2+
// (`gen-ui-interrupt`, `interrupt-headless`). The interrupt backend
3+
// (`src/agents/interrupt_agent.py`) supplies its own candidate slots
4+
// inside the interrupt payload — these fallbacks only run if the
5+
// payload arrives without them. Generating relative to `Date.now()`
6+
// keeps the fallback from rotting, which previously had hardcoded
7+
// dates that decayed within a week of being authored.
8+
9+
export interface TimeSlot {
10+
label: string;
11+
iso: string;
12+
}
13+
14+
function atLocal(date: Date, hour: number, minute = 0): Date {
15+
return new Date(
16+
date.getFullYear(),
17+
date.getMonth(),
18+
date.getDate(),
19+
hour,
20+
minute,
21+
0,
22+
0,
23+
);
24+
}
25+
26+
function nextMonday(from: Date): Date {
27+
// `getDay()` is 0=Sun, 1=Mon, ..., 6=Sat. We want the next Monday
28+
// that's at LEAST 2 days away — otherwise "Monday" would collide
29+
// with "Tomorrow" on Sunday (offset would be 1) or with itself on
30+
// Monday (offset would be 0). Mirrors interrupt_agent.py.
31+
const day = from.getDay();
32+
let offset = (1 - day + 7) % 7;
33+
if (offset <= 1) offset += 7;
34+
const next = new Date(from);
35+
next.setDate(from.getDate() + offset);
36+
return next;
37+
}
38+
39+
export function generateFallbackSlots(now: Date = new Date()): TimeSlot[] {
40+
const tomorrow = new Date(now);
41+
tomorrow.setDate(now.getDate() + 1);
42+
const monday = nextMonday(now);
43+
44+
const candidates: Array<[string, Date]> = [
45+
["Tomorrow 10:00 AM", atLocal(tomorrow, 10)],
46+
["Tomorrow 2:00 PM", atLocal(tomorrow, 14)],
47+
["Monday 9:00 AM", atLocal(monday, 9)],
48+
["Monday 3:30 PM", atLocal(monday, 15, 30)],
49+
];
50+
51+
return candidates.map(([label, date]) => ({
52+
label,
53+
iso: date.toISOString(),
54+
}));
55+
}

showcase/integrations/langgraph-python/src/app/demos/gen-ui-interrupt/fallback-slots.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

showcase/integrations/langgraph-python/src/app/demos/gen-ui-interrupt/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
useInterrupt,
77
} from "@copilotkit/react-core/v2";
88
import { TimePickerCard, TimeSlot } from "./_components/time-picker-card";
9-
import { FALLBACK_SLOTS } from "./fallback-slots";
9+
import { generateFallbackSlots } from "../_shared/interrupt-fallback-slots";
1010
import { useGenUiInterruptSuggestions } from "./suggestions";
1111

1212
export default function GenUiInterruptDemo() {
@@ -42,7 +42,7 @@ function Chat() {
4242
const slots =
4343
payload.slots && payload.slots.length > 0
4444
? payload.slots
45-
: FALLBACK_SLOTS;
45+
: generateFallbackSlots();
4646
return (
4747
<TimePickerCard
4848
topic={payload.topic ?? "a call"}

0 commit comments

Comments
 (0)