Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/live-a2a.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:
"$GITHUB_WORKSPACE/tests/ci/restrict_hermes_tools.py"
"$HERMES_HOME/bin/uv" pip install \
--python "$HERMES_HOME/hermes-agent/venv/bin/python3" \
'inkbox>=0.5.9,<1.0.0' 'aiohttp>=3.9' 'segno>=1.5'
'audioop-lts>=0.2.1; python_version >= "3.13"' 'inkbox>=0.5.9,<1.0.0' 'aiohttp>=3.9' 'segno>=1.5'

- name: Configure the deterministic model and AUT identity
env:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/live-channels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
"$HERMES_HOME/hermes-agent/venv/bin/python3" \
"$GITHUB_WORKSPACE/tests/ci/restrict_hermes_tools.py"
"$HERMES_HOME/bin/uv" pip install --python "$HERMES_HOME/hermes-agent/venv/bin/python3" \
'inkbox>=0.5.9,<1.0.0' \
'audioop-lts>=0.2.1; python_version >= "3.13"' 'inkbox>=0.5.9,<1.0.0' \
'aiohttp>=3.9' 'segno>=1.5'

- name: Configure AUT identity + model (${{ matrix.mode }})
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/live-external-events.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
"$HERMES_HOME/hermes-agent/venv/bin/python3" \
"$GITHUB_WORKSPACE/tests/ci/restrict_hermes_tools.py"
"$HERMES_HOME/bin/uv" pip install --python "$HERMES_HOME/hermes-agent/venv/bin/python3" \
'inkbox>=0.5.9,<1.0.0' \
'audioop-lts>=0.2.1; python_version >= "3.13"' 'inkbox>=0.5.9,<1.0.0' \
'aiohttp>=3.9' 'segno>=1.5'

- name: Configure AUT identity + model
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/live-voice.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ jobs:
"$HERMES_HOME/hermes-agent/venv/bin/python3" \
"$GITHUB_WORKSPACE/tests/ci/restrict_hermes_tools.py"
"$HERMES_HOME/bin/uv" pip install --python "$HERMES_HOME/hermes-agent/venv/bin/python3" \
'inkbox>=0.5.9,<1.0.0' \
'audioop-lts>=0.2.1; python_version >= "3.13"' 'inkbox>=0.5.9,<1.0.0' \
'aiohttp>=3.9' 'segno>=1.5' fastapi uvicorn

- name: Configure AUT identity + model + speech path (${{ matrix.scenario }})
Expand Down Expand Up @@ -159,6 +159,7 @@ jobs:
# Pytest owns the early hangup after both the transcript and open
# action gates pass. This is only the media peer's safety ceiling.
export VOICE_DRIVER_LISTEN=180
export VOICE_DRIVER_REASK=0
elif [ "${{ matrix.scenario }}" = "outbound_realtime_contact" ]; then
# Must name the contact the test seeds (see test_voice.py LOOKUP_CONTACT_*).
export VOICE_DRIVER_LINE="Hi! What email address do you have on file for your contact Olivia Parker? Please read the email out loud, then wait while I confirm I heard it."
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/scheduled-failure-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:

permissions:
actions: read
contents: read

jobs:
report:
Expand All @@ -22,6 +23,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted reporting scripts
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false

