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
1 change: 1 addition & 0 deletions DOC_AUDIT_IGNORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ similarity_search: langchain/pinecone Pinecone.similarity_search — search comp
build_index: real IndexBuilder.build_index (signalwire/search/index_builder.py) — Python-only search skill, absent from the cross-port surface
build_index_from_sources: real IndexBuilder.build_index_from_sources (signalwire/search/index_builder.py) — Python-only search skill, absent from the cross-port surface
migrate_sqlite_to_pgvector: real migration helper (signalwire/search/migration.py) — Python-only search skill, absent from the cross-port surface
get_stats: real SearchEngine.get_stats (signalwire/search/search_engine.py) — Python-only search skill, absent from the cross-port surface
argsort: numpy.argsort — DIY search example in docs/search_overview.md
md: filename-extension regex false positive (matches `file.md (…`) in docs/search_overview.md processing listing
do_search: user-defined method inside a caching example in docs/search_deployment.md
Expand Down
61 changes: 61 additions & 0 deletions signalwire/signalwire/ai_chat/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ class AIChatError(Exception):
"""Base error for AI Chat service failures."""

def __init__(self, code: int | None, message: str) -> None:
"""Carry the service's own failure code alongside its message.

Args:
code: JSON-RPC error code from the service (e.g. -32001 unknown
conversation, -32009 rejected identity), or None when the
failure happened below the JSON-RPC layer (transport, bad HTTP).
message: Human-readable description from the service.
"""
self.code = code
self.message = message
super().__init__(f"[{code}] {message}")
Expand Down Expand Up @@ -109,20 +117,51 @@ class SummaryError(AIChatError):

@dataclass
class ConversationInfo:
"""A conversation as the service reports it after create/end.

Attributes:
id: Service-assigned conversation id. This, not anything the caller
supplies, is what later turns must reference.
status: Lifecycle state reported by the service (e.g. "created",
"ended").
initial_message: Opening line when the agent speaks first, else None.
"""

id: str
status: str
initial_message: str | None = None


@dataclass
class ChatResponse:
"""One assistant turn.

Attributes:
text: What the assistant said — the only field a simple client needs.
conversation_id: Conversation this turn belongs to, for the next turn.
user_event: Structured payload the agent emitted alongside the text
(a SWML user_event), or None. This is how an agent asks the client
to do something — render a form, show a keypad — rather than only
speak.
"""

text: str
conversation_id: str
user_event: dict[str, Any] | None = None


@dataclass
class ChatLog:
"""A conversation transcript as the service stores it.

Attributes:
messages: Every turn, INCLUDING the substituted system prompt and tool
traffic. Do not relay this to a browser verbatim — see
`ChatGateway.visible_messages`, which reduces it to the dialogue.
call_timeline: Timed events for the conversation, when the service
reports them.
"""

messages: list[dict[str, Any]] = field(default_factory=list)
call_timeline: list[dict[str, Any]] = field(default_factory=list)

