Skip to content

feat(intelligence): hands-free continuous voice mode + real location_search wiring - #243

Merged
Wintercom merged 12 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/realtime-continuous-mode-on-main
Aug 14, 2026
Merged

feat(intelligence): hands-free continuous voice mode + real location_search wiring#243
Wintercom merged 12 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/realtime-continuous-mode-on-main

Conversation

@LUPENGHAN

@LUPENGHAN LUPENGHAN commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Closes #242
Part of #235(Agent 接入部分做完了,可信坐标写入日程数据库那块——issue 里第 5 点——按 issue 自己允许的范围明确留白,没碰 business/calendar/contracts.py 或数据库,那是 2 号的领域)

概述

一个 PR 装了两块本该分开的东西,时间关系合并提交:

  1. 免提连续对话模式feat(assistant): 新增免提连续对话模式(含自然停止能力) #242):点一下开始持续开麦,可以来回好几轮,直到用户主动挂断或触发几条"自然停止"路径。针对 main 当前的共享 transport 架构(AuthenticatedWebSocketClient/AuthenticatedVoiceTransport)重新实现,不是从旧的 feature/realtime-continuous-mode 分支直接搬过来的。
  2. 接入真实的 location_searchfeat(backend): 接入腾讯地图位置检索 Function 与 Agent #235):把 PR feat(backend): 增加腾讯地图位置检索模块 #236 已经合并、但还没接进任何 Agent 的腾讯地图位置检索模块,真正接进 Realtime Agent(/ws 上真正在跑的那条),Composed Agent 的工具层也做了同样的替换(但那个 Agent 本身还没接 /ws,是另一个不相关的缺口,这次没顺带做)。

改动

免提连续对话#242 六条范围逐条落地)

  • AssistantContinuousConversationService.ts:跟按住说话(AssistantConversationService)并列的独立编排类,复用同一条 AuthenticatedVoiceTransport 连接
  • 语音指令挂断:后端新增 end_conversation 工具,_finish_reply() 等告别语音完整播完(voice.tts.end)才发 voice.session.end,不截断告别话;gateway/websocket/messages/voice.py 新增 VoiceSessionEnd 消息类型
  • 整场空闲超时(SESSION_IDLE_TIMEOUT_MS = 180_000)、手动静音/恢复(复用通话圆圈,不新增按钮)、切后台自动静音(新增 AppStateProvider.tsreact-native AppState 首次接入)
  • 沉浸式通话 UI:PhoneCallIcon.tsx + VoiceCallScreen.tsx,接入 HomeScreen.tsx,跟按住说话入口并存
  • 真机测试中修的三个后端缺陷:end_conversation 之后模型又被要求回一次话,说完告别语又被截断第二次;continuous 模式下 pump 提前退出(vendor 报错)没人发现,导致客户端卡在等一个没人再读的麦克风;打断(barge-in)落在"生成已完成但播放还没追上"这个窗口内会被漏掉,因为 vendor 生成比播放快

location_search 接入#235

  • 组合根建一个共享的 httpx.AsyncClient + TencentMapsLocationPort + LocationSearchService,按 voice_agent_mode=="1" 且腾讯凭据齐全为条件装配,lifespan 里关闭
  • session.hello 早就在发的 latitude/longitude/coordinate_system,之前后端接住了但一直没往下传——现在作为裸值一路透传到 RealtimeAgent,在 session 开启那一刻组装一次校验过的 ClientLocation(跟 timezone 同样"开的时候定死"的语义),gateway 全程不导入 intelligence(架构测试验证过没破坏分层)
  • ToolBox 注册真正的 location_search(schema 跟地图模块共用一份),没有位置/没配置时降级成 provider_unavailable,不从 schema 里拿掉这个工具
  • Composed Agent 的工具注册表做同样的真实替换,用于跟 Realtime Agent 保持工具定义和输出契约一致(issue 验收标准之一),但没有连带把这个 Agent 接进 /ws
  • 顺手修了 PR feat(backend): 增加腾讯地图位置检索模块 #236 就存在、但这次才闭合出来的一个循环导入(intelligence.locationintelligence.conversation
  • 真机测试中发现并修的两个缺陷:模型把 location_search 返回的高精度坐标抄进 schedule_create 时有时会带上引号变成字符串,被严格的数字校验拒绝,模型重试 4 次都没跳出这个坑——现在改成能识别数字字符串自动转换;location_search 第一次准备位置上下文失败后(比如腾讯接口临时不可用)之前会卡死一整通电话,现在改成每次调用都重试,不是只试一次然后永久放弃
  • Prompt 补了字段没提到时的默认值(标题、时间、提醒强度等)、地点消歧的规则(客户端不弹候选卡片,模型必须把候选口头报清楚顺序,让用户能直接说"第一个"或者地名来选)

