feat: couple broadcast to voice + voice-controlled broadcast toggle

Completes the STREAM_BROWSER=true behaviour:
- handleJoin auto-starts the broadcast on voice join and wires the session to
  the guild streamer; each turn reports the live state to the brain so search
  routes Chrome (live) vs Gemini (off).
- New setBroadcast tool lets the user toggle the broadcast by voice ("방송
  켜줘/꺼줘") via the LLM (no hardcoded phrases); it refuses when
  STREAM_BROWSER=false. The directive flows brain -> bridge (broadcast_action)
  -> bot streamer.start/stop, guarded by isActive() so it's idempotent.
- Per-turn IPC uses a thread-local (reply/turn_state.py) instead of threading
  params through the whole engine chain: bridge sets broadcasting in, tool
  records the directive out; Tool.execute exposes broadcasting on ToolContext.

Bot typecheck clean; brain covered by tests/test_set_broadcast.py (+ existing
routing tests). Specs + docs updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
javis-bot
2026-06-11 10:08:30 +09:00
parent 35e754d6ee
commit ca86390407
11 changed files with 316 additions and 14 deletions

View File

@@ -0,0 +1,48 @@
"""Per-turn, request-scoped state shared between the bridge, the reply engine
and tools — without threading extra parameters through the whole engine call
chain (run_reply_engine -> run_tool_with_retries -> execute_tool -> execute).
Flask serves each HTTP request on its own thread and the reply engine + tools
run synchronously on that thread, so a ``threading.local`` cleanly scopes a
single turn. Anything that can't see the thread-local (a worker thread, an eval,
a unit test) just reads ``None`` and the caller falls back to its default.
Fields
------
broadcasting : Optional[bool]
The live screen-share (Go-Live) state the bot sends with the request. The
webSearch routing reads it to pick on-screen Chrome (live) vs Gemini (off).
broadcast_action : Optional[str]
A directive ("start"/"stop") recorded by the setBroadcast tool for the
bridge to return to the bot, which owns the actual broadcast.
"""
from __future__ import annotations
import threading
from typing import Optional
_state = threading.local()
def reset() -> None:
"""Clear per-turn state. Call at the start of each request."""
_state.broadcasting = None
_state.broadcast_action = None
def set_broadcasting(value: Optional[bool]) -> None:
_state.broadcasting = value
def get_broadcasting() -> Optional[bool]:
return getattr(_state, "broadcasting", None)
def request_broadcast(action: str) -> None:
"""Record a broadcast directive. ``action`` is "start" or "stop"."""
_state.broadcast_action = action
def get_broadcast_action() -> Optional[str]:
return getattr(_state, "broadcast_action", None)

View File

@@ -111,6 +111,14 @@ class Tool(ABC):
This method creates the context and calls the tool's run method.
Tools should implement run(), not this method.
"""
# Live broadcast state for this turn is carried in request-scoped
# thread-local state (set by the bridge) rather than threaded through
# the whole engine call chain. Absent (eval / unit test) -> None.
try:
from jarvis.reply.turn_state import get_broadcasting
broadcasting = get_broadcasting()
except Exception:
broadcasting = None
context = ToolContext(
db=db,
cfg=cfg,
@@ -120,5 +128,6 @@ class Tool(ABC):
max_retries=max_retries,
user_print=user_print,
language=language,
broadcasting=broadcasting,
)
return self.run(tool_args, context)

View File

@@ -0,0 +1,75 @@
"""Start or stop the live screen-share broadcast (Go-Live) on request.
The brain cannot drive the Discord broadcast itself — the bot owns it. This
tool records a directive ("start"/"stop") in the per-turn state; the bridge
returns it to the bot, which performs the actual start/stop. Only meaningful in
screen-share mode (``STREAM_BROWSER`` true); when broadcasting is disabled the
tool refuses so the user is told it cannot be turned on.
"""
from __future__ import annotations
from typing import Dict, Any, Optional
from ..base import Tool, ToolContext
from ..types import ToolExecutionResult
from ...debug import debug_log
class SetBroadcastTool(Tool):
"""Turn the live screen-share broadcast on or off."""
@property
def name(self) -> str:
return "setBroadcast"
@property
def description(self) -> str:
return (
"Start or stop the live screen-share broadcast (Go-Live / showing your "
"screen on the call). Use when the user asks to turn the broadcast or "
"screen on or off (show/hide your screen, go live, stop streaming). "
"Only available in screen-share mode."
)
@property
def inputSchema(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["start", "stop"],
"description": "'start' to begin the broadcast, 'stop' to end it.",
}
},
"required": ["action"],
}
def run(self, args: Optional[Dict[str, Any]], context: ToolContext) -> ToolExecutionResult:
cfg = context.cfg
if not getattr(cfg, "stream_browser", True):
# Broadcast capability is disabled (STREAM_BROWSER=false): refuse so
# the user is clearly told it can't be turned on in this mode.
return ToolExecutionResult(
success=False,
reply_text="방송(화면 공유) 기능이 꺼져 있어 켤 수 없습니다 (STREAM_BROWSER=false).",
)
action = ""
if args and isinstance(args, dict):
action = str(args.get("action", "")).strip().lower()
if action not in ("start", "stop"):
return ToolExecutionResult(
success=False,
reply_text="Specify action='start' or action='stop'.",
)
# Record the directive for the bridge to return to the bot.
from ...reply.turn_state import request_broadcast
request_broadcast(action)
debug_log(f" 📡 setBroadcast requested: {action}", "stream")
return ToolExecutionResult(
success=True,
reply_text="방송을 시작합니다." if action == "start" else "방송을 종료합니다.",
)

View File

@@ -0,0 +1,30 @@
# setBroadcast tool
Lets the user turn the live screen-share broadcast (Go-Live) on or off by voice
or text, e.g. "방송 켜줘 / 화면 보여줘" or "방송 꺼줘". The LLM picks the intent
and calls the tool (no hardcoded phrase matching), so it works in any language.
## Contract
- Input: `{ "action": "start" | "stop" }`.
- The brain cannot drive the Discord broadcast itself (the bot owns it). The
tool records the directive in request-scoped state
(`jarvis/reply/turn_state.request_broadcast`); `bridge/server.py` returns it as
`broadcast_action` in the `/converse` (and `/text`) response, and the bot
(`voice.ts` -> `index.ts` `onBroadcastAction`) calls `streamer.start/stop`.
- Mode gate: when `cfg.stream_browser` is false (`STREAM_BROWSER=false`)
broadcasting is disabled — the tool refuses with a spoken explanation and
records no directive. This is the same capability flag that makes `/stream`
refuse and forces real-time search to Gemini.
## Principles
- Tool returns raw intent/result; it performs no broadcast itself and makes no
LLM call.
- Idempotent at the bot edge: `start` is a no-op if already live, `stop` a no-op
if already off (`streamer.isActive()` guard).
- Fail-safe: a missing/invalid action records nothing; the bot only acts on an
explicit `"start"`/`"stop"` directive.
See `docs/stream_browser_modes.md` for how this fits the broadcast-coupled
search routing.

View File

@@ -21,6 +21,7 @@ from .builtin.weather import WeatherTool
from .builtin.stop import StopTool
from .builtin.tool_search import ToolSearchTool
from .builtin.browse_and_play import BrowseAndPlayTool
from .builtin.set_broadcast import SetBroadcastTool
from .types import ToolExecutionResult
from ..config import Settings
from .external.mcp_client import MCPClient
@@ -41,6 +42,7 @@ BUILTIN_TOOLS = {
"stop": StopTool(),
"toolSearchTool": ToolSearchTool(),
"browseAndPlay": BrowseAndPlayTool(),
"setBroadcast": SetBroadcastTool(),
}
# Global MCP tools cache