Expand All @@ -141,6 +180,23 @@ def __init__(
url: str | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Build a client for the AI Chat service.

Each argument falls back to its environment variable, so a configured
environment needs no arguments at all.

Args:
project: Project id. Falls back to `SIGNALWIRE_PROJECT_ID`.
token: API token. Falls back to `SIGNALWIRE_API_TOKEN`.
space: Space name used to build the default URL. Falls back to
`SIGNALWIRE_SPACE`.
url: Full service URL, overriding the one derived from `space`.
session: An existing aiohttp session to reuse. Omit and the client
creates (and owns, and closes) its own.

Raises:
ValueError: If no project id is available from either source.
"""
self._project = project or os.environ.get("SIGNALWIRE_PROJECT_ID", "")
self._token = token or os.environ.get("SIGNALWIRE_API_TOKEN", "")
space = space or os.environ.get("SIGNALWIRE_SPACE", "")
Expand Down Expand Up @@ -201,6 +257,11 @@ async def _ensure_session(self) -> aiohttp.ClientSession:
return self._session

async def close(self) -> None:
"""Close the HTTP session, if this client owns it.

A session passed in via `session=` belongs to the caller and is left
open; only one the client created for itself is closed here.
"""
if self._owns_session and self._session is not None:
await self._session.close()
self._session = None
Expand Down
52 changes: 52 additions & 0 deletions signalwire/signalwire/ai_chat/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ class GatewayRejection(Exception):
"""

def __init__(self, status: int, reason: str) -> None:
"""Refuse a browser request with the HTTP status the route should return.

Args:
status: HTTP status to send back (401 bad key, 403 origin/handle,
400 disallowed method, 429 a cap was hit).
reason: Short, non-leaking explanation. It reaches the browser, so
it must not disclose why a handle failed to verify.
"""
self.status = status
self.reason = reason
super().__init__(f"{status}: {reason}")
Expand Down Expand Up @@ -156,6 +164,34 @@ def __init__(
max_turns: int = DEFAULT_MAX_TURNS,
window_seconds: int = DEFAULT_WINDOW_SECONDS,
) -> None:
"""Build a gateway that fronts one agent for browser traffic.

Args:
config_url: SWML config the gateway always sends upstream. Required,
and never taken from the request body — if a browser could name
it, whoever holds a key would pick which agent runs and which
project pays for it.
key: Publishable key the browser presents. Safe to ship in a page:
it names no credential and the caps below bound what it can cost.
allowed_origins: Origins permitted to call in. localhost is always
allowed so `pip install` → run works without shipping
open-by-default; every other origin must be listed.
client: An `AIChatClient` to reuse. Omit and the gateway builds (and
owns) its own from the ambient credentials.
secret: HMAC key for signing conversation handles. Omit to generate
one per process — handles then stop verifying across a restart
or a second worker, so set it in production.
handle_ttl: Seconds a signed handle stays valid.
conversation_timeout: Idle seconds before the service ends a
conversation. Omit to report the service default.
max_new_conversations: Cap on conversations minted per window — the
control that makes a leaked key a bill rather than a breach.
max_turns: Cap on turns per conversation.
window_seconds: Length of the rolling window the caps count over.

Raises:
ValueError: If `config_url` is empty.
"""
if not config_url:
raise ValueError("config_url is required — it is what a key is scoped to.")

Expand Down Expand Up @@ -223,6 +259,11 @@ def effective_timeout(self) -> int:
return self.conversation_timeout or SERVICE_DEFAULT_CONVERSATION_TIMEOUT

async def close(self) -> None:
"""Release the upstream HTTP session, if this gateway owns it.

A client passed in via `client=` belongs to the caller and is left
open; only a client the gateway built for itself is closed here.
"""
if self._owns_client:
await self._client.close()

Expand Down Expand Up @@ -288,6 +329,17 @@ def check_origin(self, origin: str | None) -> None:
raise GatewayRejection(403, "origin not allowed")

def check_key(self, presented: str | None) -> None:
"""Verify the publishable key the browser sent.

Compared with `hmac.compare_digest` rather than `==` so the check does
not leak the key a character at a time through timing.

Args:
presented: Key from the request, or None when the header is absent.

Raises:
GatewayRejection: 401 if the key is missing or does not match.
"""
if not presented or not hmac.compare_digest(presented, self.key):
raise GatewayRejection(401, "bad key")

Expand Down
51 changes: 12 additions & 39 deletions signalwire/signalwire/core/post_prompt_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@
from typing import Any, Literal, TypeAlias, TypedDict
from typing import TYPE_CHECKING

# Types owned by sibling swaig specs, imported so the cross-file
# $ref fields below resolve to the real type rather than a dict.
# SwaigRequest is generated in swaig_request_generated; aliased here for the
# swaig_log entry's post_data field.
if TYPE_CHECKING:
from signalwire.core.swaig_actions_generated import SwaigResponse as SwaigResponse
from signalwire.core.swaig_request_generated import SwaigRequest as SwaigRequest


Expand Down Expand Up @@ -70,7 +69,7 @@ class PostPrompt(TypedDict, total=False):
class PostPromptData(TypedDict, total=False):
"""Open shape: extra server keys permitted; not validated at runtime."""

parsed: list[dict[str, Any] | list[Any]]
parsed: list[dict[str, Any]]
raw: str
substituted: str

Expand Down Expand Up @@ -161,40 +160,14 @@ class PostPromptSystemLogEntry(TypedDict, total=False):
role: str
content: str
timestamp: int
action: Literal[
"attention_timeout",
"attention_wait",
"auto_correct",
"change_step_failed",
"check_for_input",
"context_enter",
"double_turn",
"filler",
"function_call",
"function_error",
"function_loop",
"gather_answer",
"gather_complete",
"gather_question",
"gather_reject",
"gather_start",
"hangup_hook",
"hearing_hint",
"inner_dialog",
"inner_dialog_scorecard",
"manual_say",
"reset",
"session_end",
"session_start",
"startup_hook",
"step_change",
"summarize_start",
"swaig_problem",
]
action: str
lang: str
tokens: int
content_type: str
metadata: dict[str, Any]
context: str
step: str
step_index: int


class PostPromptSystemEntry(TypedDict, total=False):
Expand All @@ -211,16 +184,16 @@ class PostPromptSwaigLogEntry(TypedDict, total=False):
command_name: str
command_arg: str
epoch_time: int
native: Literal[True]
native: bool
active_count: int | Literal["endless"]
url: str
post_data: SwaigRequest
post_response: SwaigResponse
delayed_post_response: SwaigResponse
post_response: dict[str, Any]
delayed_post_response: dict[str, Any]
mcp_url: str
mcp_tool: str
mcp_response: str
mcp_error: Literal[True]
mcp_response: dict[str, Any]
mcp_error: str


class PostPromptTimesEntry(TypedDict, total=False):
Expand Down
Loading