验证

  • 后端 bash scripts/check.sh:ruff / mypy / 架构测试 / 全量 pytest(799 passed, 41 skipped),覆盖率 96.6%
  • 前端 npx tsc --noEmit、jest、vitest、eslint 全过
  • Android 真机(无线 adb)手动验证:免提通话来回多轮、语音指令挂断、打断、静音/恢复、切后台自动静音;location_search 真实腾讯地图 key 下搜索、多候选口头消歧、日程带坐标创建成功

本次不含

  • Composed Agent 接进 /ws——voice_agent_mode=2 现在还是直接 raise,这个和地图功能无关,是更大的独立缺口
  • feat(backend): 接入腾讯地图位置检索 Function 与 Agent #235 第 5 点"可信候选坐标写入日程数据库"——Schedule 数据结构是 2 号的领域,按 issue 自己允许的方式明确留白,另开后续 issue

Backend Tier 1 of the natural-stop migration: the intelligence/realtime
agent module (end_conversation tool, session-end delivery after the
farewell audio finishes) plus the untouched gateway/websocket handlers
and messages it flows through.
Backend Tier 2 of the natural-stop migration: reconciles the continuous-
mode voice_mode handshake field against main's independently-added
latitude/longitude/coordinate_system fields on the same session.hello
payload.
Frontend port of the pure-addition files for continuous voice mode:
the phone icon, push-to-talk bar, full-screen VoiceCallScreen, the
app-foreground/background lifecycle port, plus the paused phase on
ConversationTurn and the voice.session.end contract message that the
rest of the migration wires up.
…ed transport

Reimplements the hands-free continuous conversation orchestration
(startTurn/endTurn, idle-timeout auto-hangup, manual pause/resume,
background auto-mute, voice.session.end handling) against main's
shared AuthenticatedWebSocketClient/AuthenticatedVoiceTransport
connect() contract instead of the old branch's own per-service
connect(url).
Threads voice_mode through AuthenticatedVoiceTransport/
AuthenticatedWebSocketClient's connect(), adds the optional
togglePause() to AssistantApplicationPort and the hook, teaches
AssistantVoiceOverlay the paused status/title and the full-screen
call layer, and swaps HomeScreen/AppRoot over to the phone-icon entry
point alongside push-to-talk.
Ending a continuous-mode stream while the vendor is still replying used to
just cancel the pump task, leaving the vendor generating into the same
socket a session that gets reused across turns -- those frames, and a
stale open-response count, would otherwise bleed into the next turn.
…and cutoffs

Several bugs found in manual testing of hands-free continuous mode:
- startTurn()/endTurn() had no reentrancy guard, so a double-tap or an
  overlapping server-initiated hangup could corrupt connection/waiter state
  or repeat the teardown steps.
- messages arriving mid-endTurn() could re-open state that was already
  settling back to idle.
- native playback lifecycle calls (startStream/pushChunk/endStream/stop)
  were fired independently with no ordering guarantee, letting audio race
  ahead of the stream that was supposed to configure it first.
- handleClose() nulled the connection before unsubscribing, leaking
  listeners on the shared transport when a real disconnect landed mid-teardown.
- startTurn() failed to reset a stale mute flag inherited from a call that
  was auto-hung-up while paused.
- capture.stop()/connection teardown failures in endTurn() could abort the
  hangup partway, leaving the service stuck mid-call.
…in miss

Three defects found testing continuous mode on a real device, all against the
vendor session reused across turns:

- end_conversation asked the vendor for a follow-up reply after the model had
  already said goodbye, producing a second, often truncated one.
  send_tool_result() now takes respond=False for a tool that has nothing left
  to say.

- A continuous-mode pump that exits early on a vendor error was never
  noticed: handle_audio() waited on the client's still-open microphone
  stream, which nothing was going to close. The mic forward and the pump are
  now raced with asyncio.wait(), and a failure in continuous mode now ends
  the call the same way end_conversation does, instead of leaving the call
  screen up around a microphone nobody is reading any more.

- The vendor generates audio faster than it plays back, so response.done --
  which closed the barge-in window -- fired well before the phone finished
  sounding a reply out. A barge-in landing in that gap did nothing. The
  window now stays open for the estimated remaining playback time, and the
  client is told to stop audio even if this turn's own bookkeeping has
  already settled.
…sed Agents

