Skip to content
Open
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
21 changes: 16 additions & 5 deletions backend/src/agent/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,19 @@ class AgentDeps:
os.environ.setdefault("GEMINI_API_KEY", Settings.GEMINI_API_KEY)
# `google-gla:` was dropped in pydantic-ai 2.x — the prefix is now `google:`
model_name = Settings.GEMINI_MODEL_NAME or "google:gemini-3-flash-preview"
# Reasoning tokens share the output budget on Gemini's thinking models.
# Capped below max_tokens so the post-tool-call turn always has room left
# to answer — uncapped, the model has burned the whole budget thinking,
# returned neither text nor a tool call, and pydantic-ai's forced retry
# made it re-answer from scratch: duplicated reply, then a hard failure
# once retries ran out ("Exceeded maximum output retries").
_reasoning_settings = {"google_thinking_config": {"thinking_budget": 8192}}
else:
model_name = Settings.OPENAI_MODEL_NAME or "openai:gpt-5.2"
# Same failure class applies to OpenAI's reasoning models (o-series/gpt-5):
# reasoning tokens count against max_completion_tokens too. No numeric
# budget knob here, only an effort level — capped so it can't dominate.
_reasoning_settings = {"openai_reasoning_effort": "low"}

_agent_cache: dict[str, Agent] = {}

Expand All @@ -40,11 +51,11 @@ def build_agent(city) -> Agent:
model_name,
system_prompt=city.system_prompt + UI_TOOLS_PROMPT,
deps_type=AgentDeps,
# Without an explicit cap the provider default applies, and on Gemini's
# thinking models reasoning tokens come out of that same output budget —
# the model can spend the whole allowance thinking and die with
# "token limit exceeded before any response was generated".
model_settings=ModelSettings(max_tokens=8192),
# Without an explicit cap the provider default applies — see
# `_reasoning_settings` above for why that's dangerous on a thinking
# model. 16384 gives that capped reasoning budget headroom to still
# leave room for the actual reply.
model_settings=ModelSettings(max_tokens=16384, **_reasoning_settings),
)

search.register(agent, city)
Expand Down
17 changes: 12 additions & 5 deletions frontend/app/components/ConversationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,11 @@ export default function ConversationSidebar({
router.replace("/login");
}

const fetchPage = useCallback(async (pageNum: number, append = false) => {
if (pageNum === 1) { setLoading(true); setError(null); }
const fetchPage = useCallback(async (pageNum: number, append = false, silent = false) => {
// `silent` is for background refreshes (e.g. after switching threads) where
// we already have data on screen — showing the skeleton would blank a list
// the user is actively looking at for no reason.
if (pageNum === 1 && !silent) { setLoading(true); setError(null); }
try {
const res = await fetch(`/api/conversations?limit=10&page=${pageNum}`, { credentials: "include" });
if (!res.ok) throw new Error(`${res.status}`);
Expand All @@ -163,10 +166,10 @@ export default function ConversationSidebar({
setConversations(prev => append ? [...prev, ...convs] : convs);
setHasMore(Array.isArray(data) ? false : (data.has_more ?? false));
} catch (e) {
setError("Could not load conversations.");
if (!silent) setError("Could not load conversations.");
console.error("[ConversationSidebar]", e);
} finally {
if (pageNum === 1) setLoading(false);
if (pageNum === 1 && !silent) setLoading(false);
}
}, []);

Expand Down Expand Up @@ -204,8 +207,12 @@ export default function ConversationSidebar({

useEffect(() => { fetchPage(1); }, [fetchPage]);

// Refresh the list after switching threads (title/count may have changed) —
// skip the very first run, the mount effect above already just fetched it.
const didMount = useRef(false);
useEffect(() => {
const t = setTimeout(() => fetchPage(1), 1500);
if (!didMount.current) { didMount.current = true; return; }
const t = setTimeout(() => fetchPage(1, false, true), 1500);
return () => clearTimeout(t);
}, [activeThreadId, fetchPage]);

Expand Down