Skip to content

tools

tools

Safe desktop tools for realtime voice sessions (capped step budget).

Classes

VoiceToolBudget

VoiceToolBudget(max_steps: int = 12)

Limit chained tool calls inside one TURN.

The cap used to be per-session and never reset: after twelve tool calls spread over a long conversation, every later "joue X" silently failed with "budget exceeded" for the rest of the session. The loop bound it exists for (a model asking for tools forever) is a per-turn problem.

Source code in src/diapason/speech/realtime/tools.py
def __init__(self, max_steps: int = 12) -> None:
    self.max_steps = max(0, int(max_steps))
    self.used = 0

Functions:

gemini_function_declarations

gemini_function_declarations(
    allowed: Optional[Sequence[str]] = None,
) -> list[dict[str, Any]]

Gemini Live functionDeclarations list.

Source code in src/diapason/speech/realtime/tools.py
def gemini_function_declarations(
    allowed: Optional[Sequence[str]] = None,
) -> list[dict[str, Any]]:
    """Gemini Live ``functionDeclarations`` list."""
    _ensure_desktop_tools_loaded()
    from diapason.core.registry import ToolRegistry

    decls: list[dict[str, Any]] = []
    for tid in list_voice_tool_ids(allowed):
        try:
            tool = ToolRegistry.create(tid)
        except Exception:
            continue
        spec = tool.spec
        decls.append(
            {
                "name": spec.name,
                "description": spec.description,
                "parameters": spec.parameters or {"type": "object", "properties": {}},
            }
        )
    return decls

openai_tools_schema

openai_tools_schema(
    allowed: Optional[Sequence[str]] = None,
) -> list[dict[str, Any]]

OpenAI Realtime session.tools entries.

Source code in src/diapason/speech/realtime/tools.py
def openai_tools_schema(
    allowed: Optional[Sequence[str]] = None,
) -> list[dict[str, Any]]:
    """OpenAI Realtime ``session.tools`` entries."""
    _ensure_desktop_tools_loaded()
    from diapason.core.registry import ToolRegistry

    out: list[dict[str, Any]] = []
    for tid in list_voice_tool_ids(allowed):
        try:
            tool = ToolRegistry.create(tid)
        except Exception:
            continue
        out.append(tool.to_openai_function())
    return out

execute_voice_tool

execute_voice_tool(
    name: str,
    arguments: Optional[dict[str, Any]] = None,
    allowed: Optional[Sequence[str]] = None,
) -> dict[str, Any]

Run an allow-listed tool through the executor; JSON-serializable payload.

Cette fonction appelait tool.execute(**args) EN DIRECT. Elle sautait donc ToolExecutor, et avec lui la politique de capacités, le garde-frontière, le limiteur de débit et — le plus grave — la confirmation des actions sensibles. Or mail_send et messages_send figurent dans la liste vocale : une phrase mal comprise pouvait envoyer un courriel ou un SMS au nom de l'utilisateur, sans que rien ne lui soit demandé.

Le commentaire « never auto-send from live voice — drafts only » ne protégeait que mail_compose et messages_compose. Les deux outils d'envoi, eux, passaient à côté.

La liste d'autorisation reste la première barrière ; l'exécuteur est la seconde, et c'est celle qui demande l'accord.

Source code in src/diapason/speech/realtime/tools.py
def execute_voice_tool(
    name: str,
    arguments: Optional[dict[str, Any]] = None,
    allowed: Optional[Sequence[str]] = None,
) -> dict[str, Any]:
    """Run an allow-listed tool through the executor; JSON-serializable payload.

    Cette fonction appelait ``tool.execute(**args)`` EN DIRECT. Elle sautait
    donc ``ToolExecutor``, et avec lui la politique de capacités, le
    garde-frontière, le limiteur de débit et — le plus grave — la confirmation
    des actions sensibles. Or ``mail_send`` et ``messages_send`` figurent dans
    la liste vocale : une phrase mal comprise pouvait envoyer un courriel ou un
    SMS au nom de l'utilisateur, sans que rien ne lui soit demandé.

    Le commentaire « never auto-send from live voice — drafts only » ne
    protégeait que ``mail_compose`` et ``messages_compose``. Les deux outils
    d'envoi, eux, passaient à côté.

    La liste d'autorisation reste la première barrière ; l'exécuteur est la
    seconde, et c'est celle qui demande l'accord.
    """
    _ensure_desktop_tools_loaded()
    from diapason.core.types import ToolCall

    tid = (name or "").strip()
    ids = list_voice_tool_ids(allowed)
    if tid not in set(ids):
        return {"ok": False, "error": f"Tool not allowed in voice mode: {tid}"}
    try:
        args = dict(arguments or {})
        # Never auto-send from live voice — drafts only.
        if tid in ("mail_compose", "messages_compose"):
            args.pop("send", None)
        resultat = _executeur_pour(ids).execute(
            ToolCall(
                id=f"voice-{tid}", name=tid, arguments=json.dumps(args, default=str)
            )
        )
        return {
            "ok": bool(resultat.success),
            "content": resultat.content,
            "metadata": getattr(resultat, "metadata", None) or {},
        }
    except Exception as exc:
        logger.exception("voice tool %s failed", tid)
        return {"ok": False, "error": str(exc)}