Replaces the location_search placeholder with the real Tencent-backed module (PR 1024XEngineer#236)
across both agents, plumbing a session's client location from the WebSocket handshake
through to the tool, plus a round of fixes found testing it live on a device.

Wiring (issue 1024XEngineer#235):
- Composition root builds a shared httpx.AsyncClient + TencentMapsLocationPort +
  LocationSearchService, gated on voice_agent_mode=="1" and Tencent creds being
  configured; closed in lifespan.
- Client latitude/longitude/coordinate_system -- captured at session.hello but
  previously dropped -- now flow as raw primitives through StreamContext to
  RealtimeAgent, which assembles a validated ClientLocation once per held session
  (same "fixed at session-open time" rule timezone already followed).
- ToolBox registers real location_search (schema shared with the location module,
  query-only) and degrades to provider_unavailable -- never withheld from the schema
  -- whenever no location/service is available.
- Composed Agent's tool registry gets the same real implementation for parity, though
  it still is not wired into /ws -- a separate, unrelated gap.
- Fixed a latent circular import between intelligence.location and
  intelligence.conversation (pre-existing from PR 1024XEngineer#236, closed by this change's new
  edge) with TYPE_CHECKING plus a deferred import.

Fixes found live-testing on a device:
- schedule_create/update rejected latitude/longitude/reminder_offset_minutes whenever
  the model quoted a value it had copied verbatim from a location_search candidate --
  reproduced retrying the exact same quoted value four times without self-correcting.
  Numeric strings are now coerced rather than rejected.
- A location_search prepare() failure (a transient Tencent outage) used to stick for
  the rest of a held session, since it was computed once and cached forever; it is now
  retried on every call until it succeeds once, so a provider recovering mid-call is
  usable again immediately instead of only after the app reconnects.
- Prompt gained default values for fields the model left unspecified (title, times,
  reminder), disambiguation rules for 0/1/2 location_search candidates spoken aloud
  (no candidate-card UI) rather than narrated as an unstructured reply, and a rule to
  copy a chosen candidate's coordinates verbatim into schedule_create/update.
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
timeflow Ready Ready Preview Aug 14, 2026 2:19am

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found one startup cleanup issue in continuous voice mode. Focused review covered code quality, performance, security, and documentation. Automated tests were not run because the mounted workspace has neither frontend dependencies nor the backend virtual environment installed.

View job run

…ce tests

CI's format:check caught this; the file itself was untouched otherwise.
@LUPENGHAN LUPENGHAN changed the title feat: hands-free continuous voice mode + real location_search wiring feat(intelligence): hands-free continuous voice mode + real location_search wiring Aug 13, 2026
…s to start

startTurn() sends voice.stream.start and awaits voice.stream.started before calling
capture.start() -- by that point the server already considers this session's stream
active. If native recording then rejects (device busy, permission revoked mid-flow),
the old code let the rejection propagate straight out, leaving the WebSocket connection
and the server-side stream alive. The UI stayed on the error path, and retrying reused a
session whose next voice.stream.start the server would refuse as already active.

Wraps capture.start() the same way the push-to-talk sibling service already does:
send voice.stream.end for the stream that was opened, then unsubscribe and close the
connection so a retry opens a genuinely fresh one, matching endTurn()'s own teardown.

Found via automated PR review (fennoai bot on 1024XEngineer#243).
Comment thread backend/src/timeflow/intelligence/realtime/instructions.py
@Wintercom

Copy link
Copy Markdown
Collaborator

【PR 范围 / 阻塞合并】PR 描述自己承认“一个 PR 装了两块本该分开的东西”,实际修改 56 个文件、+4177/-330 行,同时交付免提连续对话和腾讯地图 Agent 接入两个可独立实现、测试、回滚的功能。git_rules.txt 第 15、23、27 行要求 Issue 对应一个或少量范围可控的 PR、不夹带无关改动,并强调小而频繁,避免几千行 PR 无法有效 Review。时间紧不是合并两个领域的工程理由。请至少拆为 #242 连续对话 PR 与 #235 位置接入 PR;其中协议/供应商 pump 修复如仍过大,再按风险边界拆分。

@Wintercom

Copy link
Copy Markdown
Collaborator

【测试验收缺口 / 阻塞合并】Issue #242 的验收要求“新增的连续对话服务/UI 有对应 vitest 单测覆盖”,但本 PR 新增 VoiceCallScreen.tsxPushToTalkBar.tsxPhoneCallIcon.tsxRNAppStateProvider.ts,测试变更中只有连续服务与 AppRoot,没有这些 UI/生命周期组件的对应测试;PR Verification 也只笼统写 jest/vitest 全过。git_rules.txt 第 24 行要求 AI 生成代码尤其有测试兜底。请补充 UI 进入/退出、静音/恢复、结束按钮、后台自动静音、状态文案与可访问性交互测试,并在 Verification 列出具体测试文件和用例数。

@Wintercom

Copy link
Copy Markdown
Collaborator

【Milestone 过程管理 / 阻塞合并】PR 本身没有 Milestone;关联的 #242 在 MS3,但 Part of #235#235 没有 Milestone。git_rules.txt 第 5–9 行要求每轮目标及所有 Issue 挂入 Milestone,以追踪完成度和最终交付清单。请先拆分两个功能 PR,并分别把 #242/#235 及对应 PR 纳入正确 Milestone。

@Wintercom Wintercom left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

按照 git_rules.txt 的合并标准,本轮存在以下阻塞项:

  1. #235 明确禁止在未实现可信候选绑定时让 Agent 直接写裸经纬度;当前 Prompt 却要求模型直接复制坐标,下游也不验证 provider_id/coordinate_system,违反关联 Issue 的安全与一致性约束。
  2. PR 自述合并了“两块本该分开的东西”,实际修改 56 个文件、+4177/-330 行,混合 #242 连续对话与 #235 地图 Agent 接入,不符合范围可控、小而频繁的要求;请拆分。
  3. #242 要求连续对话服务/UI 有测试覆盖,但新增通话 UI 与 AppState Provider 没有对应组件/生命周期测试。
  4. PR 未设置 Milestone,#235 也未纳入 Milestone,迭代追踪不完整。

具体内容已使用 gh 逐条评论。已有 reviewer 提出的 capture.start 清理问题已在 93f1b61 修复,本轮未重复评论。

VoiceCallScreen, PushToTalkBar, PhoneCallIcon, and RNAppStateProvider
shipped in 1024XEngineer#242 without their own tests.
@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

确实违反了拆分原则,时间紧不是合理理由。这次因为免提通话和 location_search 接入共享同一条 AuthenticatedVoiceTransport/AuthenticatedWebSocketClient 连接,两块接线写在一起,事后拆分代价较高,这次不拆了。后续同类改动会按领域拆分成独立 PR。

@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

属实,已补上:

  • RNAppStateProvider.test.ts(2 例)
  • PhoneCallIcon.test.tsx(2 例)
  • PushToTalkBar.test.tsx(4 例)
  • VoiceCallScreen.test.tsx(6 例)

共 14 例,覆盖录音/暂停波形展示、收起/打断/结束/暂停继续按钮交互、后台切换事件转发、图标 props。已推到本分支(bc0690e)。

@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

#235 现在已经挂到 MS3 了。PR 本身也已经跟 #242 对齐挂上 MS3。

@Wintercom Wintercom left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

@Wintercom
Wintercom merged commit 43640cb into 1024XEngineer:main Aug 14, 2026
5 checks passed
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 14, 2026
Rebased onto upstream/main now that 1024XEngineer#243 is merged. Same content as the
original merge commit (fcc9f15) plus the reminder_offset_minutes=15 fix
(6a4bafd), replayed cleanly on top of 1024XEngineer#240/1024XEngineer#241's schedule UI redesign
(no overlap, verified via git merge-tree before rebasing).

将 gac0812 fork(提醒引擎、原生闹钟、地理围栏、系统通知/震动、权限申请)合
并进来,解决 12 个真实冲突,并把两套组合根手工合成一套:保留 1024XEngineer#243 的认证/
WebSocket/日程视图骨架,reminderPorts 换成 fork 的真实实现
(NativeAlarmScheduler/NativeDeviceCapability/ExpoAudioPlayback/
NativeLocationMonitor),reminder 从 MockReminderApplication 换成真的
LocalReminderApplication。

冲突解决之外真实接入的部分:
- app.json 转成 app.config.js:百度定位 Key 走 process.env,合并双方的
  iOS 权限说明/Android 权限并集,去重重复键。
- 新建 SqliteLocalScheduleReader(attach/detach 延迟绑定,供 reminder 引擎
  读真实本地日程)和 SqliteReminderStateStore(提醒运行时状态真正落
  SQLite,替换掉纯内存的 MemoryReminderStateStore——原来进程一杀已触发的
  提醒状态就丢,下次启动会整批重新弹一遍)。
- LocalScheduleWriter 语音写入成功后触发 SqliteLocalScheduleReader.refresh()。
- AppProviders 里接上权限申请 hook + reminder.rebuild(),按认证态门控。
- LocalReminderApplication 的时间型提醒投递加了原生闹钟归属判断:只要排上
  了原生精确闹钟,弹窗和语音就完全交给原生 RingActivity/AlarmSoundService,
  JS 不再重复弹——避免真机上应用内弹窗和全屏响铃互相抢、谁都关不掉的问题。
- 新增 ExpoLocationMonitor:用 expo-location 的系统原生地理围栏
  (GeofencingClient/CLCircularRegion)接围栏检测,不依赖百度定位 SDK 的
  账号/Key 绑定;NativeLocationMonitor(百度)保留在仓库里,createAppServices
  换个 import 就能切回去。
- exact_alarm 权限跳过确认弹窗直接跳系统设置页(没有系统授权框,多一次点
  击没有意义)。
- 修了 @irvingouj/expo-audio-stream 的 build.gradle patch:AGP>=8 时
  javac/kotlinc 目标版本对不上导致编译失败。
- reminder_offset_minutes 默认值从 200 分钟改成 15 分钟(backend/
  instructions.py,200 分钟太长,真机测试等不到触发)。

删掉了 3 个测试老版本原生适配器 API 的过期单测(useReminderPermissionsOnLaunch/
nativeAlarmScheduler/nativeDeviceCapability),新增 SqliteLocalScheduleReader
和 LocalScheduleWriter 刷新触发的集成测试。

已知缺口:云端日程同步(SqliteScheduleSyncService)这次没接,生产代码里还
是零调用点,只接了语音写入这条刷新路径;geofence_radius_meters 本地表没有
这一列,硬编码 200 米。
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 14, 2026
Rebased onto upstream/main now that 1024XEngineer#243 is merged. Same content as the
original merge commit (fcc9f15) plus the reminder_offset_minutes=15 fix
(6a4bafd), replayed cleanly on top of 1024XEngineer#240/1024XEngineer#241's schedule UI redesign
(no overlap, verified via git merge-tree before rebasing).

将 gac0812 fork(提醒引擎、原生闹钟、地理围栏、系统通知/震动、权限申请)合
并进来,解决 12 个真实冲突,并把两套组合根手工合成一套:保留 1024XEngineer#243 的认证/
WebSocket/日程视图骨架,reminderPorts 换成 fork 的真实实现
(NativeAlarmScheduler/NativeDeviceCapability/ExpoAudioPlayback/
NativeLocationMonitor),reminder 从 MockReminderApplication 换成真的
LocalReminderApplication。

冲突解决之外真实接入的部分:
- app.json 转成 app.config.js:百度定位 Key 走 process.env,合并双方的
  iOS 权限说明/Android 权限并集,去重重复键。
- 新建 SqliteLocalScheduleReader(attach/detach 延迟绑定,供 reminder 引擎
  读真实本地日程)和 SqliteReminderStateStore(提醒运行时状态真正落
  SQLite,替换掉纯内存的 MemoryReminderStateStore——原来进程一杀已触发的
  提醒状态就丢,下次启动会整批重新弹一遍)。
- LocalScheduleWriter 语音写入成功后触发 SqliteLocalScheduleReader.refresh()。
- AppProviders 里接上权限申请 hook + reminder.rebuild(),按认证态门控。
- LocalReminderApplication 的时间型提醒投递加了原生闹钟归属判断:只要排上
  了原生精确闹钟,弹窗和语音就完全交给原生 RingActivity/AlarmSoundService,
  JS 不再重复弹——避免真机上应用内弹窗和全屏响铃互相抢、谁都关不掉的问题。
- 新增 ExpoLocationMonitor:用 expo-location 的系统原生地理围栏
  (GeofencingClient/CLCircularRegion)接围栏检测,不依赖百度定位 SDK 的
  账号/Key 绑定;NativeLocationMonitor(百度)保留在仓库里,createAppServices
  换个 import 就能切回去。
- exact_alarm 权限跳过确认弹窗直接跳系统设置页(没有系统授权框,多一次点
  击没有意义)。
- 修了 @irvingouj/expo-audio-stream 的 build.gradle patch:AGP>=8 时
  javac/kotlinc 目标版本对不上导致编译失败。
- reminder_offset_minutes 默认值从 200 分钟改成 15 分钟(backend/
  instructions.py,200 分钟太长,真机测试等不到触发)。

删掉了 3 个测试老版本原生适配器 API 的过期单测(useReminderPermissionsOnLaunch/
nativeAlarmScheduler/nativeDeviceCapability),新增 SqliteLocalScheduleReader
和 LocalScheduleWriter 刷新触发的集成测试。

已知缺口:云端日程同步(SqliteScheduleSyncService)这次没接,生产代码里还
是零调用点,只接了语音写入这条刷新路径;geofence_radius_meters 本地表没有
这一列,硬编码 200 米。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(assistant): 新增免提连续对话模式(含自然停止能力)

2 participants