- name: Post failure notification
env:
NOTIFICATION_URL: ${{ secrets.SCHEDULED_FAILURE_NOTIFICATION_URL }}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
python-version: ["3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@v7
Expand All @@ -39,7 +39,7 @@ jobs:
run: |
uv venv --python ${{ matrix.python-version }}
uv pip install \
'inkbox>=0.5.9,<1.0.0' \
'audioop-lts>=0.2.1; python_version >= "3.13"' 'inkbox>=0.5.9,<1.0.0' \
'aiohttp>=3.9' 'segno>=1.5' 'pytest>=8' 'ruff>=0.11.0,<0.16'

- name: Lint
Expand Down Expand Up @@ -74,7 +74,7 @@ jobs:
run: |
uv venv --python 3.12
uv pip install \
'inkbox>=0.5.9,<1.0.0' \
'audioop-lts>=0.2.1; python_version >= "3.13"' 'inkbox>=0.5.9,<1.0.0' \
'aiohttp>=3.9' 'segno>=1.5' 'pytest>=8' 'ruff>=0.11.0,<0.16'
uv pip install --editable "$GITHUB_WORKSPACE/.upstream-hermes"

Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ RUN /usr/local/bin/uv pip install \
--python /opt/hermes/.venv/bin/python \
"inkbox==0.5.9" \
"aiohttp>=3.9" \
"audioop-lts>=0.2.1; python_version >= '3.13'" \
"segno>=1.5"

# Hermes's plugin installer expects a Git repository. Build one from the local
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ Realtime calls receive the agent's Inkbox handle, mailbox, phone number, caller

When Realtime is enabled, the plugin preflights the OpenAI Realtime websocket before accepting the Inkbox call in raw-media mode. If that preflight fails, calls fall back to Inkbox STT/TTS by default. Set `INKBOX_REALTIME_FALLBACK_TO_INKBOX_STT_TTS=false` to fail the call instead.

Realtime calls negotiate HD mono PCM16 audio at 16 kHz. The bridge resamples to and from the realtime session’s 24 kHz PCM format, with legacy 8 kHz call compatibility.

### Two calling lines

Calls — inbound and outbound — can run over either of two lines, and the agent picks the one that matches the channel it's talking on:
Expand Down
41 changes: 37 additions & 4 deletions adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,14 @@ def _write_private_json_atomic(path: Path, value: Any) -> None:
"just write it. Only call inkbox_send_email to email a DIFFERENT thread or "
"recipient, never to reply here (that sends your message twice).",
}
_ACTION_EXECUTION_GUIDANCE = (
"For a requested action such as placing a call or sending to another channel, "
"invoke the relevant tool; a text reply does not perform that action. "
"If Hermes exposes tool_search, tool_describe, and tool_call, discovery only "
"returns schemas: execute a discovered tool with tool_call, passing its name "
"and arguments. If the tool is directly available, call it directly. "
"Do not report an action as performed unless its tool result confirms success."
)
SMS_MAX_LENGTH = 1600 # Inkbox SMS hard cap
IMESSAGE_MAX_LENGTH = 18995 # Sendblue-compatible iMessage text cap
IMESSAGE_MEDIA_MAX_BYTES = 10 * 1024 * 1024
Expand Down Expand Up @@ -4214,10 +4222,9 @@ def _hosted_sms_context_from_data(
str(action.get(field) or "")
for field in ("action", "description", "details")
)
context.extend(_positive_sms_clauses(
action_text,
_OPEN_ACTION_SMS_COMMITMENT_PATTERNS,
))
if _positive_sms_clauses(action_text, _OPEN_ACTION_SMS_COMMITMENT_PATTERNS):
# Preserve explicit message bodies, including punctuation.
context.append(action_text)
for text in transcript_texts:
context.extend(_positive_sms_clauses(
text,
Expand Down Expand Up @@ -4307,6 +4314,9 @@ def _hosted_sms_correction_event(
"Call inkbox_send_sms exactly once with `to` set to the exact "
f"authoritative remote number `{remote_phone}` and `text` set to "
"the still-needed SMS body from the SMS-only context below.",
"If the caller supplied an exact message body in the action or "
"transcript, copy it verbatim. Do not replace it with an "
"acknowledgment, summary, or generic follow-up.",
"Do not use conversationId, another recipient, or plain prose. "
"Do not reply [SILENT] or skip the tool in this correction turn. "
"Do not execute any non-SMS post-call action. Stop after the tool result.",
Expand Down Expand Up @@ -5950,6 +5960,7 @@ def _resolve_channel_overrides(
# it's always in context — an operator prompt (if any) is appended after.
builtin = _REPLY_AUTOSEND_DIRECTIVES.get(modality)
if builtin:
builtin = f"{builtin}\n\n{_ACTION_EXECUTION_GUIDANCE}"
prompt = f"{builtin}\n\n{prompt}" if prompt else builtin
configured = self._lookup_channel_skills(extra, contact_key, modality)
return prompt, self._merge_auto_skills(default_skills, configured)
Expand Down Expand Up @@ -7548,6 +7559,21 @@ async def _on_call_ended(
call_id = str(call.get("id") or "").strip()
if not call_id:
return web.Response(status=200, text="ignored")
admissions = self.__dict__.setdefault("_hosted_call_admissions", set())
if call_id in admissions:
return web.Response(status=200, text="duplicate")
admissions.add(call_id)
try:
return await self._admit_hosted_call_completion(
envelope, data, call, call_id, _safe_recovery=_safe_recovery,
)
finally:
admissions.discard(call_id)

async def _admit_hosted_call_completion(
self, envelope: Dict[str, Any], data: Dict[str, Any],
call: Dict[str, Any], call_id: str, *, _safe_recovery: bool = False,
) -> "web.Response":
event_id = str(envelope.get("id") or "").strip()
existing = self._read_hosted_call_registry().get(call_id)
existing_state = (
Expand Down Expand Up @@ -7732,6 +7758,9 @@ async def _on_call_ended(
f"remote number `{escaped_remote}` and `text` set to the "
"requested SMS body. Do not use conversationId for this "
"post-call send.",
"If the caller supplied an exact message body in the action or "
"transcript, copy it verbatim. Do not replace it with an "
"acknowledgment, summary, or generic follow-up.",
"The SMS commitment is complete only after inkbox_send_sms "
"returns a success payload with `ok: true`. Plain text is not "
"a send and does not complete the commitment.",
Expand Down Expand Up @@ -7877,6 +7906,7 @@ async def _handle_call_ws(self, request: "web.Request") -> "web.WebSocketRespons

async def _prepare_call_ws(*, use_realtime: bool) -> None:
if use_realtime:
ws.headers["x-inkbox-audio-format"] = "pcm_s16le_16000"
ws.headers["x-use-inkbox-text-to-speech"] = "false"
ws.headers["x-use-inkbox-speech-to-text"] = "false"
else:
Expand Down Expand Up @@ -8600,6 +8630,9 @@ async def _realtime_post_call_actions(
"Do not merely say still-needed actions are impossible. If an email, "
"SMS, note, or contact update is still needed and enough recipient/"
"content info is present, perform it.",
"If the caller supplied an exact message body in the action or "
"transcript, copy it verbatim. Do not replace it with an "
"acknowledgment, summary, or generic follow-up.",
"Do NOT send a confirmation follow-up after successful work unless the "
"caller explicitly requested one. Only if required information is "
"missing, ask the caller for the missing information. Try SMS first; "
Expand Down
65 changes: 65 additions & 0 deletions audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Streaming mono PCM16 conversion for call audio."""

from __future__ import annotations

import base64
import sys


class AudioConverter:
"""Convert one continuous audio stream, preserving phase and partial samples."""

def __init__(self, source_rate: int, target_rate: int, *, decode_ulaw=False, encode_ulaw=False):
self.source_rate = source_rate
self.target_rate = target_rate
self.decode_ulaw = decode_ulaw
self.encode_ulaw = encode_ulaw
self.reset()

def reset(self):
self._state = None
self._pending = b""

def convert(self, payload: str) -> str:
import audioop

data = base64.b64decode(payload, validate=True)
if self.decode_ulaw:
data = audioop.ulaw2lin(data, 2)
else:
data = self._pending + data
complete = len(data) - len(data) % 2
self._pending, data = data[complete:], data[:complete]
if sys.byteorder != "little":
data = audioop.byteswap(data, 2)
if not data:
return ""
converted, self._state = audioop.ratecv(
data, 2, 1, self.source_rate, self.target_rate, self._state,
)
if self.encode_ulaw:
converted = audioop.lin2ulaw(converted, 2)
elif sys.byteorder != "little":
converted = audioop.byteswap(converted, 2)
return base64.b64encode(converted).decode("ascii")


class CallAudio:
"""Negotiate call media independently from the 24 kHz realtime session."""

def __init__(self):
self.configure(None)

def configure(self, descriptor):
if descriptor is None:
rate, ulaw = 8000, True
elif not isinstance(descriptor, dict):
raise ValueError("Unsupported call audio format")
elif (descriptor.get("encoding"), descriptor.get("sample_rate"), descriptor.get("channels")) == ("L16", 16000, 1):
rate, ulaw = 16000, False
elif (str(descriptor.get("encoding", "")).lower(), descriptor.get("sample_rate"), descriptor.get("channels")) == ("pcmu", 8000, 1):
rate, ulaw = 8000, True
else:
raise ValueError("Unsupported call audio format")
self.inbound = AudioConverter(rate, 24000, decode_ulaw=ulaw)
self.outbound = AudioConverter(24000, rate, encode_ulaw=ulaw)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ description = "Inkbox platform plugin for Hermes Agent"
requires-python = ">=3.11"
dependencies = [
"aiohttp>=3.9",
"audioop-lts>=0.2.1; python_version >= '3.13'",
"inkbox>=0.5.9,<1.0.0",
"segno>=1.5",
]
Expand Down
36 changes: 28 additions & 8 deletions realtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
1. Preflights an OpenAI Realtime API WebSocket
(``wss://api.openai.com/v1/realtime?model=<model>``) and sends
``session.update`` configuring tools, instructions, and the
``g711_ulaw`` input/output audio format.
24 kHz PCM input/output audio format.
2. Lets the adapter accept the Inkbox call WS with
``x-use-inkbox-text-to-speech: false`` and
``x-use-inkbox-speech-to-text: false`` headers only after OpenAI is ready,
Expand Down Expand Up @@ -50,6 +50,11 @@
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple
from urllib.parse import urlencode

try:
from .audio import CallAudio
except ImportError: # Standalone plugin loading.
from audio import CallAudio

try:
import aiohttp
except ImportError: # pragma: no cover — aiohttp is a core dep on this fork
Expand All @@ -60,7 +65,7 @@
REALTIME_URL = "wss://api.openai.com/v1/realtime"
DEFAULT_MODEL = "gpt-realtime-2"
DEFAULT_VOICE = "cedar"
AUDIO_FORMAT_TELEPHONY = {"type": "audio/pcmu"}
AUDIO_FORMAT_PCM = {"type": "audio/pcm", "rate": 24000}
INPUT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe"

AGENT_CONSULT_TOOL_NAME = "consult_agent"
Expand Down Expand Up @@ -538,6 +543,7 @@ class RealtimeConsultResult:

@dataclass
class _BridgeState:
audio: CallAudio = field(default_factory=CallAudio)
transcript: List[Tuple[str, str]] = field(default_factory=list)
post_call_actions: List[Dict[str, str]] = field(default_factory=list)
consult_results: List[RealtimeConsultResult] = field(default_factory=list)
Expand Down Expand Up @@ -1167,7 +1173,7 @@ async def _send_session_update(
"output_modalities": ["audio"],
"audio": {
"input": {
"format": AUDIO_FORMAT_TELEPHONY,
"format": AUDIO_FORMAT_PCM,
"noise_reduction": None,
"transcription": {"model": INPUT_TRANSCRIPTION_MODEL},
# Server-side VAD with default settings — the model
Expand All @@ -1184,7 +1190,7 @@ async def _send_session_update(
},
},
"output": {
"format": AUDIO_FORMAT_TELEPHONY,
"format": AUDIO_FORMAT_PCM,
"voice": config.voice,
},
},
Expand Down Expand Up @@ -1223,13 +1229,24 @@ async def _inkbox_to_openai_pump(
continue
event = (frame.get("event") or "").lower()
if event == "start":
state.stream_id = frame.get("stream_id") or state.stream_id
start = frame.get("start") or {}
state.stream_id = frame.get("stream_id") or start.get("stream_id") or state.stream_id
state.audio.configure(start.get("media_format", frame.get("media_format")))
logger.info(
"[Inkbox realtime] call_id=%s audio_format=%s sample_rate=%d",
meta.call_id,
"pcmu" if state.audio.inbound.decode_ulaw else "pcm_s16le",
state.audio.inbound.source_rate,
)
await _maybe_send_greeting(openai_ws, state, meta)
elif event == "media":
if not state.greeting_triggered:
await _maybe_send_greeting(openai_ws, state, meta)
payload_b64 = (frame.get("media") or {}).get("payload")
if payload_b64:
payload_b64 = state.audio.inbound.convert(payload_b64)
if not payload_b64:
continue
state.caller_media_frames += 1
await openai_ws.send_str(json.dumps({
"type": "input_audio_buffer.append",
Expand Down Expand Up @@ -1508,11 +1525,12 @@ async def _relay_transcript(party: str, text: str) -> None:

# GA emits ``response.output_audio.delta``; beta ``response.audio.delta``.
if ftype in ("response.output_audio.delta", "response.audio.delta"):
# Already μ-law base64. Forward as an outbound Inkbox media frame,
# echoing the stream_id and tagging the track per the Inkbox media
# protocol.
# Convert the streaming PCM response to the negotiated call format.
delta_b64 = frame.get("delta") or ""
if delta_b64:
delta_b64 = state.audio.outbound.convert(delta_b64)
if not delta_b64:
continue
# Goodbye audio is still streaming — hold any pending
# auto-confirm so the farewell isn't clipped mid-word.
_cancel_hangup_auto_confirm(state)
Expand All @@ -1530,6 +1548,7 @@ async def _relay_transcript(party: str, text: str) -> None:

# Outbound audio for a response finished — tell Inkbox to flush/play.
elif ftype in ("response.output_audio.done", "response.audio.done"):
state.audio.outbound.reset()
done = {"event": "audio_done"}
if state.stream_id:
done["stream_id"] = state.stream_id
Expand All @@ -1556,6 +1575,7 @@ async def _relay_transcript(party: str, text: str) -> None:

# Caller started speaking (barge-in) — drop any queued outbound audio.
elif ftype == "input_audio_buffer.speech_started":
state.audio.outbound.reset()
if state.hangup_armed_at is not None:
state.hangup_armed_at = None
_cancel_hangup_auto_confirm(state)
Expand Down
Loading
Loading