Repository navigation
feat(ag2space-voice): the voice agent as an AG2 Space MCP client — read, search, documents and writes in a room by voice - #5458
Conversation
…s through the AG2 Space MCP The voice agent starts the ag2-space stdio MCP server the core registered (CLAUDE_CONFIG_DIR/.claude.json) and calls room.list + room.action.read (room.context.read) inline. Declared only when that entry exists. Token is minted at startup; warm reads ~0.6 s including the name lookup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rom an earlier answer Live on xueqing 10-13: after one not-a-member answer, the model repeated it for three later questions (and into a new session) without calling read_room, though the room had become readable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sonichi
left a comment
There was a problem hiding this comment.
Reviewed the diff (skills/ag2space-voice/room-read.ts, tools.ts, tests/ag2space-voice-room-read.test.ts) in full.
Scoping/safety check (the thing I actually went looking for): this reuses the core's already-registered ag2-space MCP stdio entry verbatim — same command/args/env, so no new credential path is created, and an install without that entry (findAg2SpaceMcpEntry → null) gets the tool not declared at all, confirmed by its own test. The room-read calls (room.list, room.action.read) go through the same server-side authorization the core already has for that identity, so this doesn't widen what's readable, only who can trigger a read (the voice model, inline).
Correctness spot-checks:
pickRoom: exact-name match (case/whitespace-normalized) wins over fuzzy matches; 2+ exact matches narrow the ambiguous-candidates list to just the exact duplicates rather than every fuzzy hit; single fuzzy match is accepted. Tests cover all four branches.- Timeout math checks out:
ROOM_READ_CALL_TIMEOUT_MS=6000, tool's declaredtimeout = 6000*2+1000 = 13000— correct for the worst case of a cold room.list + room.action.read both near their individual timeout. - Every failure path (
not_connected,timeout,unavailable, a refused read's own error code) returns a result the model can speak, never throws — confirmed by the "never throws" test block. - The prompt rule explicitly tells the model to re-call every time rather than reuse an earlier answer or task result — matches the PR's stated intent for the second (untested-live) commit.
One nit, not blocking: the spawned MCP child's stderr is 'ignore' (stdio: ['pipe','pipe','ignore']) — a crash reason from the server itself never reaches the voice-agent log, only the generic exited (code)/failed to start message. Minor, but worth a log(data.toString()) on stderr if this gets debugged often in practice.
Architecturally this is the right call per the inline-tool rule (needs sub-second response, replacing the slower work() queue path) — correctly placed in skills/ag2space-voice/, not src/.
Holding formal approval until CI finishes (several jobs still pending as I write this, including the one that runs the new test file) — will convert to APPROVE once it's green. Live evidence (Susan's warm ~0.6s measurement, the real voice call at 974ms) covers the live-path witness requirement for the first commit; the PR body already flags the second commit as not yet live-tested, which is honest and fine to land with a known gap noted rather than blocking on it.
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
sonichi
left a comment
There was a problem hiding this comment.
CI is green now (all jobs settled). Converting my earlier comment to a formal approve — review details are in that comment above.
…re by the turn check The relay agent's turn check only stood down when the voice model called work. A room question the voice model answered with read_room was sent to the core as a task too (2026-10-13 17:08, task-1791911315512: the read was still in flight). A skill's voiceSurface() now names its answeringTools: tools that fetch the answer themselves. When the voice model calls one, the relay agent treats the turn as taken, exactly as for a work call. A read that fails tells the voice model, which can still call work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d to a real one
A spoken room name is often misheard ("Sutando Triage" came through as
"Sutando Triaging" and "Sultano triage", 2026-10-13 17:41). read_room looked
the heard name up by substring and answered not_found, a dead end: the voice
model gave up, then tried navigate_ui, and the turn check sent a task.
The plugin now keeps an index of the rooms the agent is a member of
(room.list, scope self, paged), fetched at start-up (it is the warm-up: 8 s
cold measured) and refreshed in the background after five minutes or on a
miss. Its names go into every prompt as a ROOMS line, so the model passes a
name spelled exactly as listed. A read_room miss returns the names too, and
its description says to read again with the one meant instead of declaring
the room absent. list_rooms fetches the list on demand.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…wever far back read_room reads the latest 15 messages (40 at most). The #5473 review the user asked about (2026-10-13 20:19) was the 37th-42nd newest message in Sutando Triage: out of reach, and the voice model answered from memory, mixing it up with #5479, then offered to check instead of checking. search_room runs the room's own full-text search (room.events.search): the words, the room, up to 50 hits newest first, each with its event_id. read_room takes `around` = that id to read the discussion around a hit. The descriptions and the prompt rule split the two: "what's new" reads, "what was said about <PR/topic/person>" searches, and neither question is ever answered from memory. Both tools answer the turn, so the turn check sends nothing. Live on 2026-10-13: "5473" in Sutando Triage → 2 hits in 173 ms; reading around the newest hit → 6 messages in 254 ms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he voice model's own; stamps carry the zone
2026-10-13 20:54: "Sutando Triage 里其他的 conversation 是啥" was sent to the
core by the turn check, which only knew work and chit-chat, not that the voice
model reads and searches rooms itself. A skill's voiceSurface() can now give
the turn check rule lines (turnCheckRules); ag2space-voice says a question
about a room's messages is none, never work, even when it names a PR.
The voice prompt rule also says what a message means is answered from that
message (read_room around it), and work is submitted only when the room does
not hold the answer. Message and hit stamps carry the time zone ("Oct 13,
09:46 PDT"): the model called a Pacific stamp Eastern (20:50).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ts, pages and tables by voice The AG2 Space MCP offers room.commons.search (Doc pages, HTML pages, database and sheet rows: title, where, snippet, link, and a page's kind) and room.commons.pages.read (one page's text from an offset). search_commons and read_page expose them to the voice model, inline, with the same room-name handling as read_room. A page read hands the model at most 8,000 characters and says when there is more. Both calls get 15 s: a page read took 9 s once. Some rooms answer INTERNAL to a Commons search (2026-10-13); the error code is passed on and the description tells the model to say the documents cannot be searched now. Live on 2026-10-13: "launch video design" in open.space launch video → 1 doc hit in 0.5 s; reading it → 8,000 chars (more) in 0.66 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s, after the user's yes A new Doc page (title + text) or text appended to a page (its kind, or "markdown" for the main Doc), through room.action.execute with a fresh operation_id. Nothing is written without text, without a title for a new page, over 4,000 characters, or to a misheard room; an approval-gated room, a refusal or a failed status come back as errors and nothing is retried. Speech is misheard and a write is seen by every member, so the description and the prompt rule make the condition explicit: say back the exact title, text and room, and call only after a clear yes. The turn check is told a note to write is the assistant's own, so the core is not asked to write it too. Not yet run against a live room: the execute result's shape (status, result kind) is taken from the MCP's own description. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ppended to it 2026-10-13 21:16, live on xueqing: "make a comment on the doc: Susan has reviewed" was written with write_page, so it became the page's last line and the user found no comment. 21:18: the model said it would add the second one and never called a tool. add_comment leaves a comment through room.commons.comments.add, on the whole page or anchored to a passage of a Doc page with quote, and returns the thread's root event_id. write_page's description sends comments there. Both write tools now say: once the user has said yes, call in that same turn — saying so without the call writes nothing. The turn check is told a note or comment to write is the assistant's own even while its text is still missing, so it no longer asks the user for it a second time (21:17:48). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ice; add_comment replies in a thread send_message posts through room.message.send as the agent: a plain message, a reply to a message (its event_id from read_room or search_room), a post in a thread, and mentions — full user ids, only when the user asked to @ someone, since mentioned agents act on being mentioned. add_comment takes reply_to to answer in a comment thread (room.commons.comments.reply). Both follow the write rule: say the text, room and recipient back, call only after a clear yes, in that same turn. The turn check is told a message to post in a room is the assistant's own; what remains work is an action outside the room. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sonichi
left a comment
There was a problem hiding this comment.
Requesting changes at 0c8ecea — not on code quality (the write-tool safety design is sound), but on three objective blockers.
1. Real merge conflict, not cosmetic. gh pr view reports mergeable: CONFLICTING, mergeStateStatus: DIRTY. I merged origin/main into this head locally to see what's colliding: src/relay-agent.ts conflicts in actOnTurn/invoke, and it's a genuine feature collision, not whitespace — this PR renames/repurposes lastWorkCallAt into lastTakenAt (for the new noteAnsweringTool signal), while origin/main independently added lastWorkCallAt-based origin capture (captureOrigin, threading origin through actOnTurn/submit) on the same method. Resolving this needs both signals to coexist (the answering-tools short-circuit AND the origin-capture-before-waits ordering), which is an author decision, not something a reviewer should resolve by picking a side. CI barely ran (only CLA + recheck) — likely gated on this.
2. The GitHub APPROVED is stale against this head. Chi's review is APPROVED at 1875e073, which is 9 commits behind the current 0c8ecea9 (10 commits total on the PR, confirmed via the commits API) — the version he saw was read_room only, per Susan's bot's own message here. The scope since then: room index with a 5-min refresh, search_room/around, search_commons/read_page, and three new WRITE tools (write_page/add_comment/send_message) through room.action.execute. That's a different PR in substance from what carries the owner's approval. This needs a fresh owner look at the write-tool scope specifically, not just a re-stamp.
3. The live write path is admittedly untested live. add_comment and send_message write to a room via room.action.execute and haven't been exercised live yet (only read/search/commons/write_page have, per the message above). Per this repo's own rule, a live external-write path needs a real post-restart round trip, not harness-only proof — doubly so here since these three tools' only safety gate is prompt text (repeat-back, explicit yes, same-turn call); there's no code-level confirmation check, so a live round trip is also the only way to see the actual LLM honor that contract under real conditions, not just pass a scripted unit test asserting the tool description contains the right sentence.
What I did verify, for the record: the three write tools' descriptions and ROOM_READ_PROMPT_RULE text do encode the repeat-back/yes/same-turn rule consistently across write_page, add_comment, and send_message, and the tests pin that text. I did not run the stated 42/91/9/35 test counts myself (TS toolchain setup — didn't want to delay flagging the conflict); if someone re-confirms those post-rebase, that's real additional evidence, but the conflict blocks any of it from being CI-verified at this head regardless.
Suggest: rebase onto main, resolve the relay-agent.ts collision explicitly (not just taking the auto-merge's pick), re-run full CI, get a live round trip on at least one of add_comment/send_message, and ask Chi to re-approve the write-tool scope specifically before merging.
… kept src/relay-agent.ts collided in drainTurns and invoke: this branch's lastTakenAt (a turn taken by work or by an answering tool is not sent by the turn check) and main's origin capture (#5035: where a request was asked from, taken before any wait). Both stay: invoke marks the turn taken and captures the origin; the turn check passes the turn's origin to actOnTurn. tsc clean; relay-agent 91, room-read 42, plugin-seam 9, navigate 35 pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@cla-assistant check |
sonichi
left a comment
There was a problem hiding this comment.
Converting my CHANGES_REQUESTED to APPROVE at c0272ff. All three points addressed:
- Merge conflict — verified myself (see my prior review): resolved, both signals (
lastTakenAt, origin capture) coexist.mergeable: MERGEABLEconfirmed again now, 34 checks pass. - Stale owner approval — not something I can close on Chi's behalf; this is a note for him, not a review blocker. The scope since his
1875e073approval (room index, search, commons, the three write tools) is now fully described and evidenced in the PR body. - Live write round trip — the PR body now has it:
add_commentandsend_messageeach run live on xueqing at 14:33/14:34 PT, with the actual confirm dialogue (repeat-back → explicit "确定" → same-turn call) and an MCP read-back confirming the written content landed correctly (the comment text, the reply text). I did not run this myself — I'm taking the pasted log as reported, same as any other evidence — but it's specific, timestamped, and consistent with the design rather than a generic claim, which is what I'd expect from a real run.
Nothing else outstanding from me. Chi's own look at the write-tool scope is still worth getting before merge, per point 2, but that's his call to make, not a gate I'm holding.
What and why
Voice could only learn what was said in an AG2 Space room by submitting work: the core read the room and the answer came back tens of seconds later. This PR makes the voice agent an AG2 Space MCP client and gives the voice model inline tools for a room — read, search, documents, and, after the user's yes, writes — each answered in about a second.
Gemini Live has no MCP support of its own, so the voice agent calls MCP for it.
skills/ag2space-voice/room-read.tsfinds the core's registeredag2-spacestdio MCP entry in$CLAUDE_CONFIG_DIR/.claude.json(or~/.claude.json) — the entry the desktop app writes and thatskills/ag2-space-mcp/register.py(#5036) writes — and starts the same credential proxy. The voice agent reads and writes as the same agent identity, holds no secret, and goes through the same server-side authorization the core has. An install without the entry is unchanged: none of this is declared.Tools (all inline, declared through
voiceSurface())read_roomroom.action.read→room.context.readarounda search hitsearch_roomroom.events.searchevent_idlist_roomsroom.list(scope self)search_commonsroom.commons.searchread_pageroom.commons.pages.readmoresays when there is more)write_pageroom.action.execute→room.commons.pages.create/pages.appendadd_commentroom.commons.comments.add/comments.replysend_messageroom.message.sendThe room index
A spoken room name is often misheard ("Sutando Triage" came through as "Sutando Triaging", "Sultano triage", "Orlando Triage"). The plugin keeps an index of the rooms the agent is a member of (
room.list, paged), fetched at start-up — it is the warm-up — and refreshed in the background after five minutes or on a miss. The names go into every prompt as aROOMS:line, so the model passes a name spelled exactly as listed; a miss returns the names too, and the descriptions say to call again with the one meant instead of declaring the room absent. Live: "Orlando Triage" and "Bodhi Migration" were mapped to the right rooms by the model with no code guessing.The relay agent's turn check
The turn check (#5348) sent a turn to the core unless the voice model had called
work. A room question the voice model answered withread_roomwas sent to the core as a task too. Two seams, both general:voiceSurface()names itsansweringTools; a turn the voice model answers with one of them is taken, like aworkcall (RelayAgent.noteAnsweringTool, wired invoice-agent.ts'sonToolCall);voiceSurface()gives the turn checkturnCheckRules;ag2space-voicesays a question about a room's messages or documents, or a note, comment or message to write into one, is the assistant's own, never work, even when it names a PR and even while the text is still missing.Writes
Speech is misheard and a write is seen by every member. Every write tool's description and the prompt rule make the condition explicit: say back the exact text, the page or room and the recipient, call only after a clear yes, and call in that same turn (live, the model once said "我现在就为您添加" and called nothing). Nothing is written without text, without a title for a new page, over 4,000 characters, or to a misheard room; an approval-gated room, a refusal or a failed status come back as errors and nothing is retried. Mentions are full user ids and only when the user asked to @ someone: mentioned agents act on being mentioned.
Other
not_connected,timeout,unavailable,no_room,not_found(with the rooms),ambiguous(with candidates), or the server's own code.Evidence
Tests (
tsc --noEmitclean):tests/ag2space-voice-room-read.test.ts: 42 cases — entry discovery; the stdio client against a real child process (init once, timeout, a dying server, a command that cannot start); name matching; the index (paging, one fetch at a time, kept on failure, refreshed when stale, cold timeout); every outcome ofreadRoom,searchRoom,searchCommons,readPage,writePage,addComment,sendMessage; each tool's shape and the wording the descriptions and rules must carry.tests/relay-agent.test.ts: 91 — including a turn answered with an answering tool (also while the check is running) is not sent, and the next turn is checked as usual; a skill's rule sits with the turn check's rules.tests/voice-plugin-seam.test.ts: 9 —answeringToolsandturnCheckRulesare collected.tests/ag2space-voice-navigate.test.ts: 35, unchanged.Mutation checks (each reverted): removing the answering-tool mark, the refresh-on-miss, the
aroundargument, the page cut, the title check, the approval check, the comment action, the mention and thread id checks — each fails 1–3 of the tests above.Live on xueqing (open-source checkout + this branch, agent
@liususan091219-maddy.agent),workspace/logs/voice-agent.log:The #5473 review was the 37th–42nd newest message in Sutando Triage, out of
read_room's reach; beforesearch_roomthe model answered from memory and mixed it up with #5479. The created page and the appended line were read back through the MCP afterwards and are there.add_commentandsend_message, live on xueqing at c0272ff's tools (14:33–14:34 PT), each read back through the MCP afterwards:In both, the model said the text and the target back, waited for the yes, and called in the turn the yes came. The turn check sent nothing for any of these turns (
answers this turn; the turn check sends nothing).After merging
origin/main(c0272ff):src/relay-agent.tskeeps both signals —lastTakenAt(a turn taken byworkor an answering tool) and #5035's origin capture before any wait. CI: 33 checks pass (Windows skipped as usual).Known limits
not_found.INTERNALto a Commons search (server side); the model is told the documents cannot be searched now.🤖 Generated with Claude Code