diff --git a/agent/agent.py b/agent/agent.py index 41c07d8..24a3b9c 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -631,7 +631,8 @@ async def handle_job(ctx: JobContext) -> None: voice_enabled = mode == "voice" logger.info("Switching conversation mode to %s", mode) if not voice_enabled: - asyncio.create_task(session.interrupt()) + # interrupt() returns an already-scheduled Future, not a coroutine. + session.interrupt() session.input.set_audio_enabled(voice_enabled) session.output.set_audio_enabled(voice_enabled) return diff --git a/agent/task_worker.py b/agent/task_worker.py index 5ddde26..b43566b 100644 --- a/agent/task_worker.py +++ b/agent/task_worker.py @@ -102,11 +102,11 @@ class TaskWorker: if not text: reg.fail(self.task_id, "LLM returned empty response") return - if self._is_deferred_answer(text): + if self._is_deferred_answer(text) or self._is_underfilled_news_answer(text): reg.add_step( self.task_id, "thinking", - "Draft deferred the work; continuing automatically.", + "Draft was incomplete; continuing automatically.", ) if iteration == max_iterations - 1: reg.fail(self.task_id, "Could not produce a complete answer") @@ -117,8 +117,9 @@ class TaskWorker: { "role": "user", "content": ( - "Do not ask me a follow-up or offer to continue later. " - "Continue the task now, run any more targeted tool calls " + "The previous draft did not fully answer the original request. " + "Do not ask a follow-up or offer to continue later. Continue " + "the task now, run any more targeted tool calls " "you need, make reasonable assumptions, and only finish " "when you have directly answered the original request." ), @@ -153,6 +154,23 @@ class TaskWorker: ) return "?" in text or any(phrase in lowered for phrase in deferral_phrases) + def _is_underfilled_news_answer(self, text: str) -> bool: + """Reject generic news summaries that do not name actual events.""" + description = self.description.lower() + if "news" not in description and "headline" not in description: + return False + lowered = text.lower() + generic_phrases = ( + "reports also cover global developments", + "other sources indicate", + "various international perspectives", + "ongoing coverage of world events", + "recommend checking major", + "unable to retrieve", + "could not retrieve", + ) + return any(phrase in lowered for phrase in generic_phrases) + def _system_prompt(self) -> str: today = datetime.now(timezone.utc).strftime("%B %d, %Y") return ( @@ -162,8 +180,10 @@ class TaskWorker: "to finish later; make reasonable assumptions and continue autonomously. If a web " "search is broad or inconclusive, immediately run a more targeted query and scrape " "the most promising result pages, including today's date and specific reputable " - "sources when freshness matters. For headline " - "requests, return concrete named events rather than links to news homepages, and " + "sources when freshness matters. For plural news or headline requests, provide three " + "distinct concrete events unless the user requests a different count. For three " + "headlines, write exactly three sentences with one event per sentence and no extras. Never pad the " + "answer with generic phrases about broad coverage or international perspectives, and " "respect any requested item count exactly. Only " "produce a final answer once it fulfills the original request. The final answer must " "be 1-3 concise sentences suitable for speaking aloud, with plain text only and no " diff --git a/tests/test_background_tasks.py b/tests/test_background_tasks.py index 9f1f633..6b7c785 100644 --- a/tests/test_background_tasks.py +++ b/tests/test_background_tasks.py @@ -56,6 +56,14 @@ def test_deferred_task_answers_are_rejected(): "Reuters reports that the agreement was signed today, while the BBC says voting begins Monday." ) + news_worker = TaskWorker("test", "Find the latest international news headlines") + assert news_worker._is_underfilled_news_answer( + "One flood was reported. Other sources indicate ongoing coverage of world events." + ) + assert not news_worker._is_underfilled_news_answer( + "Nepal began flood recovery, Venezuela signed an oil agreement, and Iran resumed talks." + ) + def test_task_event_directories_are_isolated(tmp_path): first = TaskRegistry(tmp_path / "room-one") diff --git a/web/app.js b/web/app.js index e1ea166..20ac5e5 100644 --- a/web/app.js +++ b/web/app.js @@ -166,9 +166,16 @@ function updateModeUI() { async function setConversationMode(mode) { if (mode !== "voice" && mode !== "text") return; + const previousMode = conversationMode; conversationMode = mode; localStorage.setItem(MODE_KEY, mode); updateModeUI(); + if (mode === "voice" && previousMode === "text") { + chatInput.blur(); + window.setTimeout(() => { + document.querySelector(".workspace").scrollIntoView({ block: "start", behavior: "smooth" }); + }, 0); + } if (!room || !room.localParticipant) return; sendModeMessage(mode); @@ -314,9 +321,10 @@ function inferState() { userSpeaking = micLevel > 0.035; if (agentAudioLevel > 0.02) return "speaking"; if (userSpeaking) return "listening"; - // Not speaking, agent silent: stay in thinking until audio arrives, - // otherwise settle back to listening. - if (uiState === "thinking" || uiState === "speaking") return "thinking"; + // Once agent audio ends, return to listening. The previous implementation + // converted speaking → thinking forever, leaving the UI apparently frozen. + if (uiState === "speaking") return "listening"; + if (uiState === "thinking") return "thinking"; return "listening"; } diff --git a/web/style.css b/web/style.css index 56d6f16..c2d852a 100644 --- a/web/style.css +++ b/web/style.css @@ -511,6 +511,7 @@ button { .controls { position: relative; z-index: 2; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; @@ -656,6 +657,7 @@ button { .connection-strip { width: min(340px, 100%); + flex-shrink: 0; margin-top: 1rem; } @@ -685,6 +687,7 @@ button { .privacy-note { display: flex; + flex-shrink: 0; align-items: center; gap: 0.4rem; margin-top: 1.15rem; @@ -1223,6 +1226,39 @@ body[data-mode="text"] .mic-meter { .task-error { margin-top: 0.5rem; color: #ffc2ba; background: rgba(255, 128, 111, 0.1); } /* ── Responsive ─────────────────────────────────────────────────────────── */ +@media (max-height: 760px) and (min-width: 881px) { + .workspace { + min-height: 540px; + } + + .voice-console { + padding-top: 1.5rem; + padding-bottom: 1rem; + } + + .console-intro h2 { + font-size: 1.65rem; + } + + .console-intro p { + margin-top: 0.35rem; + } + + .orb-stage { + min-height: 220px; + margin: -1rem 0; + } + + .orb { + transform: scale(0.78); + } + + .connection-strip, + .privacy-note { + margin-top: 0.65rem; + } +} + @media (max-width: 880px) { body { padding: calc(0.85rem + env(safe-area-inset-top))