feat: add seamless text and voice chat
Rebuild & Deploy / rebuild (push) Successful in 49s

This commit is contained in:
snowspeeder committed 2026-08-29 07:49:29 -04:00
1 parent 321421e37f
commit 120ab192f1
7 files changed
+558 -79

No files matched your search

+29 -16
View File
@@ -80,7 +80,8 @@ SYSTEM_PROMPT = textwrap.dedent("""\
no markdown, and no formatting except the Azure delivery markers below.
- Spell out numbers when it sounds more natural ("twenty twenty-six"
instead of "2026").
- If you need to ask a question, ask exactly one.
- Make reasonable assumptions and answer directly. Ask a follow-up only
when it is genuinely impossible to proceed without one.
- Be warm and direct. Don't be sycophantic or overly formal.
- If you don't know something, say so briefly and move on.
@@ -144,7 +145,7 @@ SYSTEM_PROMPT = textwrap.dedent("""\
- User says "look up the price of a PS5" → CALL dispatch_task with
description="Find the current retail price of a PlayStation 5"
After calling dispatch_task, tell the user "I'll get on that for you"
and keep the conversation going. The result comes back automatically — when
without asking for more permission or details. The result comes back automatically — when
you receive it, share the findings naturally in your conversational style.
You can have multiple tasks running at once.
@@ -612,29 +613,41 @@ async def handle_job(ctx: JobContext) -> None:
),
)
# Listen for data messages (voice switching) from the web UI.
# Listen for conversation controls (voice selection and voice/text mode).
# Defined here so it captures this job's session via closure.
def _on_room_data(packet) -> None:
topic = getattr(packet, "topic", None)
if topic and topic != "voice-control":
if topic and topic not in ("voice-control", "conversation-control"):
return
try:
msg = json.loads(packet.data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return
if msg.get("type") != "set_voice":
if msg.get("type") == "set_mode":
mode = msg.get("mode")
if mode not in ("voice", "text"):
return
voice_enabled = mode == "voice"
logger.info("Switching conversation mode to %s", mode)
if not voice_enabled:
asyncio.create_task(session.interrupt())
session.input.set_audio_enabled(voice_enabled)
session.output.set_audio_enabled(voice_enabled)
return
voice = msg.get("voice", "")
if not voice:
return
logger.info("Switching TTS voice to %s", voice)
try:
tts = session.tts
if hasattr(tts, "update_options"):
tts.update_options(voice=voice)
logger.info("Voice updated to %s", voice)
except Exception as e: # noqa: BLE001
logger.warning("Failed to update voice: %s", e)
if msg.get("type") == "set_voice":
voice = msg.get("voice", "")
if not voice:
return
logger.info("Switching TTS voice to %s", voice)
try:
active_tts = session.tts
if hasattr(active_tts, "update_options"):
active_tts.update_options(voice=voice)
logger.info("Voice updated to %s", voice)
except Exception as e: # noqa: BLE001
logger.warning("Failed to update voice: %s", e)
ctx.room.on("data_received", _on_room_data)
+106 -12
View File
@@ -65,8 +65,8 @@ class TaskWorker:
model=model,
messages=messages,
tools=tools or None,
max_tokens=500,
temperature=0.7,
max_tokens=700,
temperature=0.3,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
except Exception as e:
@@ -102,18 +102,72 @@ class TaskWorker:
if not text:
reg.fail(self.task_id, "LLM returned empty response")
return
if self._is_deferred_answer(text):
reg.add_step(
self.task_id,
"thinking",
"Draft deferred the work; continuing automatically.",
)
if iteration == max_iterations - 1:
reg.fail(self.task_id, "Could not produce a complete answer")
return
messages.extend(
[
{"role": "assistant", "content": text},
{
"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 "
"you need, make reasonable assumptions, and only finish "
"when you have directly answered the original request."
),
},
]
)
continue
reg.add_step(self.task_id, "text", text)
reg.complete(self.task_id, text)
return
reg.fail(self.task_id, f"Reached max iterations ({max_iterations})")
@staticmethod
def _is_deferred_answer(text: str) -> bool:
"""Reject drafts that ask permission or defer the requested work."""
lowered = text.lower()
deferral_phrases = (
"would you like me",
"do you want me",
"should i ",
"shall i ",
"if you'd like",
"if you would like",
"let me know if",
"i need to perform",
"i need more information",
"i can refine",
"i can search",
"i can provide",
"need a more targeted search",
)
return "?" in text or any(phrase in lowered for phrase in deferral_phrases)
def _system_prompt(self) -> str:
today = datetime.now(timezone.utc).strftime("%B %d, %Y")
return (
"You are Hope's research assistant working on a background task. "
"Use your tools to gather information thoroughly. When you have "
"enough, provide a final answer in 1-3 sentences suitable for "
"speaking aloud. Plain text only, no markdown or formatting."
"You are Hope's autonomous research assistant working on a background task. "
f"Today is {today}. Use your tools until you have directly completed the request. "
"Never ask the user a follow-up question, ask permission to search again, or offer "
"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 "
"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 "
"markdown or formatting."
)
def _load_tools(self) -> list[dict]:
@@ -157,7 +211,7 @@ class TaskWorker:
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web for current information using Firecrawl.",
"description": "Search the web for current information. Returns titles, descriptions, and URLs.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
@@ -165,6 +219,18 @@ class TaskWorker:
},
},
},
{
"type": "function",
"function": {
"name": "web_scrape",
"description": "Read the full content of a promising URL returned by web_search.",
"parameters": {
"type": "object",
"properties": {"url": {"type": "string"}},
"required": ["url"],
},
},
},
]
async def _execute_tool(self, name: str, args: dict) -> str:
@@ -227,18 +293,46 @@ class TaskWorker:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{firecrawl_base}/v1/search",
json={"query": query, "limit": 3},
json={"query": query, "limit": 5},
)
if resp.status_code == 200:
data = resp.json()
items = data.get("data", [])
lines = [
f"{r.get('title', '')}: {r.get('description', '')}"
for r in items[:3]
]
lines = []
for item in items[:5]:
title = (item.get("title") or "Untitled").strip()
description = (item.get("description") or "").strip()
url = (item.get("url") or "").strip()
lines.append(f"{title}: {description}")
if url:
lines.append(f"URL: {url}")
return "\n".join(lines) or "No results found."
return f"Search failed (HTTP {resp.status_code})."
elif name == "web_scrape":
url = args.get("url", "")
if not url:
return "A URL is required."
firecrawl_base = os.environ.get("FIRECRAWL_BASE", "")
if not firecrawl_base:
return "Web scraping is not configured."
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(
f"{firecrawl_base}/v1/scrape",
json={"url": url, "formats": ["markdown"], "timeout": 60000},
)
if resp.status_code != 200:
return f"Scrape failed (HTTP {resp.status_code})."
data = resp.json()
if not data.get("success"):
return f"Scrape failed: {data.get('error', 'unknown error')}"
result = data.get("data", {})
markdown = (result.get("markdown") or "").strip()
if not markdown:
return "No page content was extracted."
title = (result.get("metadata", {}).get("title") or "").strip()
return f"Page: {title}\n\n{markdown[:8000]}"
else:
return f"Unknown tool: {name}"