Spaces:
Running
Running
Commit
·
2f49513
1
Parent(s):
06523e9
Added the healthcare example
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- Dockerfile +16 -8
- README.md +75 -4
- examples/voice_agent_multi_thread/DOCKER_DEPLOYMENT.md +322 -0
- examples/voice_agent_multi_thread/Dockerfile +78 -0
- examples/voice_agent_multi_thread/PIPECAT_MULTI_THREADING.md +295 -0
- examples/voice_agent_multi_thread/README.md +112 -0
- examples/voice_agent_multi_thread/agents/.telco_thread_id +1 -0
- examples/voice_agent_multi_thread/agents/Dockerfile.langgraph.api +39 -0
- examples/voice_agent_multi_thread/agents/env.example +10 -0
- examples/voice_agent_multi_thread/agents/helper_functions.py +62 -0
- examples/voice_agent_multi_thread/agents/langgraph.json +15 -0
- examples/voice_agent_multi_thread/agents/requirements.txt +15 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/IMPLEMENTATION_SUMMARY.md +280 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/MULTI_THREAD_README.md +227 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/README.md +57 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/__init__.py +10 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/example_multi_thread.py +233 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/logic.py +1003 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/customers.json +66 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/otps.json +17 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/packages.json +72 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/roaming_rates.json +30 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/prompts.py +30 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/react_agent.py +600 -0
- examples/voice_agent_multi_thread/agents/telco-agent-multi/tools.py +192 -0
- examples/voice_agent_multi_thread/agents/telco_client.py +570 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/README.md +56 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/__init__.py +9 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/fees_agent.py +15 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/logic.py +634 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/accounts.json +170 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/beneficiaries.json +15 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/country_requirements.json +11 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/cutoff_times.json +6 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/exchange_rates.json +12 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/fee_schedules.json +17 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/limits.json +15 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/otps.json +13 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/packages.json +11 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/sanctions_list.json +8 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/transactions.json +22 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/prompts.py +31 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/react_agent.py +396 -0
- examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/tools.py +167 -0
- examples/voice_agent_multi_thread/docker-compose.yml +49 -0
- examples/voice_agent_multi_thread/env.example +9 -0
- examples/voice_agent_multi_thread/index.html +154 -0
- examples/voice_agent_multi_thread/ipa.json +121 -0
- examples/voice_agent_multi_thread/langgraph_llm_service.py +432 -0
- examples/voice_agent_multi_thread/pipeline.py +550 -0
Dockerfile
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Build UI assets
|
| 2 |
FROM node:18-alpine AS ui-builder
|
|
|
|
| 3 |
|
| 4 |
WORKDIR /ui
|
| 5 |
# Install UI dependencies
|
| 6 |
-
COPY examples/
|
| 7 |
RUN npm ci --no-audit --no-fund && npm cache clean --force
|
| 8 |
# Build UI
|
| 9 |
-
COPY examples/
|
| 10 |
RUN npm run build
|
| 11 |
|
| 12 |
# Base image
|
| 13 |
FROM python:3.12-slim
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
# Environment setup
|
| 16 |
ENV PYTHONUNBUFFERED=1
|
| 17 |
ENV UV_NO_TRACKED_CACHE=1
|
|
|
|
| 18 |
|
| 19 |
# System dependencies
|
| 20 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
@@ -46,13 +54,13 @@ COPY --chown=user pyproject.toml uv.lock \
|
|
| 46 |
LICENSE README.md NVIDIA_PIPECAT.md \
|
| 47 |
./
|
| 48 |
COPY --chown=user src/ ./src/
|
| 49 |
-
COPY --chown=user examples
|
| 50 |
|
| 51 |
# Copy built UI into example directory so FastAPI can serve it
|
| 52 |
-
COPY --from=ui-builder --chown=user /ui/dist /app/examples/
|
| 53 |
|
| 54 |
# Example app directory
|
| 55 |
-
WORKDIR /app/examples
|
| 56 |
|
| 57 |
# Dependencies
|
| 58 |
RUN uv sync --frozen
|
|
@@ -64,7 +72,7 @@ RUN chmod +x start.sh
|
|
| 64 |
# Fix ownership so runtime user can read caches and virtualenv
|
| 65 |
RUN mkdir -p /home/user/.cache/uv \
|
| 66 |
&& chown -R 1000:1000 /home/user/.cache \
|
| 67 |
-
&& if [ -d /app/examples
|
| 68 |
|
| 69 |
# Port configuration (single external port for app)
|
| 70 |
EXPOSE 7860
|
|
@@ -72,7 +80,7 @@ EXPOSE 7860
|
|
| 72 |
# Healthcheck
|
| 73 |
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s CMD curl -f http://localhost:7860/get_prompt || exit 1
|
| 74 |
|
| 75 |
-
# Start command
|
| 76 |
-
CMD
|
| 77 |
|
| 78 |
|
|
|
|
| 1 |
+
# Build argument to specify which example to use
|
| 2 |
+
ARG EXAMPLE_NAME=voice_agent_webrtc_langgraph
|
| 3 |
+
|
| 4 |
# Build UI assets
|
| 5 |
FROM node:18-alpine AS ui-builder
|
| 6 |
+
ARG EXAMPLE_NAME
|
| 7 |
|
| 8 |
WORKDIR /ui
|
| 9 |
# Install UI dependencies
|
| 10 |
+
COPY examples/${EXAMPLE_NAME}/ui/package*.json ./
|
| 11 |
RUN npm ci --no-audit --no-fund && npm cache clean --force
|
| 12 |
# Build UI
|
| 13 |
+
COPY examples/${EXAMPLE_NAME}/ui/ .
|
| 14 |
RUN npm run build
|
| 15 |
|
| 16 |
# Base image
|
| 17 |
FROM python:3.12-slim
|
| 18 |
|
| 19 |
+
# Build argument needs to be repeated in this stage
|
| 20 |
+
ARG EXAMPLE_NAME=voice_agent_webrtc_langgraph
|
| 21 |
+
|
| 22 |
# Environment setup
|
| 23 |
ENV PYTHONUNBUFFERED=1
|
| 24 |
ENV UV_NO_TRACKED_CACHE=1
|
| 25 |
+
ENV EXAMPLE_NAME=${EXAMPLE_NAME}
|
| 26 |
|
| 27 |
# System dependencies
|
| 28 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
|
| 54 |
LICENSE README.md NVIDIA_PIPECAT.md \
|
| 55 |
./
|
| 56 |
COPY --chown=user src/ ./src/
|
| 57 |
+
COPY --chown=user examples/${EXAMPLE_NAME} ./examples/${EXAMPLE_NAME}
|
| 58 |
|
| 59 |
# Copy built UI into example directory so FastAPI can serve it
|
| 60 |
+
COPY --from=ui-builder --chown=user /ui/dist /app/examples/${EXAMPLE_NAME}/ui/dist
|
| 61 |
|
| 62 |
# Example app directory
|
| 63 |
+
WORKDIR /app/examples/${EXAMPLE_NAME}
|
| 64 |
|
| 65 |
# Dependencies
|
| 66 |
RUN uv sync --frozen
|
|
|
|
| 72 |
# Fix ownership so runtime user can read caches and virtualenv
|
| 73 |
RUN mkdir -p /home/user/.cache/uv \
|
| 74 |
&& chown -R 1000:1000 /home/user/.cache \
|
| 75 |
+
&& if [ -d /app/examples/${EXAMPLE_NAME}/.venv ]; then chown -R 1000:1000 /app/examples/${EXAMPLE_NAME}/.venv; fi
|
| 76 |
|
| 77 |
# Port configuration (single external port for app)
|
| 78 |
EXPOSE 7860
|
|
|
|
| 80 |
# Healthcheck
|
| 81 |
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s CMD curl -f http://localhost:7860/get_prompt || exit 1
|
| 82 |
|
| 83 |
+
# Start command (using sh to expand EXAMPLE_NAME variable)
|
| 84 |
+
CMD sh -c "/app/examples/${EXAMPLE_NAME}/start.sh"
|
| 85 |
|
| 86 |
|
README.md
CHANGED
|
@@ -35,7 +35,7 @@ Optional but useful:
|
|
| 35 |
- Starts the Pipecat pipeline (`pipeline.py`) exposing:
|
| 36 |
- HTTP: `http://<host>:7860` (health, RTC config)
|
| 37 |
- WebSocket: `ws://<host>:7860/ws` (audio + transcripts)
|
| 38 |
-
-
|
| 39 |
|
| 40 |
Defaults:
|
| 41 |
- ASR: NVIDIA Riva (NIM) via `RIVA_API_KEY` and built-in `NVIDIA_ASR_FUNCTION_ID`
|
|
@@ -52,9 +52,32 @@ From `examples/voice_agent_webrtc_langgraph/`:
|
|
| 52 |
docker compose up --build -d
|
| 53 |
```
|
| 54 |
|
| 55 |
-
Then open `http://<machine-ip>:
|
| 56 |
|
| 57 |
-
Chrome on http origins: enable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
### Option B: Python (local)
|
| 60 |
Requires Python 3.12 and `uv`.
|
|
@@ -112,6 +135,54 @@ Notes for Magpie Zero‑shot:
|
|
| 112 |
|
| 113 |
## 5) Troubleshooting
|
| 114 |
- Healthcheck: `curl -f http://localhost:7860/get_prompt`
|
| 115 |
-
- If the UI can
|
| 116 |
- For NAT/firewall issues, configure TURN or provide Twilio credentials.
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
- Starts the Pipecat pipeline (`pipeline.py`) exposing:
|
| 36 |
- HTTP: `http://<host>:7860` (health, RTC config)
|
| 37 |
- WebSocket: `ws://<host>:7860/ws` (audio + transcripts)
|
| 38 |
+
- Static UI: `http://<host>:7860/` (served by FastAPI)
|
| 39 |
|
| 40 |
Defaults:
|
| 41 |
- ASR: NVIDIA Riva (NIM) via `RIVA_API_KEY` and built-in `NVIDIA_ASR_FUNCTION_ID`
|
|
|
|
| 52 |
docker compose up --build -d
|
| 53 |
```
|
| 54 |
|
| 55 |
+
Then open `http://<machine-ip>:7860/`.
|
| 56 |
|
| 57 |
+
Chrome on http origins: enable "Insecure origins treated as secure" at `chrome://flags/` and add `http://<machine-ip>:7860`.
|
| 58 |
+
|
| 59 |
+
#### Building for Different Examples
|
| 60 |
+
The Dockerfile in the repository root is generalized to work with any example. Use the `EXAMPLE_NAME` build argument to specify which example to use:
|
| 61 |
+
|
| 62 |
+
**For voice_agent_webrtc_langgraph (default):**
|
| 63 |
+
```bash
|
| 64 |
+
docker build --build-arg EXAMPLE_NAME=voice_agent_webrtc_langgraph -t my-voice-agent .
|
| 65 |
+
docker run -p 7860:7860 --env-file examples/voice_agent_webrtc_langgraph/.env my-voice-agent
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
**For voice_agent_multi_thread:**
|
| 69 |
+
```bash
|
| 70 |
+
docker build --build-arg EXAMPLE_NAME=voice_agent_multi_thread -t my-voice-agent .
|
| 71 |
+
docker run -p 7860:7860 --env-file examples/voice_agent_multi_thread/.env my-voice-agent
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
The Dockerfile will automatically:
|
| 75 |
+
- Build the UI for the specified example
|
| 76 |
+
- Copy only the files for that example
|
| 77 |
+
- Set up the correct working directory
|
| 78 |
+
- Configure the start script to run the correct example
|
| 79 |
+
|
| 80 |
+
**Note:** The UI is served on the same port as the API (7860). The FastAPI app serves both the WebSocket/HTTP endpoints and the static UI files.
|
| 81 |
|
| 82 |
### Option B: Python (local)
|
| 83 |
Requires Python 3.12 and `uv`.
|
|
|
|
| 135 |
|
| 136 |
## 5) Troubleshooting
|
| 137 |
- Healthcheck: `curl -f http://localhost:7860/get_prompt`
|
| 138 |
+
- If the UI can't access the mic on http, use the Chrome flag above or host the UI via HTTPS.
|
| 139 |
- For NAT/firewall issues, configure TURN or provide Twilio credentials.
|
| 140 |
|
| 141 |
+
|
| 142 |
+
## 6) Multi-threaded Voice Agent (voice_agent_multi_thread)
|
| 143 |
+
|
| 144 |
+
The `voice_agent_multi_thread` example includes a non-blocking multi-threaded agent implementation that allows users to continue conversing while long-running operations execute in the background.
|
| 145 |
+
|
| 146 |
+
### Build the Docker image:
|
| 147 |
+
```bash
|
| 148 |
+
docker build -t voice-agent-multi-thread .
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
### Run the container:
|
| 152 |
+
```bash
|
| 153 |
+
docker run -d --name voice-agent-multi-thread \
|
| 154 |
+
-p 2024:2024 \
|
| 155 |
+
-p 7862:7860 \
|
| 156 |
+
--env-file examples/voice_agent_multi_thread/.env \
|
| 157 |
+
voice-agent-multi-thread
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
Then access:
|
| 161 |
+
- **LangGraph API**: `http://localhost:2024`
|
| 162 |
+
- **Web UI**: `http://localhost:7862`
|
| 163 |
+
- **Pipeline WebSocket**: `ws://localhost:7862/ws`
|
| 164 |
+
|
| 165 |
+
The multi-threaded agent automatically enables for `telco-agent` and `wire-transfer-agent`, allowing the secondary thread to handle status checks and interim conversations while the main thread processes long-running tools.
|
| 166 |
+
|
| 167 |
+
### Stop and remove the container:
|
| 168 |
+
```bash
|
| 169 |
+
docker stop voice-agent-multi-thread && docker rm voice-agent-multi-thread
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
## 7) Manual Docker Commands (voice_agent_webrtc_langgraph)
|
| 174 |
+
|
| 175 |
+
If you prefer manual Docker commands instead of docker-compose:
|
| 176 |
+
|
| 177 |
+
```bash
|
| 178 |
+
docker build -t ace-voice-webrtc:latest \
|
| 179 |
+
-f examples/voice_agent_webrtc_langgraph/Dockerfile \
|
| 180 |
+
.
|
| 181 |
+
|
| 182 |
+
docker run --name ace-voice-webrtc -d \
|
| 183 |
+
-p 7860:7860 \
|
| 184 |
+
-p 2024:2024 \
|
| 185 |
+
--env-file examples/voice_agent_webrtc_langgraph/.env \
|
| 186 |
+
-e LANGGRAPH_ASSISTANT=healthcare-agent \
|
| 187 |
+
ace-voice-webrtc:latest
|
| 188 |
+
```
|
examples/voice_agent_multi_thread/DOCKER_DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker Deployment - Multi-Threaded Voice Agent
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
This Docker container runs the complete multi-threaded telco voice agent stack:
|
| 6 |
+
- **LangGraph Server** (`langgraph dev`) on port 2024
|
| 7 |
+
- **Pipecat Pipeline** (FastAPI + WebRTC) on port 7860
|
| 8 |
+
- **React UI** served at `http://localhost:7860`
|
| 9 |
+
|
| 10 |
+
## Quick Start
|
| 11 |
+
|
| 12 |
+
### Build the Image
|
| 13 |
+
|
| 14 |
+
```bash
|
| 15 |
+
# From project root
|
| 16 |
+
docker build -t voice-agent-multi-thread .
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
### Run the Container
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
docker run -p 7860:7860 \
|
| 23 |
+
-e RIVA_API_KEY=your_nvidia_api_key \
|
| 24 |
+
-e NVIDIA_ASR_FUNCTION_ID=52b117d2-6c15-4cfa-a905-a67013bee409 \
|
| 25 |
+
-e NVIDIA_TTS_FUNCTION_ID=4e813649-d5e4-4020-b2be-2b918396d19d \
|
| 26 |
+
voice-agent-multi-thread
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
### Access the UI
|
| 30 |
+
|
| 31 |
+
Open your browser to: **http://localhost:7860**
|
| 32 |
+
|
| 33 |
+
## What Happens Inside the Container
|
| 34 |
+
|
| 35 |
+
The `start.sh` script orchestrates two processes:
|
| 36 |
+
|
| 37 |
+
### 1. LangGraph Server (Port 2024)
|
| 38 |
+
```bash
|
| 39 |
+
cd /app/examples/voice_agent_multi_thread/agents
|
| 40 |
+
uv run langgraph dev --no-browser --host 0.0.0.0 --port 2024
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
This runs the multi-threaded telco agent with:
|
| 44 |
+
- Main thread for long operations
|
| 45 |
+
- Secondary thread for interim queries
|
| 46 |
+
- Store-based coordination
|
| 47 |
+
|
| 48 |
+
### 2. Pipecat Pipeline (Port 7860)
|
| 49 |
+
```bash
|
| 50 |
+
cd /app/examples/voice_agent_multi_thread
|
| 51 |
+
uv run pipeline.py
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
This runs the voice pipeline with:
|
| 55 |
+
- WebRTC transport
|
| 56 |
+
- RIVA ASR (speech-to-text)
|
| 57 |
+
- LangGraphLLMService (multi-threaded routing)
|
| 58 |
+
- RIVA TTS (text-to-speech)
|
| 59 |
+
- React UI
|
| 60 |
+
|
| 61 |
+
## Environment Variables
|
| 62 |
+
|
| 63 |
+
### Required
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
# NVIDIA API Key for RIVA services
|
| 67 |
+
RIVA_API_KEY=nvapi-xxxxx
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Optional
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
# LangGraph Configuration
|
| 74 |
+
LANGGRAPH_HOST=0.0.0.0
|
| 75 |
+
LANGGRAPH_PORT=2024
|
| 76 |
+
LANGGRAPH_ASSISTANT=telco-agent
|
| 77 |
+
|
| 78 |
+
# User Configuration
|
| 79 |
+
USER_EMAIL=user@example.com
|
| 80 |
+
|
| 81 |
+
# ASR Configuration
|
| 82 |
+
NVIDIA_ASR_FUNCTION_ID=52b117d2-6c15-4cfa-a905-a67013bee409
|
| 83 |
+
RIVA_ASR_LANGUAGE=en-US
|
| 84 |
+
RIVA_ASR_MODEL=parakeet-1.1b-en-US-asr-streaming-silero-vad-asr-bls-ensemble
|
| 85 |
+
|
| 86 |
+
# TTS Configuration
|
| 87 |
+
NVIDIA_TTS_FUNCTION_ID=4e813649-d5e4-4020-b2be-2b918396d19d
|
| 88 |
+
RIVA_TTS_VOICE_ID=Magpie-ZeroShot.Female-1
|
| 89 |
+
RIVA_TTS_MODEL=magpie_tts_ensemble-Magpie-ZeroShot
|
| 90 |
+
RIVA_TTS_LANGUAGE=en-US
|
| 91 |
+
|
| 92 |
+
# Zero-shot audio prompt (optional)
|
| 93 |
+
ZERO_SHOT_AUDIO_PROMPT_URL=https://github.com/your-repo/audio-prompt.wav
|
| 94 |
+
|
| 95 |
+
# Multi-threading (default: true)
|
| 96 |
+
ENABLE_MULTI_THREADING=true
|
| 97 |
+
|
| 98 |
+
# Debug
|
| 99 |
+
LANGGRAPH_DEBUG_STREAM=false
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## Docker Compose
|
| 103 |
+
|
| 104 |
+
Create `docker-compose.yml`:
|
| 105 |
+
|
| 106 |
+
```yaml
|
| 107 |
+
version: '3.8'
|
| 108 |
+
|
| 109 |
+
services:
|
| 110 |
+
voice-agent:
|
| 111 |
+
build: .
|
| 112 |
+
ports:
|
| 113 |
+
- "7860:7860"
|
| 114 |
+
environment:
|
| 115 |
+
- RIVA_API_KEY=${RIVA_API_KEY}
|
| 116 |
+
- NVIDIA_ASR_FUNCTION_ID=52b117d2-6c15-4cfa-a905-a67013bee409
|
| 117 |
+
- NVIDIA_TTS_FUNCTION_ID=4e813649-d5e4-4020-b2be-2b918396d19d
|
| 118 |
+
- USER_EMAIL=user@example.com
|
| 119 |
+
- LANGGRAPH_ASSISTANT=telco-agent
|
| 120 |
+
- ENABLE_MULTI_THREADING=true
|
| 121 |
+
volumes:
|
| 122 |
+
# Optional: mount .env file
|
| 123 |
+
- ./examples/voice_agent_multi_thread/.env:/app/examples/voice_agent_multi_thread/.env:ro
|
| 124 |
+
# Optional: persist audio recordings
|
| 125 |
+
- ./audio_dumps:/app/examples/voice_agent_multi_thread/audio_dumps
|
| 126 |
+
healthcheck:
|
| 127 |
+
test: ["CMD", "curl", "-f", "http://localhost:7860/get_prompt"]
|
| 128 |
+
interval: 30s
|
| 129 |
+
timeout: 10s
|
| 130 |
+
retries: 3
|
| 131 |
+
start_period: 60s
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
Run with:
|
| 135 |
+
```bash
|
| 136 |
+
docker-compose up
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
## Using .env File
|
| 140 |
+
|
| 141 |
+
Create `.env` in `examples/voice_agent_multi_thread/`:
|
| 142 |
+
|
| 143 |
+
```bash
|
| 144 |
+
# NVIDIA API Keys
|
| 145 |
+
RIVA_API_KEY=nvapi-xxxxx
|
| 146 |
+
|
| 147 |
+
# LangGraph
|
| 148 |
+
LANGGRAPH_ASSISTANT=telco-agent
|
| 149 |
+
LANGGRAPH_BASE_URL=http://127.0.0.1:2024
|
| 150 |
+
|
| 151 |
+
# User
|
| 152 |
+
USER_EMAIL=test@example.com
|
| 153 |
+
|
| 154 |
+
# ASR
|
| 155 |
+
NVIDIA_ASR_FUNCTION_ID=52b117d2-6c15-4cfa-a905-a67013bee409
|
| 156 |
+
|
| 157 |
+
# TTS
|
| 158 |
+
NVIDIA_TTS_FUNCTION_ID=4e813649-d5e4-4020-b2be-2b918396d19d
|
| 159 |
+
RIVA_TTS_VOICE_ID=Magpie-ZeroShot.Female-1
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
The `start.sh` script automatically loads this file.
|
| 163 |
+
|
| 164 |
+
## Ports
|
| 165 |
+
|
| 166 |
+
| Service | Internal Port | External Port | Purpose |
|
| 167 |
+
|---------|---------------|---------------|---------|
|
| 168 |
+
| LangGraph Server | 2024 | - | Agent runtime (internal only) |
|
| 169 |
+
| Pipecat Pipeline | 7860 | 7860 | WebRTC + HTTP API |
|
| 170 |
+
| React UI | - | 7860 | Served by pipeline |
|
| 171 |
+
|
| 172 |
+
**Note**: Only port 7860 is exposed externally. LangGraph runs internally on 2024.
|
| 173 |
+
|
| 174 |
+
## Healthcheck
|
| 175 |
+
|
| 176 |
+
The container includes a healthcheck that verifies the pipeline is responding:
|
| 177 |
+
|
| 178 |
+
```bash
|
| 179 |
+
curl -f http://localhost:7860/get_prompt
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
Check health status:
|
| 183 |
+
```bash
|
| 184 |
+
docker ps
|
| 185 |
+
# Look for "(healthy)" in STATUS column
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
## Logs
|
| 189 |
+
|
| 190 |
+
View all logs:
|
| 191 |
+
```bash
|
| 192 |
+
docker logs -f <container-id>
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
You'll see both:
|
| 196 |
+
- LangGraph server startup and agent logs
|
| 197 |
+
- Pipeline startup and WebRTC connection logs
|
| 198 |
+
|
| 199 |
+
## Testing Multi-Threading
|
| 200 |
+
|
| 201 |
+
1. **Open UI**: http://localhost:7860
|
| 202 |
+
2. **Select Agent**: Choose "Telco Agent"
|
| 203 |
+
3. **Test Long Operation**:
|
| 204 |
+
- Say: *"Close my contract"*
|
| 205 |
+
- Confirm: *"Yes"*
|
| 206 |
+
- Operation starts (50 seconds)
|
| 207 |
+
4. **Test Secondary Thread**:
|
| 208 |
+
- While waiting, say: *"What's the status?"*
|
| 209 |
+
- Agent responds with progress
|
| 210 |
+
- Say: *"How much data do I have left?"*
|
| 211 |
+
- Agent answers while main operation continues
|
| 212 |
+
|
| 213 |
+
## Troubleshooting
|
| 214 |
+
|
| 215 |
+
### Container won't start
|
| 216 |
+
```bash
|
| 217 |
+
# Check logs
|
| 218 |
+
docker logs <container-id>
|
| 219 |
+
|
| 220 |
+
# Common issues:
|
| 221 |
+
# 1. Missing RIVA_API_KEY
|
| 222 |
+
# 2. Port 7860 already in use
|
| 223 |
+
# 3. Insufficient memory
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
### LangGraph not starting
|
| 227 |
+
```bash
|
| 228 |
+
# Check if agents directory exists
|
| 229 |
+
docker exec <container-id> ls -la /app/examples/voice_agent_multi_thread/agents
|
| 230 |
+
|
| 231 |
+
# Check langgraph.json
|
| 232 |
+
docker exec <container-id> cat /app/examples/voice_agent_multi_thread/agents/langgraph.json
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
### Pipeline not responding
|
| 236 |
+
```bash
|
| 237 |
+
# Check pipeline logs
|
| 238 |
+
docker logs <container-id> 2>&1 | grep pipeline
|
| 239 |
+
|
| 240 |
+
# Check if port is accessible
|
| 241 |
+
curl http://localhost:7860/get_prompt
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
### Multi-threading not working
|
| 245 |
+
```bash
|
| 246 |
+
# Verify env var
|
| 247 |
+
docker exec <container-id> env | grep MULTI_THREADING
|
| 248 |
+
|
| 249 |
+
# Check LangGraph server
|
| 250 |
+
docker exec <container-id> curl http://localhost:2024/assistants
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
## Development Mode
|
| 254 |
+
|
| 255 |
+
To develop inside the container:
|
| 256 |
+
|
| 257 |
+
```bash
|
| 258 |
+
# Run with shell
|
| 259 |
+
docker run -it -p 7860:7860 \
|
| 260 |
+
-v $(pwd)/examples/voice_agent_multi_thread:/app/examples/voice_agent_multi_thread \
|
| 261 |
+
voice-agent-multi-thread /bin/bash
|
| 262 |
+
|
| 263 |
+
# Inside container:
|
| 264 |
+
cd /app/examples/voice_agent_multi_thread
|
| 265 |
+
|
| 266 |
+
# Start services manually
|
| 267 |
+
cd agents && uv run langgraph dev &
|
| 268 |
+
cd .. && uv run pipeline.py
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
## Building for Production
|
| 272 |
+
|
| 273 |
+
### Multi-stage optimization
|
| 274 |
+
The Dockerfile uses a multi-stage build:
|
| 275 |
+
1. **ui-builder**: Compiles React UI
|
| 276 |
+
2. **python base**: Installs Python dependencies
|
| 277 |
+
3. **Final image**: ~2GB (UI + Python + agents)
|
| 278 |
+
|
| 279 |
+
### Reducing image size
|
| 280 |
+
```dockerfile
|
| 281 |
+
# Use slim Python base (already done)
|
| 282 |
+
FROM python:3.12-slim
|
| 283 |
+
|
| 284 |
+
# Clean up build artifacts (already done)
|
| 285 |
+
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
| 286 |
+
|
| 287 |
+
# Use uv for faster installs (already done)
|
| 288 |
+
RUN pip install uv
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
## Security Considerations
|
| 292 |
+
|
| 293 |
+
1. **Non-root user**: Container runs as UID 1000
|
| 294 |
+
2. **No secrets in image**: Use environment variables or mount secrets
|
| 295 |
+
3. **Read-only filesystem**: UI dist is built at image time
|
| 296 |
+
4. **Health checks**: Automatic restart on failure
|
| 297 |
+
|
| 298 |
+
## Performance
|
| 299 |
+
|
| 300 |
+
- **Startup time**: ~30-60 seconds
|
| 301 |
+
- **Memory**: ~2GB recommended
|
| 302 |
+
- **CPU**: 2 cores minimum
|
| 303 |
+
- **Storage**: ~3GB for image + runtime
|
| 304 |
+
|
| 305 |
+
## Related Files
|
| 306 |
+
|
| 307 |
+
- `Dockerfile` - Container definition
|
| 308 |
+
- `start.sh` - Startup orchestration
|
| 309 |
+
- `agents/langgraph.json` - Agent configuration
|
| 310 |
+
- `pipeline.py` - Pipecat pipeline
|
| 311 |
+
- `langgraph_llm_service.py` - Multi-threaded LLM service
|
| 312 |
+
|
| 313 |
+
## Support
|
| 314 |
+
|
| 315 |
+
For issues:
|
| 316 |
+
1. Check logs: `docker logs <container-id>`
|
| 317 |
+
2. Verify environment variables
|
| 318 |
+
3. Test components individually (LangGraph, Pipeline)
|
| 319 |
+
4. Review `PIPECAT_MULTI_THREADING.md` for architecture details
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
|
examples/voice_agent_multi_thread/Dockerfile
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: BSD 2-Clause License
|
| 3 |
+
|
| 4 |
+
# Build UI assets
|
| 5 |
+
FROM node:18-alpine AS ui-builder
|
| 6 |
+
|
| 7 |
+
WORKDIR /ui
|
| 8 |
+
# Install UI dependencies
|
| 9 |
+
COPY examples/voice_agent_webrtc_langgraph/ui/package*.json ./
|
| 10 |
+
RUN npm ci --no-audit --no-fund && npm cache clean --force
|
| 11 |
+
# Build UI
|
| 12 |
+
COPY examples/voice_agent_webrtc_langgraph/ui/ .
|
| 13 |
+
RUN npm run build
|
| 14 |
+
|
| 15 |
+
# Base image
|
| 16 |
+
FROM python:3.12-slim
|
| 17 |
+
|
| 18 |
+
# Image metadata
|
| 19 |
+
LABEL maintainer="NVIDIA"
|
| 20 |
+
LABEL description="Voice Agent WebRTC using Langgraph"
|
| 21 |
+
LABEL version="1.0"
|
| 22 |
+
|
| 23 |
+
# Environment setup
|
| 24 |
+
ENV PYTHONUNBUFFERED=1
|
| 25 |
+
|
| 26 |
+
# System dependencies
|
| 27 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 28 |
+
libgl1 \
|
| 29 |
+
libglx-mesa0 \
|
| 30 |
+
curl \
|
| 31 |
+
ffmpeg \
|
| 32 |
+
git \
|
| 33 |
+
net-tools \
|
| 34 |
+
procps \
|
| 35 |
+
vim \
|
| 36 |
+
&& apt-get clean \
|
| 37 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 38 |
+
&& pip install --no-cache-dir --upgrade pip uv
|
| 39 |
+
|
| 40 |
+
# App directory setup
|
| 41 |
+
WORKDIR /app
|
| 42 |
+
|
| 43 |
+
# App files
|
| 44 |
+
COPY pyproject.toml uv.lock \
|
| 45 |
+
LICENSE README.md NVIDIA_PIPECAT.md \
|
| 46 |
+
./
|
| 47 |
+
COPY src/ ./src/
|
| 48 |
+
COPY examples/voice_agent_webrtc_langgraph/ ./examples/voice_agent_webrtc_langgraph/
|
| 49 |
+
|
| 50 |
+
# Copy built UI into example directory
|
| 51 |
+
COPY --from=ui-builder /ui/dist /app/examples/voice_agent_webrtc_langgraph/ui/dist
|
| 52 |
+
|
| 53 |
+
# Example app directory
|
| 54 |
+
WORKDIR /app/examples/voice_agent_webrtc_langgraph
|
| 55 |
+
|
| 56 |
+
# Dependencies
|
| 57 |
+
RUN uv sync --frozen
|
| 58 |
+
# RUN uv sync
|
| 59 |
+
# Install all agent requirements recursively into the project's virtual environment
|
| 60 |
+
# RUN if [ -d "agents" ]; then \
|
| 61 |
+
# find agents -type f -name "requirements.txt" -print0 | xargs -0 -I {} uv pip install -r "{}"; \
|
| 62 |
+
# fi
|
| 63 |
+
|
| 64 |
+
RUN uv pip install -r agents/requirements.txt
|
| 65 |
+
# Ensure langgraph CLI is available at build time
|
| 66 |
+
RUN uv pip install -U langgraph
|
| 67 |
+
RUN chmod +x start.sh
|
| 68 |
+
|
| 69 |
+
# Port configuration
|
| 70 |
+
EXPOSE 7860
|
| 71 |
+
EXPOSE 9000
|
| 72 |
+
EXPOSE 2024
|
| 73 |
+
|
| 74 |
+
# Healthcheck similar to docker-compose
|
| 75 |
+
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s CMD curl -f http://localhost:7860/get_prompt || exit 1
|
| 76 |
+
|
| 77 |
+
# Start command
|
| 78 |
+
CMD ["/app/examples/voice_agent_webrtc_langgraph/start.sh"]
|
examples/voice_agent_multi_thread/PIPECAT_MULTI_THREADING.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pipecat Multi-Threading Integration
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
This document explains how the multi-threaded telco agent is integrated with the Pipecat voice pipeline using WebRTC.
|
| 6 |
+
|
| 7 |
+
## Architecture
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
┌─────────────────────────────────────────────────────────┐
|
| 11 |
+
│ Browser (WebRTC) │
|
| 12 |
+
└────────────────────┬────────────────────────────────────┘
|
| 13 |
+
│
|
| 14 |
+
↓ Audio Stream
|
| 15 |
+
┌─────────────────────────────────────────────────────────┐
|
| 16 |
+
│ pipeline.py (FastAPI + Pipecat) │
|
| 17 |
+
│ │
|
| 18 |
+
│ ┌─────────────────────────────────────────────────┐ │
|
| 19 |
+
│ │ Pipeline: │ │
|
| 20 |
+
│ │ WebRTC → ASR → LangGraphLLMService → TTS → │ │
|
| 21 |
+
│ └─────────────────────────────────────────────────┘ │
|
| 22 |
+
│ ↓ │
|
| 23 |
+
│ langgraph_llm_service.py │
|
| 24 |
+
│ ↓ │
|
| 25 |
+
└────────────────────┬───────────────────────────────────┘
|
| 26 |
+
│
|
| 27 |
+
↓ HTTP/WebSocket
|
| 28 |
+
┌─────────────────────────────────────────────────────────┐
|
| 29 |
+
│ LangGraph Server (langgraph dev) │
|
| 30 |
+
│ │
|
| 31 |
+
│ ┌──────────────────────────────────────────────────┐ │
|
| 32 |
+
│ │ react_agent.py (Multi-threaded) │ │
|
| 33 |
+
│ │ │ │
|
| 34 |
+
│ │ Main Thread: Handles long operations │ │
|
| 35 |
+
│ │ Secondary Thread: Handles interim queries │ │
|
| 36 |
+
│ │ │ │
|
| 37 |
+
│ │ Store: Coordinates between threads │ │
|
| 38 |
+
│ └──────────────────────────────────────────────────┘ │
|
| 39 |
+
└─────────────────────────────────────────────────────────┘
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## How It Works
|
| 43 |
+
|
| 44 |
+
### 1. **LangGraphLLMService** (`langgraph_llm_service.py`)
|
| 45 |
+
|
| 46 |
+
This service acts as a bridge between Pipecat's frame-based processing and LangGraph's agent.
|
| 47 |
+
|
| 48 |
+
#### Key Changes:
|
| 49 |
+
|
| 50 |
+
**a) Dual Thread Management:**
|
| 51 |
+
```python
|
| 52 |
+
self._thread_id_main: Optional[str] = None # For long operations
|
| 53 |
+
self._thread_id_secondary: Optional[str] = None # For interim queries
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
**b) Operation Status Checking:**
|
| 57 |
+
```python
|
| 58 |
+
async def _check_long_operation_running(self) -> bool:
|
| 59 |
+
"""Check if a long operation is currently running via the store."""
|
| 60 |
+
# Queries LangGraph store for "running" status
|
| 61 |
+
# Returns True if a long operation is in progress
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
**c) Automatic Routing:**
|
| 65 |
+
```python
|
| 66 |
+
# Before each message, check if long operation is running
|
| 67 |
+
long_operation_running = await self._check_long_operation_running()
|
| 68 |
+
|
| 69 |
+
if long_operation_running:
|
| 70 |
+
thread_type = "secondary" # Route to secondary thread
|
| 71 |
+
else:
|
| 72 |
+
thread_type = "main" # Route to main thread
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
**d) Input Format:**
|
| 76 |
+
```python
|
| 77 |
+
# New multi-threaded format
|
| 78 |
+
input_payload = {
|
| 79 |
+
"messages": [{"type": "human", "content": text}],
|
| 80 |
+
"thread_type": "main" or "secondary",
|
| 81 |
+
"interim_messages_reset": bool,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
# Config includes namespace for coordination
|
| 85 |
+
config = {
|
| 86 |
+
"configurable": {
|
| 87 |
+
"user_email": self.user_email,
|
| 88 |
+
"thread_id": thread_id,
|
| 89 |
+
"namespace_for_memory": ["user@example.com", "tools_updates"],
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### 2. **Pipeline Configuration** (`pipeline.py`)
|
| 95 |
+
|
| 96 |
+
```python
|
| 97 |
+
# Enable multi-threading for specific assistants
|
| 98 |
+
enable_multi_threading = selected_assistant in ["telco-agent", "wire-transfer-agent"]
|
| 99 |
+
|
| 100 |
+
llm = LangGraphLLMService(
|
| 101 |
+
base_url=os.getenv("LANGGRAPH_BASE_URL", "http://127.0.0.1:2024"),
|
| 102 |
+
assistant=selected_assistant,
|
| 103 |
+
enable_multi_threading=enable_multi_threading, # NEW
|
| 104 |
+
)
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### 3. **React Agent** (`react_agent.py`)
|
| 108 |
+
|
| 109 |
+
Already updated to handle multi-threaded input format (see `MULTI_THREAD_README.md`).
|
| 110 |
+
|
| 111 |
+
## Flow Example
|
| 112 |
+
|
| 113 |
+
### User says: "Close my contract"
|
| 114 |
+
|
| 115 |
+
```
|
| 116 |
+
1. Browser (WebRTC) → Pipecat Pipeline
|
| 117 |
+
2. ASR converts to text: "Close my contract"
|
| 118 |
+
3. LangGraphLLMService receives text
|
| 119 |
+
4. Service checks store: No long operation running
|
| 120 |
+
5. Service sends to main thread:
|
| 121 |
+
{
|
| 122 |
+
"messages": [{"type": "human", "content": "Close my contract"}],
|
| 123 |
+
"thread_type": "main",
|
| 124 |
+
"interim_messages_reset": True
|
| 125 |
+
}
|
| 126 |
+
6. Agent starts 50-second contract closure
|
| 127 |
+
7. Agent writes status to store: {"status": "running", "progress": 10}
|
| 128 |
+
8. TTS speaks: "Processing your contract closure..."
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
### User says (5 seconds later): "What's the status?"
|
| 132 |
+
|
| 133 |
+
```
|
| 134 |
+
1. Browser (WebRTC) → Pipecat Pipeline
|
| 135 |
+
2. ASR converts to text: "What's the status?"
|
| 136 |
+
3. LangGraphLLMService receives text
|
| 137 |
+
4. Service checks store: Long operation IS running ✓
|
| 138 |
+
5. Service sends to secondary thread:
|
| 139 |
+
{
|
| 140 |
+
"messages": [{"type": "human", "content": "What's the status?"}],
|
| 141 |
+
"thread_type": "secondary",
|
| 142 |
+
"interim_messages_reset": False
|
| 143 |
+
}
|
| 144 |
+
6. Secondary thread checks status tool
|
| 145 |
+
7. Agent responds: "Your request is 20% complete"
|
| 146 |
+
8. TTS speaks response
|
| 147 |
+
9. Main thread continues running in background
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### Main operation completes (50 seconds later)
|
| 151 |
+
|
| 152 |
+
```
|
| 153 |
+
1. Main thread finishes contract closure
|
| 154 |
+
2. Agent synthesizes: result + interim conversation
|
| 155 |
+
3. Agent sets completion flag in store
|
| 156 |
+
4. TTS speaks: "Your contract has been closed..."
|
| 157 |
+
5. Service detects completion on next message
|
| 158 |
+
6. Routes future messages to main thread
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
## Configuration
|
| 162 |
+
|
| 163 |
+
### Environment Variables
|
| 164 |
+
|
| 165 |
+
```bash
|
| 166 |
+
# LangGraph Server
|
| 167 |
+
LANGGRAPH_BASE_URL=http://127.0.0.1:2024
|
| 168 |
+
LANGGRAPH_ASSISTANT=telco-agent
|
| 169 |
+
|
| 170 |
+
# User identification (for namespace)
|
| 171 |
+
USER_EMAIL=test@example.com
|
| 172 |
+
|
| 173 |
+
# Enable debug logging
|
| 174 |
+
LANGGRAPH_DEBUG_STREAM=true
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
### Enable/Disable Multi-Threading
|
| 178 |
+
|
| 179 |
+
**For specific agents:**
|
| 180 |
+
```python
|
| 181 |
+
# In pipeline.py
|
| 182 |
+
enable_multi_threading = selected_assistant in ["telco-agent", "wire-transfer-agent"]
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
**Via environment variable (optional):**
|
| 186 |
+
```python
|
| 187 |
+
enable_multi_threading = os.getenv("ENABLE_MULTI_THREADING", "true").lower() == "true"
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
**Disable for an agent:**
|
| 191 |
+
```python
|
| 192 |
+
llm = LangGraphLLMService(
|
| 193 |
+
assistant="some-other-agent",
|
| 194 |
+
enable_multi_threading=False, # Use simple single-threaded mode
|
| 195 |
+
)
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
## Store Keys Used
|
| 199 |
+
|
| 200 |
+
The service queries these store keys for coordination:
|
| 201 |
+
|
| 202 |
+
| Key | Purpose | Set By |
|
| 203 |
+
|-----|---------|--------|
|
| 204 |
+
| `working-tool-status-update` | Current tool progress | Agent's long-running tools |
|
| 205 |
+
| `main_operation_complete` | Completion signal | Agent's main thread |
|
| 206 |
+
| `secondary_interim_messages` | Interim conversation | Agent's secondary thread |
|
| 207 |
+
|
| 208 |
+
## Backward Compatibility
|
| 209 |
+
|
| 210 |
+
When `enable_multi_threading=False`:
|
| 211 |
+
- Uses single thread
|
| 212 |
+
- Sends simple message format: `[HumanMessage(content=text)]`
|
| 213 |
+
- No store coordination
|
| 214 |
+
- Works with non-multi-threaded agents
|
| 215 |
+
|
| 216 |
+
## Benefits
|
| 217 |
+
|
| 218 |
+
1. **Non-Blocking Voice UX**: User can continue talking during long operations
|
| 219 |
+
2. **Transparent**: User doesn't need to know about threading
|
| 220 |
+
3. **Automatic Routing**: Service handles main/secondary routing automatically
|
| 221 |
+
4. **Store-Based**: No client-side coordination needed
|
| 222 |
+
5. **Backward Compatible**: Existing agents work without changes
|
| 223 |
+
|
| 224 |
+
## Testing
|
| 225 |
+
|
| 226 |
+
### With Web UI
|
| 227 |
+
|
| 228 |
+
1. Start LangGraph server: `langgraph dev`
|
| 229 |
+
2. Start pipeline: `python pipeline.py`
|
| 230 |
+
3. Open browser to `http://localhost:7860`
|
| 231 |
+
4. Select "Telco Agent"
|
| 232 |
+
5. Say: "Close my contract" → Confirm with "yes"
|
| 233 |
+
6. While processing, say: "What's the status?"
|
| 234 |
+
7. Agent should respond with progress while operation continues
|
| 235 |
+
|
| 236 |
+
### With Client Script
|
| 237 |
+
|
| 238 |
+
```bash
|
| 239 |
+
# Terminal 1: Start LangGraph
|
| 240 |
+
cd examples/voice_agent_multi_thread/agents
|
| 241 |
+
langgraph dev
|
| 242 |
+
|
| 243 |
+
# Terminal 2: Test with client
|
| 244 |
+
cd examples/voice_agent_multi_thread/agents
|
| 245 |
+
python telco_client.py --interactive
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
## Troubleshooting
|
| 249 |
+
|
| 250 |
+
### Messages always go to main thread
|
| 251 |
+
- Check that `enable_multi_threading=True`
|
| 252 |
+
- Verify long-running tools are writing status to store
|
| 253 |
+
- Check namespace matches: `("user_email", "tools_updates")`
|
| 254 |
+
|
| 255 |
+
### Secondary thread not responding
|
| 256 |
+
- Ensure secondary thread has limited tool set
|
| 257 |
+
- Check `SECONDARY_SYSTEM_PROMPT` in `react_agent.py`
|
| 258 |
+
- Verify `check_status` tool is included
|
| 259 |
+
|
| 260 |
+
### Synthesis not working
|
| 261 |
+
- Check `secondary_interim_messages` in store
|
| 262 |
+
- Verify meaningful messages filter in agent
|
| 263 |
+
- Check synthesis prompt in agent
|
| 264 |
+
|
| 265 |
+
## Performance
|
| 266 |
+
|
| 267 |
+
- **Store queries**: ~10-20ms per check
|
| 268 |
+
- **Thread switching**: Negligible (routing decision)
|
| 269 |
+
- **Memory overhead**: Two threads vs one
|
| 270 |
+
- **Latency impact**: Minimal (<50ms added per request)
|
| 271 |
+
|
| 272 |
+
## Future Enhancements
|
| 273 |
+
|
| 274 |
+
1. **Session persistence**: Store thread IDs in Redis
|
| 275 |
+
2. **Multiple long operations**: Queue system
|
| 276 |
+
3. **Progress streaming**: Real-time progress updates
|
| 277 |
+
4. **Cancellation**: User can cancel long operations
|
| 278 |
+
5. **Thread pooling**: Reuse secondary threads
|
| 279 |
+
|
| 280 |
+
## Related Files
|
| 281 |
+
|
| 282 |
+
- `langgraph_llm_service.py` - Service implementation
|
| 283 |
+
- `pipeline.py` - Pipeline configuration
|
| 284 |
+
- `react_agent.py` - Multi-threaded agent
|
| 285 |
+
- `tools.py` - Long-running tools with progress reporting
|
| 286 |
+
- `helper_functions.py` - Store coordination utilities
|
| 287 |
+
- `telco_client.py` - CLI test client
|
| 288 |
+
|
| 289 |
+
## Credits
|
| 290 |
+
|
| 291 |
+
Implementation: Option 1 (Tool-Level Designation)
|
| 292 |
+
Date: September 30, 2025
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
|
examples/voice_agent_multi_thread/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Voice Agent WebRTC + LangGraph (Quick Start)
|
| 2 |
+
|
| 3 |
+
This example launches a complete voice agent stack:
|
| 4 |
+
- LangGraph dev server for local agents
|
| 5 |
+
- Pipecat-based speech pipeline (WebRTC, ASR, LLM adapter, TTS)
|
| 6 |
+
- Static UI you can open in a browser
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
## 1) Mandatory environment variables
|
| 10 |
+
Create `.env` next to this README (or copy from `env.example`) and set at least:
|
| 11 |
+
|
| 12 |
+
- `NVIDIA_API_KEY` or `RIVA_API_KEY`: required for NVIDIA NIM-hosted Riva ASR/TTS
|
| 13 |
+
- `USE_LANGGRAPH=true`: enable LangGraph-backed LLM
|
| 14 |
+
- `LANGGRAPH_BASE_URL` (default `http://127.0.0.1:2024`)
|
| 15 |
+
- `LANGGRAPH_ASSISTANT` (default `ace-base-agent`)
|
| 16 |
+
- `USER_EMAIL` (any email for routing, e.g. `test@example.com`)
|
| 17 |
+
- `LANGGRAPH_STREAM_MODE` (default `values`)
|
| 18 |
+
- `LANGGRAPH_DEBUG_STREAM` (default `true`)
|
| 19 |
+
|
| 20 |
+
Optional but commonly used:
|
| 21 |
+
- `RIVA_ASR_LANGUAGE` (default `en-US`)
|
| 22 |
+
- `RIVA_TTS_LANGUAGE` (default `en-US`)
|
| 23 |
+
- `RIVA_TTS_VOICE_ID` (e.g. `Magpie-ZeroShot.Female-1`)
|
| 24 |
+
- `RIVA_TTS_MODEL` (e.g. `magpie_tts_ensemble-Magpie-ZeroShot`)
|
| 25 |
+
- `ZERO_SHOT_AUDIO_PROMPT` if using Magpie Zero‑shot and a custom voice prompt
|
| 26 |
+
- `ZERO_SHOT_AUDIO_PROMPT_URL` to auto-download prompt on startup
|
| 27 |
+
- `ENABLE_SPECULATIVE_SPEECH` (default `true`)
|
| 28 |
+
- TURN/Twilio for WebRTC if needed: `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, or `TURN_SERVER_URL`, `TURN_USERNAME`, `TURN_PASSWORD`
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
## 2) What it does
|
| 32 |
+
- Starts LangGraph dev server to serve local agents from `agents/`.
|
| 33 |
+
- Starts the Pipecat pipeline (`pipeline.py`) exposing:
|
| 34 |
+
- HTTP: `http://<host>:7860` (health and RTC config)
|
| 35 |
+
- WebSocket: `ws://<host>:7860/ws` for audio and transcripts
|
| 36 |
+
- Serves the built UI at `http://<host>:9000/` (via the container).
|
| 37 |
+
|
| 38 |
+
By default it uses:
|
| 39 |
+
- ASR: NVIDIA Riva (NIM) with `RIVA_API_KEY` and `NVIDIA_ASR_FUNCTION_ID`
|
| 40 |
+
- LLM: LangGraph adapter streaming from the selected assistant
|
| 41 |
+
- TTS: NVIDIA Riva Magpie (NIM) with `RIVA_API_KEY` and `NVIDIA_TTS_FUNCTION_ID`
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
## 3) Run
|
| 45 |
+
|
| 46 |
+
### Option A: Docker (recommended)
|
| 47 |
+
From this directory:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
docker compose up --build -d
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
Then open `http://<machine-ip>:9000/`.
|
| 54 |
+
|
| 55 |
+
Chrome on http origins: enable “Insecure origins treated as secure” at `chrome://flags/` and add `http://<machine-ip>:9000`.
|
| 56 |
+
|
| 57 |
+
### Option B: Python (local)
|
| 58 |
+
Requires Python 3.12 and `uv`.
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
uv run pipeline.py
|
| 62 |
+
```
|
| 63 |
+
Then start the UI from `ui/` (see `ui/README.md`).
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
## 4) Swap TTS providers (Magpie ⇄ ElevenLabs)
|
| 67 |
+
The default TTS in `pipeline.py` is NVIDIA Riva Magpie via NIM:
|
| 68 |
+
|
| 69 |
+
```startLine:endLine:examples/voice_agent_webrtc_langgraph/pipeline.py
|
| 70 |
+
tts = RivaTTSService(
|
| 71 |
+
api_key=os.getenv("RIVA_API_KEY"),
|
| 72 |
+
function_id=os.getenv("NVIDIA_TTS_FUNCTION_ID", "4e813649-d5e4-4020-b2be-2b918396d19d"),
|
| 73 |
+
voice_id=os.getenv("RIVA_TTS_VOICE_ID", "Magpie-ZeroShot.Female-1"),
|
| 74 |
+
model=os.getenv("RIVA_TTS_MODEL", "magpie_tts_ensemble-Magpie-ZeroShot"),
|
| 75 |
+
language=os.getenv("RIVA_TTS_LANGUAGE", "en-US"),
|
| 76 |
+
zero_shot_audio_prompt_file=(
|
| 77 |
+
Path(os.getenv("ZERO_SHOT_AUDIO_PROMPT")) if os.getenv("ZERO_SHOT_AUDIO_PROMPT") else None
|
| 78 |
+
),
|
| 79 |
+
)
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
To use ElevenLabs instead:
|
| 83 |
+
1) Ensure `pipecat` ElevenLabs dependency is available (already included via project deps).
|
| 84 |
+
2) Set environment:
|
| 85 |
+
- `ELEVENLABS_API_KEY`
|
| 86 |
+
- Optionally `ELEVENLABS_VOICE_ID` and model settings supported by ElevenLabs
|
| 87 |
+
3) Change the TTS construction in `pipeline.py` to use `ElevenLabsTTSServiceWithEndOfSpeech`:
|
| 88 |
+
|
| 89 |
+
```python
|
| 90 |
+
from nvidia_pipecat.services.elevenlabs import ElevenLabsTTSServiceWithEndOfSpeech
|
| 91 |
+
|
| 92 |
+
# Replace RivaTTSService(...) with:
|
| 93 |
+
tts = ElevenLabsTTSServiceWithEndOfSpeech(
|
| 94 |
+
api_key=os.getenv("ELEVENLABS_API_KEY"),
|
| 95 |
+
voice_id=os.getenv("ELEVENLABS_VOICE_ID", "Rachel"),
|
| 96 |
+
sample_rate=16000,
|
| 97 |
+
channels=1,
|
| 98 |
+
)
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
That’s it. No other pipeline changes are required. The transcript synchronization already supports ElevenLabs end‑of‑speech events.
|
| 102 |
+
|
| 103 |
+
Notes for Magpie Zero‑shot:
|
| 104 |
+
- Provide `RIVA_TTS_VOICE_ID` like `Magpie-ZeroShot.Female-1` and `RIVA_TTS_MODEL` like `magpie_tts_ensemble-Magpie-ZeroShot`.
|
| 105 |
+
- If using a custom voice prompt, mount it via `docker-compose.yml` and set `ZERO_SHOT_AUDIO_PROMPT`. You can also set `ZERO_SHOT_AUDIO_PROMPT_URL` to auto-download at startup.
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
## 5) Troubleshooting
|
| 109 |
+
- Healthcheck: `curl -f http://localhost:7860/get_prompt`
|
| 110 |
+
- If UI can’t access mic on http, use Chrome flag above or host UI via HTTPS.
|
| 111 |
+
- For NAT/firewall issues, configure TURN or Twilio credentials.
|
| 112 |
+
|
examples/voice_agent_multi_thread/agents/.telco_thread_id
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
8492eaa4-d086-452d-8f82-2cf2ed819ae1
|
examples/voice_agent_multi_thread/agents/Dockerfile.langgraph.api
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM langchain/langgraph-api:3.11
|
| 2 |
+
|
| 3 |
+
RUN pip install --upgrade pip
|
| 4 |
+
COPY ace_base_agent/requirements.txt /tmp/requirements.txt
|
| 5 |
+
RUN pip install -r /tmp/requirements.txt
|
| 6 |
+
|
| 7 |
+
# -- Adding non-package dependency agents --
|
| 8 |
+
ADD . /deps/__outer_agents/src
|
| 9 |
+
RUN set -ex && \
|
| 10 |
+
for line in '[project]' \
|
| 11 |
+
'name = "agents"' \
|
| 12 |
+
'version = "0.1"' \
|
| 13 |
+
'[tool.setuptools.package-data]' \
|
| 14 |
+
'"*" = ["**/*"]' \
|
| 15 |
+
'[build-system]' \
|
| 16 |
+
'requires = ["setuptools>=61"]' \
|
| 17 |
+
'build-backend = "setuptools.build_meta"'; do \
|
| 18 |
+
echo "$line" >> /deps/__outer_agents/pyproject.toml; \
|
| 19 |
+
done
|
| 20 |
+
# -- End of non-package dependency agents --
|
| 21 |
+
|
| 22 |
+
# -- Installing all local dependencies --
|
| 23 |
+
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/*
|
| 24 |
+
# -- End of local dependencies install --
|
| 25 |
+
ENV LANGSERVE_GRAPHS='{"ace-base-agent": "/deps/__outer_agents/src/ace_base_agent/ace_base_agent.py:agent"}'
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# -- Ensure user deps didn't inadvertently overwrite langgraph-api
|
| 30 |
+
RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
|
| 31 |
+
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir --no-deps -e /api
|
| 32 |
+
# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
|
| 33 |
+
# -- Removing build deps from the final image ~<:===~~~ --
|
| 34 |
+
RUN pip uninstall -y pip setuptools wheel
|
| 35 |
+
RUN rm -rf /usr/local/lib/python*/site-packages/pip* /usr/local/lib/python*/site-packages/setuptools* /usr/local/lib/python*/site-packages/wheel* && find /usr/local/bin -name "pip*" -delete || true
|
| 36 |
+
RUN rm -rf /usr/lib/python*/site-packages/pip* /usr/lib/python*/site-packages/setuptools* /usr/lib/python*/site-packages/wheel* && find /usr/bin -name "pip*" -delete || true
|
| 37 |
+
RUN uv pip uninstall --system pip setuptools wheel && rm /usr/bin/uv /usr/bin/uvx
|
| 38 |
+
|
| 39 |
+
WORKDIR /deps/__outer_agents/src
|
examples/voice_agent_multi_thread/agents/env.example
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# agents/.env example for local dev (used by `langgraph dev`)
|
| 2 |
+
# Copy to agents/.env and set your keys.
|
| 3 |
+
|
| 4 |
+
# Required by ace_base_agent (ChatOpenAI)
|
| 5 |
+
OPENAI_API_KEY=
|
| 6 |
+
|
| 7 |
+
# Optional tracing
|
| 8 |
+
LANGSMITH_API_KEY=
|
| 9 |
+
LANGSMITH_BASE_URL=https://api.smith.langchain.com
|
| 10 |
+
LANGSMITH_PROJECT=ace-controller
|
examples/voice_agent_multi_thread/agents/helper_functions.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helper functions for multi-threaded agent coordination and progress tracking."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict
|
| 4 |
+
from langgraph.store.base import BaseStore
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def write_status(
|
| 8 |
+
tool_name: str,
|
| 9 |
+
progress: int,
|
| 10 |
+
status: str,
|
| 11 |
+
store: BaseStore,
|
| 12 |
+
namespace: tuple | list,
|
| 13 |
+
config: Dict[str, Any] | None = None
|
| 14 |
+
) -> None:
|
| 15 |
+
"""Write tool execution status and progress to the store.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
tool_name: Name of the tool being executed
|
| 19 |
+
progress: Progress percentage (0-100)
|
| 20 |
+
status: Status string ("running", "completed", "failed")
|
| 21 |
+
store: LangGraph store instance
|
| 22 |
+
namespace: Namespace tuple for store isolation
|
| 23 |
+
config: Optional runtime config
|
| 24 |
+
"""
|
| 25 |
+
if not isinstance(namespace, tuple):
|
| 26 |
+
try:
|
| 27 |
+
namespace = tuple(namespace)
|
| 28 |
+
except (TypeError, ValueError):
|
| 29 |
+
namespace = (str(namespace),)
|
| 30 |
+
|
| 31 |
+
store.put(
|
| 32 |
+
namespace,
|
| 33 |
+
"working-tool-status-update",
|
| 34 |
+
{
|
| 35 |
+
"tool_name": tool_name,
|
| 36 |
+
"progress": progress,
|
| 37 |
+
"status": status,
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def reset_status(store: BaseStore, namespace: tuple | list) -> None:
|
| 43 |
+
"""Reset/clear tool execution status from the store.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
store: LangGraph store instance
|
| 47 |
+
namespace: Namespace tuple for store isolation
|
| 48 |
+
"""
|
| 49 |
+
if not isinstance(namespace, tuple):
|
| 50 |
+
try:
|
| 51 |
+
namespace = tuple(namespace)
|
| 52 |
+
except (TypeError, ValueError):
|
| 53 |
+
namespace = (str(namespace),)
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
store.delete(namespace, "working-tool-status-update")
|
| 57 |
+
except Exception:
|
| 58 |
+
# If key doesn't exist, that's fine
|
| 59 |
+
pass
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
examples/voice_agent_multi_thread/agents/langgraph.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"dependencies": ["."],
|
| 3 |
+
"graphs": {
|
| 4 |
+
"telco-agent": "./telco-agent-multi/react_agent.py:agent"
|
| 5 |
+
|
| 6 |
+
},
|
| 7 |
+
"dockerfile_lines": [
|
| 8 |
+
"RUN pip install --upgrade pip",
|
| 9 |
+
"COPY ace_base_agent/requirements.txt /tmp/requirements.txt",
|
| 10 |
+
"RUN pip install -r /tmp/requirements.txt",
|
| 11 |
+
"COPY ../requirements.txt /tmp/root_requirements.txt",
|
| 12 |
+
"RUN pip install -r /tmp/root_requirements.txt"
|
| 13 |
+
],
|
| 14 |
+
"env": ".env"
|
| 15 |
+
}
|
examples/voice_agent_multi_thread/agents/requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
langchain
|
| 2 |
+
langgraph
|
| 3 |
+
langgraph-cli[inmem]
|
| 4 |
+
langgraph-sdk
|
| 5 |
+
langchain_openai
|
| 6 |
+
gradio
|
| 7 |
+
matplotlib
|
| 8 |
+
seaborn
|
| 9 |
+
pytz
|
| 10 |
+
docling
|
| 11 |
+
pymongo
|
| 12 |
+
yt_dlp
|
| 13 |
+
requests
|
| 14 |
+
protobuf==6.31.1
|
| 15 |
+
twilio
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/IMPLEMENTATION_SUMMARY.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-Threaded Telco Agent - Implementation Summary
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
Successfully implemented **Option 1: Tool-Level Designation** for the telco agent, enabling non-blocking multi-threaded execution with intelligent routing based on whether operations are long-running or quick.
|
| 6 |
+
|
| 7 |
+
## Files Modified/Created
|
| 8 |
+
|
| 9 |
+
### 1. **Created: `helper_functions.py`**
|
| 10 |
+
- **Location**: `agents/helper_functions.py`
|
| 11 |
+
- **Purpose**: Shared utilities for progress tracking and coordination
|
| 12 |
+
- **Functions**:
|
| 13 |
+
- `write_status()`: Write tool execution progress to store
|
| 14 |
+
- `reset_status()`: Clear tool execution status
|
| 15 |
+
|
| 16 |
+
### 2. **Modified: `tools.py`**
|
| 17 |
+
- **Location**: `agents/telco-agent-multi/tools.py`
|
| 18 |
+
- **Changes**:
|
| 19 |
+
- Added imports for progress tracking (`time`, `get_store`, `get_stream_writer`, etc.)
|
| 20 |
+
- Updated **4 long-running tools** with progress reporting:
|
| 21 |
+
1. `close_contract_tool` (10 seconds, 5 steps)
|
| 22 |
+
2. `purchase_roaming_pass_tool` (8 seconds, 4 steps)
|
| 23 |
+
3. `change_package_tool` (10 seconds, 5 steps)
|
| 24 |
+
4. `get_billing_summary_tool` (6 seconds, 3 steps)
|
| 25 |
+
- Added `check_status()` tool for secondary thread
|
| 26 |
+
- Marked all tools with `is_long_running` attribute (True/False)
|
| 27 |
+
- Tools now send immediate feedback via `get_stream_writer()`
|
| 28 |
+
|
| 29 |
+
### 3. **Modified: `react_agent.py`**
|
| 30 |
+
- **Location**: `agents/telco-agent-multi/react_agent.py`
|
| 31 |
+
- **Major Changes**:
|
| 32 |
+
|
| 33 |
+
#### Imports
|
| 34 |
+
- Added: `time`, `ChatPromptTemplate`, `BaseStore`, `RunnableConfig`, `ensure_config`, `get_store`
|
| 35 |
+
- Imported helper functions
|
| 36 |
+
|
| 37 |
+
#### System Prompts
|
| 38 |
+
- Added `SECONDARY_SYSTEM_PROMPT` for interim conversations
|
| 39 |
+
- Kept original `SYSTEM_PROMPT` for main operations
|
| 40 |
+
|
| 41 |
+
#### LLM Configuration
|
| 42 |
+
- Split tools into:
|
| 43 |
+
- `_MAIN_TOOLS`: All 13 telco tools
|
| 44 |
+
- `_SECONDARY_TOOLS`: 6 safe, quick tools + `check_status`
|
| 45 |
+
- Created dual LLM bindings:
|
| 46 |
+
- `_LLM_WITH_TOOLS`: Main thread (temp 0.3)
|
| 47 |
+
- `_HELPER_LLM_WITH_TOOLS`: Secondary thread (temp 0.7)
|
| 48 |
+
- Added `_ALL_TOOLS_BY_NAME` dictionary
|
| 49 |
+
|
| 50 |
+
#### Synthesis Chain
|
| 51 |
+
- Added `_SYNTHESIS_PROMPT` and `_SYNTHESIS_CHAIN`
|
| 52 |
+
- Merges tool results with interim conversation
|
| 53 |
+
|
| 54 |
+
#### Agent Function (Complete Refactor)
|
| 55 |
+
- **Signature Changed**:
|
| 56 |
+
```python
|
| 57 |
+
# Before
|
| 58 |
+
def agent(messages: List[BaseMessage], previous: List[BaseMessage] | None, config: Dict[str, Any] | None = None)
|
| 59 |
+
|
| 60 |
+
# After
|
| 61 |
+
def agent(input_dict: dict, previous: Any = None, config: RunnableConfig | None = None, store: BaseStore | None = None)
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
- **Input Dictionary**:
|
| 65 |
+
```python
|
| 66 |
+
input_dict = {
|
| 67 |
+
"messages": List[BaseMessage],
|
| 68 |
+
"thread_type": "main" | "secondary",
|
| 69 |
+
"interim_messages_reset": bool
|
| 70 |
+
}
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
- **State Format**:
|
| 74 |
+
```python
|
| 75 |
+
# Before: List[BaseMessage]
|
| 76 |
+
# After: Dict[str, List[BaseMessage]]
|
| 77 |
+
{
|
| 78 |
+
"messages": [...], # Full conversation
|
| 79 |
+
"interim_messages": [...] # Interim conversation during long ops
|
| 80 |
+
}
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
- **New Features**:
|
| 84 |
+
1. **Thread Type Routing**: Choose LLM/tools based on thread type
|
| 85 |
+
2. **Processing Locks**: Secondary thread sets lock at start
|
| 86 |
+
3. **Abort Handling**: Main can signal secondary to abort
|
| 87 |
+
4. **Wait & Synthesize**: Main waits for secondary (15s timeout) and synthesizes
|
| 88 |
+
5. **Progress Tracking**: Reset status after main thread completion
|
| 89 |
+
6. **Store Coordination**: Uses namespace for thread coordination
|
| 90 |
+
|
| 91 |
+
### 4. **Created: `MULTI_THREAD_README.md`**
|
| 92 |
+
- Comprehensive documentation of:
|
| 93 |
+
- Architecture overview
|
| 94 |
+
- Long-running vs quick tools
|
| 95 |
+
- Usage examples
|
| 96 |
+
- Coordination mechanism
|
| 97 |
+
- Safety features
|
| 98 |
+
- Migration guide
|
| 99 |
+
|
| 100 |
+
### 5. **Created: `example_multi_thread.py`**
|
| 101 |
+
- Executable demo script
|
| 102 |
+
- Three scenarios:
|
| 103 |
+
1. Long operation with status checks
|
| 104 |
+
2. Quick synchronous query
|
| 105 |
+
3. Interactive mode
|
| 106 |
+
- Shows proper threading and routing logic
|
| 107 |
+
|
| 108 |
+
## Architecture
|
| 109 |
+
|
| 110 |
+
```
|
| 111 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 112 |
+
│ User Request │
|
| 113 |
+
└─────────────────────┬───────────────────────────────────────┘
|
| 114 |
+
│
|
| 115 |
+
┌────────────┴────────────┐
|
| 116 |
+
│ │
|
| 117 |
+
Long Tool? Quick Tool?
|
| 118 |
+
│ │
|
| 119 |
+
▼ ▼
|
| 120 |
+
Main Thread Main Thread
|
| 121 |
+
│ (synchronous)
|
| 122 |
+
├─ Execute Tool │
|
| 123 |
+
│ (with progress) └─ Return
|
| 124 |
+
│
|
| 125 |
+
├─ Store progress
|
| 126 |
+
│
|
| 127 |
+
├─ Check Secondary
|
| 128 |
+
│
|
| 129 |
+
└─ Synthesize Result
|
| 130 |
+
|
| 131 |
+
While Main Running:
|
| 132 |
+
▼
|
| 133 |
+
Secondary Thread
|
| 134 |
+
│
|
| 135 |
+
├─ Handle interim queries
|
| 136 |
+
│ (limited tools)
|
| 137 |
+
│
|
| 138 |
+
└─ Store interim messages
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
## Store Keys Used
|
| 142 |
+
|
| 143 |
+
| Key | Purpose | Lifecycle |
|
| 144 |
+
|-----|---------|-----------|
|
| 145 |
+
| `working-tool-status-update` | Tool progress (0-100%) | Set by long tools, cleared by main |
|
| 146 |
+
| `secondary_status` | Secondary thread lock | Set at start, cleared at end |
|
| 147 |
+
| `secondary_abort` | Abort signal | Set by main on timeout, cleared by secondary |
|
| 148 |
+
| `secondary_interim_messages` | Interim conversation | Set by secondary, read/cleared by main |
|
| 149 |
+
|
| 150 |
+
## Tool Classification
|
| 151 |
+
|
| 152 |
+
### Long-Running Tools (4 tools)
|
| 153 |
+
1. **`close_contract_tool`** - 10 seconds
|
| 154 |
+
- Simulates contract closure processing
|
| 155 |
+
- Reports 5 progress steps
|
| 156 |
+
|
| 157 |
+
2. **`purchase_roaming_pass_tool`** - 8 seconds
|
| 158 |
+
- Simulates payment processing and activation
|
| 159 |
+
- Reports 4 progress steps
|
| 160 |
+
|
| 161 |
+
3. **`change_package_tool`** - 10 seconds
|
| 162 |
+
- Simulates package provisioning
|
| 163 |
+
- Reports 5 progress steps
|
| 164 |
+
|
| 165 |
+
4. **`get_billing_summary_tool`** - 6 seconds
|
| 166 |
+
- Simulates multi-system billing queries
|
| 167 |
+
- Reports 3 progress steps
|
| 168 |
+
|
| 169 |
+
### Quick Tools (9 tools)
|
| 170 |
+
- `start_login_tool`, `verify_login_tool` (auth)
|
| 171 |
+
- `get_current_package_tool` (lookup)
|
| 172 |
+
- `get_data_balance_tool` (lookup)
|
| 173 |
+
- `list_available_packages_tool` (catalog)
|
| 174 |
+
- `recommend_packages_tool` (computation)
|
| 175 |
+
- `get_roaming_info_tool` (reference data)
|
| 176 |
+
- `list_addons_tool` (lookup)
|
| 177 |
+
- `set_data_alerts_tool` (config update)
|
| 178 |
+
|
| 179 |
+
### Helper Tool
|
| 180 |
+
- `check_status` (progress query for secondary thread)
|
| 181 |
+
|
| 182 |
+
## Safety Features
|
| 183 |
+
|
| 184 |
+
1. **Processing Locks**
|
| 185 |
+
- Secondary thread sets `secondary_status.processing = True` at start
|
| 186 |
+
- Released when complete or aborted
|
| 187 |
+
- Prevents race conditions
|
| 188 |
+
|
| 189 |
+
2. **Abort Signals**
|
| 190 |
+
- Main thread can set `secondary_abort` flag
|
| 191 |
+
- Secondary checks flag at start and before writing results
|
| 192 |
+
- Graceful termination without corrupting state
|
| 193 |
+
|
| 194 |
+
3. **Timeouts**
|
| 195 |
+
- Main thread waits max 15 seconds for secondary
|
| 196 |
+
- Prevents indefinite blocking
|
| 197 |
+
- Sets abort flag on timeout
|
| 198 |
+
|
| 199 |
+
4. **Message Sanitization**
|
| 200 |
+
- Removes orphan `ToolMessage` instances
|
| 201 |
+
- Prevents OpenAI API 400 errors
|
| 202 |
+
- Maintains conversation coherence
|
| 203 |
+
|
| 204 |
+
5. **State Isolation**
|
| 205 |
+
- Separate thread IDs for main and secondary
|
| 206 |
+
- Namespace-based store isolation
|
| 207 |
+
- No cross-contamination
|
| 208 |
+
|
| 209 |
+
## Testing Recommendations
|
| 210 |
+
|
| 211 |
+
### Unit Tests
|
| 212 |
+
- Test long tools report progress correctly
|
| 213 |
+
- Test `check_status` returns accurate status
|
| 214 |
+
- Test message sanitization removes orphans
|
| 215 |
+
- Test state merging (messages + interim_messages)
|
| 216 |
+
|
| 217 |
+
### Integration Tests
|
| 218 |
+
1. **Single long operation**: Verify completion and status reset
|
| 219 |
+
2. **Long operation + status check**: Verify secondary can query progress
|
| 220 |
+
3. **Long operation + multiple queries**: Verify multiple secondary calls
|
| 221 |
+
4. **Synthesis**: Verify main synthesizes interim conversation
|
| 222 |
+
5. **Timeout**: Verify main aborts secondary after 15s
|
| 223 |
+
6. **Quick operation**: Verify no multi-threading overhead
|
| 224 |
+
|
| 225 |
+
### Load Tests
|
| 226 |
+
- Multiple concurrent users with different namespaces
|
| 227 |
+
- Rapid main/secondary alternation
|
| 228 |
+
- Store performance under load
|
| 229 |
+
|
| 230 |
+
## Performance Considerations
|
| 231 |
+
|
| 232 |
+
1. **Store Access**: Each coordination point hits the store
|
| 233 |
+
- Consider caching for high-frequency access
|
| 234 |
+
- Monitor store latency
|
| 235 |
+
|
| 236 |
+
2. **Synthesis LLM Call**: Additional API call for merging
|
| 237 |
+
- Only happens when interim conversation exists
|
| 238 |
+
- Uses temperature 0.7 for natural language
|
| 239 |
+
|
| 240 |
+
3. **Thread Overhead**: Secondary thread runs synchronously
|
| 241 |
+
- No actual parallelism for safety
|
| 242 |
+
- Consider async/await for true concurrency
|
| 243 |
+
|
| 244 |
+
4. **Timeout Waiting**: Main thread sleeps in 0.5s intervals
|
| 245 |
+
- 15 seconds max = 30 checks
|
| 246 |
+
- Minimal CPU usage
|
| 247 |
+
|
| 248 |
+
## Migration Path
|
| 249 |
+
|
| 250 |
+
For existing deployments:
|
| 251 |
+
|
| 252 |
+
1. **Update client code** to use new input format
|
| 253 |
+
2. **Add `namespace_for_memory`** to config
|
| 254 |
+
3. **Provide store instance** to agent calls
|
| 255 |
+
4. **Update state handling** to expect dict instead of list
|
| 256 |
+
5. **Test backward compatibility** with quick tools (should work seamlessly)
|
| 257 |
+
|
| 258 |
+
## Future Enhancements
|
| 259 |
+
|
| 260 |
+
1. **Dynamic Tool Marking**: Tool duration could be learned/adjusted
|
| 261 |
+
2. **Priority Queue**: Multiple long operations could queue
|
| 262 |
+
3. **True Async**: Replace synchronous secondary with async/await
|
| 263 |
+
4. **Progress UI**: Stream progress updates to frontend
|
| 264 |
+
5. **Cancellation**: User-initiated cancellation of long operations
|
| 265 |
+
6. **Retry Logic**: Automatic retry for failed long operations
|
| 266 |
+
7. **Telemetry**: Track success rates, durations, timeout frequency
|
| 267 |
+
|
| 268 |
+
## Credits
|
| 269 |
+
|
| 270 |
+
Implementation based on the multi-threaded agent pattern with:
|
| 271 |
+
- Tool-level designation (Option 1)
|
| 272 |
+
- Store-based coordination
|
| 273 |
+
- Progress tracking and streaming
|
| 274 |
+
- Conversation synthesis
|
| 275 |
+
- Race condition handling
|
| 276 |
+
|
| 277 |
+
Date: September 30, 2025
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/MULTI_THREAD_README.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-Threaded Telco Agent
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
This telco agent now supports **non-blocking multi-threaded execution**, allowing users to continue conversing while long-running operations (like package changes, contract closures, or billing queries) are in progress.
|
| 6 |
+
|
| 7 |
+
## Key Features
|
| 8 |
+
|
| 9 |
+
### 1. **Dual Thread Architecture**
|
| 10 |
+
|
| 11 |
+
- **Main Thread**: Handles primary requests and long-running operations
|
| 12 |
+
- **Secondary Thread**: Handles interim conversations while main thread is busy
|
| 13 |
+
|
| 14 |
+
### 2. **Long-Running Tools**
|
| 15 |
+
|
| 16 |
+
The following tools are marked as long-running and will trigger multi-threaded behavior:
|
| 17 |
+
|
| 18 |
+
- `close_contract_tool` (10 seconds)
|
| 19 |
+
- `purchase_roaming_pass_tool` (8 seconds)
|
| 20 |
+
- `change_package_tool` (10 seconds)
|
| 21 |
+
- `get_billing_summary_tool` (6 seconds)
|
| 22 |
+
|
| 23 |
+
### 3. **Quick Tools** (for secondary thread)
|
| 24 |
+
|
| 25 |
+
These tools are available during long operations:
|
| 26 |
+
|
| 27 |
+
- `check_status` - Query progress of ongoing operation
|
| 28 |
+
- `get_current_package_tool` - Quick lookups
|
| 29 |
+
- `get_data_balance_tool` - Quick queries
|
| 30 |
+
- `list_available_packages_tool` - Browse packages
|
| 31 |
+
- `get_roaming_info_tool` - Roaming information
|
| 32 |
+
- `list_addons_tool` - List addons
|
| 33 |
+
|
| 34 |
+
### 4. **Progress Tracking**
|
| 35 |
+
|
| 36 |
+
Long-running tools report progress that can be queried via `check_status` tool during execution.
|
| 37 |
+
|
| 38 |
+
### 5. **Conversation Synthesis**
|
| 39 |
+
|
| 40 |
+
When a long operation completes, the agent synthesizes the result with any interim conversation that occurred, providing a natural, coherent response.
|
| 41 |
+
|
| 42 |
+
## Usage
|
| 43 |
+
|
| 44 |
+
### Input Format
|
| 45 |
+
|
| 46 |
+
The agent now expects an `input_dict` instead of a simple message list:
|
| 47 |
+
|
| 48 |
+
```python
|
| 49 |
+
input_dict = {
|
| 50 |
+
"messages": [HumanMessage(content="Close my contract")],
|
| 51 |
+
"thread_type": "main", # or "secondary"
|
| 52 |
+
"interim_messages_reset": True # Reset interim conversation tracking
|
| 53 |
+
}
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### Configuration
|
| 57 |
+
|
| 58 |
+
The agent requires a `namespace_for_memory` in the config for coordination:
|
| 59 |
+
|
| 60 |
+
```python
|
| 61 |
+
config = {
|
| 62 |
+
"configurable": {
|
| 63 |
+
"thread_id": "main-thread-123",
|
| 64 |
+
"namespace_for_memory": ("user_id", "tools_updates")
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### Example Client Usage
|
| 70 |
+
|
| 71 |
+
```python
|
| 72 |
+
import uuid
|
| 73 |
+
import threading
|
| 74 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 75 |
+
from langgraph.store.memory import InMemoryStore
|
| 76 |
+
|
| 77 |
+
# Initialize
|
| 78 |
+
store = InMemoryStore()
|
| 79 |
+
thread_id_main = str(uuid.uuid4())
|
| 80 |
+
thread_id_secondary = str(uuid.uuid4())
|
| 81 |
+
namespace = ("user_123", "telco_ops")
|
| 82 |
+
|
| 83 |
+
config_main = {
|
| 84 |
+
"configurable": {
|
| 85 |
+
"thread_id": thread_id_main,
|
| 86 |
+
"namespace_for_memory": namespace
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
config_secondary = {
|
| 91 |
+
"configurable": {
|
| 92 |
+
"thread_id": thread_id_secondary,
|
| 93 |
+
"namespace_for_memory": namespace
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
# Main thread (long operation - non-blocking)
|
| 98 |
+
def run_main_operation():
|
| 99 |
+
result = agent.invoke(
|
| 100 |
+
{
|
| 101 |
+
"messages": [HumanMessage(content="Change my package to Premium")],
|
| 102 |
+
"thread_type": "main",
|
| 103 |
+
"interim_messages_reset": True
|
| 104 |
+
},
|
| 105 |
+
config=config_main,
|
| 106 |
+
store=store
|
| 107 |
+
)
|
| 108 |
+
print(f"Main: {result[-1].content}")
|
| 109 |
+
|
| 110 |
+
# Start long operation in background
|
| 111 |
+
main_thread = threading.Thread(target=run_main_operation)
|
| 112 |
+
main_thread.start()
|
| 113 |
+
|
| 114 |
+
# While main is running, handle secondary queries
|
| 115 |
+
time.sleep(2) # Let main operation start
|
| 116 |
+
|
| 117 |
+
result = agent.invoke(
|
| 118 |
+
{
|
| 119 |
+
"messages": [HumanMessage(content="What's the status?")],
|
| 120 |
+
"thread_type": "secondary",
|
| 121 |
+
"interim_messages_reset": False
|
| 122 |
+
},
|
| 123 |
+
config=config_secondary,
|
| 124 |
+
store=store
|
| 125 |
+
)
|
| 126 |
+
print(f"Secondary: {result[-1].content}")
|
| 127 |
+
|
| 128 |
+
# Wait for main to complete
|
| 129 |
+
main_thread.join()
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
## Coordination Mechanism
|
| 133 |
+
|
| 134 |
+
The agent uses the LangGraph store for thread coordination:
|
| 135 |
+
|
| 136 |
+
### Store Keys
|
| 137 |
+
|
| 138 |
+
- `working-tool-status-update`: Current tool progress and status
|
| 139 |
+
- `secondary_status`: Lock indicating secondary thread processing state
|
| 140 |
+
- `secondary_abort`: Abort signal for terminating secondary thread
|
| 141 |
+
- `secondary_interim_messages`: Interim conversation to be synthesized
|
| 142 |
+
|
| 143 |
+
### State Management
|
| 144 |
+
|
| 145 |
+
The agent maintains two message lists:
|
| 146 |
+
|
| 147 |
+
1. `messages`: Full conversation history
|
| 148 |
+
2. `interim_messages`: Messages exchanged during long operations (for synthesis)
|
| 149 |
+
|
| 150 |
+
## Architecture
|
| 151 |
+
|
| 152 |
+
```
|
| 153 |
+
User Request
|
| 154 |
+
│
|
| 155 |
+
├─ Long Operation? ───► Main Thread
|
| 156 |
+
│ │
|
| 157 |
+
│ ├─ Execute Tool (with progress reporting)
|
| 158 |
+
│ │
|
| 159 |
+
│ └─ Wait for Secondary + Synthesize
|
| 160 |
+
│
|
| 161 |
+
└─ Quick Query? ──────► Secondary Thread
|
| 162 |
+
│
|
| 163 |
+
├─ Handle query (limited tools)
|
| 164 |
+
│
|
| 165 |
+
└─ Store interim messages
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
## Safety Features
|
| 169 |
+
|
| 170 |
+
1. **Processing Locks**: Prevent race conditions during state updates
|
| 171 |
+
2. **Abort Signals**: Gracefully terminate secondary thread if main completes
|
| 172 |
+
3. **Timeouts**: Main thread waits max 15 seconds for secondary to finish
|
| 173 |
+
4. **Message Sanitization**: Removes orphan tool messages to prevent API errors
|
| 174 |
+
|
| 175 |
+
## Testing
|
| 176 |
+
|
| 177 |
+
To test the multi-threaded behavior, you can simulate long operations:
|
| 178 |
+
|
| 179 |
+
```python
|
| 180 |
+
# Test 1: Long operation without interruption
|
| 181 |
+
response = agent.invoke({
|
| 182 |
+
"messages": [HumanMessage(content="Close my contract")],
|
| 183 |
+
"thread_type": "main",
|
| 184 |
+
"interim_messages_reset": True
|
| 185 |
+
}, config=config_main, store=store)
|
| 186 |
+
|
| 187 |
+
# Test 2: Long operation with status check
|
| 188 |
+
# (Start main in background, then query status)
|
| 189 |
+
|
| 190 |
+
# Test 3: Multiple secondary queries during long operation
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
## Environment Variables
|
| 194 |
+
|
| 195 |
+
- `REACT_MODEL`: Model for main thread (default: gpt-4o)
|
| 196 |
+
- `RBC_FEES_MAX_MSGS`: Max messages to keep in context (default: 40)
|
| 197 |
+
- `TELCO_DEBUG`: Enable debug logging (default: 0)
|
| 198 |
+
|
| 199 |
+
## Migration Notes
|
| 200 |
+
|
| 201 |
+
If you have existing code using the old agent format:
|
| 202 |
+
|
| 203 |
+
**Before:**
|
| 204 |
+
```python
|
| 205 |
+
result = agent.invoke(
|
| 206 |
+
[HumanMessage(content="Hello")],
|
| 207 |
+
config=config
|
| 208 |
+
)
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
**After:**
|
| 212 |
+
```python
|
| 213 |
+
result = agent.invoke(
|
| 214 |
+
{
|
| 215 |
+
"messages": [HumanMessage(content="Hello")],
|
| 216 |
+
"thread_type": "main",
|
| 217 |
+
"interim_messages_reset": True
|
| 218 |
+
},
|
| 219 |
+
config=config,
|
| 220 |
+
store=store
|
| 221 |
+
)
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
The state format has also changed from `List[BaseMessage]` to `Dict[str, List[BaseMessage]]` with keys `messages` and `interim_messages`.
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Telco Agent
|
| 2 |
+
|
| 3 |
+
A mobile operator assistant that verifies callers via SMS OTP, reviews current plan and data usage, answers roaming questions, recommends packages, manages addons and plan changes, and can close contracts (mock).
|
| 4 |
+
|
| 5 |
+
## How to use
|
| 6 |
+
|
| 7 |
+
1. Provide your mobile number (MSISDN). The agent will send an SMS OTP.
|
| 8 |
+
2. Provide the 6-digit code to verify.
|
| 9 |
+
3. Ask for:
|
| 10 |
+
- Current package and contract status
|
| 11 |
+
- Current data balance/remaining
|
| 12 |
+
- Roaming details for a country (pricing and passes)
|
| 13 |
+
- Package recommendations (optionally give preferences: budget, need_5g, travel_country)
|
| 14 |
+
- Close contract (the agent summarizes any early termination fee and asks for confirmation)
|
| 15 |
+
- Addons list or purchase a roaming pass
|
| 16 |
+
- Change package (now or next_cycle)
|
| 17 |
+
- Billing summary and set data alerts
|
| 18 |
+
|
| 19 |
+
## Mock data
|
| 20 |
+
|
| 21 |
+
From `mock_data/`:
|
| 22 |
+
|
| 23 |
+
- Customers (`customers.json`):
|
| 24 |
+
- `+15551234567` (Alex Lee): package `P-40` Standard 40GB 5G, billing cycle day 5, current_month used 12.5 GB.
|
| 25 |
+
- `+447911123456` (Sam Taylor): package `P-10` Lite 10GB 4G.
|
| 26 |
+
- OTPs (`otps.json`):
|
| 27 |
+
- default: `123456`
|
| 28 |
+
- by number: `+15551234567` → `246810`, `+447911123456` → `135790`
|
| 29 |
+
- Packages (`packages.json`): 4G/5G plans including Unlimited options with features like EU roaming and data rollover.
|
| 30 |
+
- Roaming rates (`roaming_rates.json`): pay-as-you-go and pass options for `IT`, `FR`, `US`.
|
| 31 |
+
|
| 32 |
+
## Example conversation
|
| 33 |
+
|
| 34 |
+
- Agent: Hi! What’s your mobile number?
|
| 35 |
+
- You: +1 555 123 4567
|
| 36 |
+
- Agent: I’ve sent a 6-digit code to ***-***-**67. What’s the code?
|
| 37 |
+
- You: 246810
|
| 38 |
+
- Agent: Verified. Would you like your current package or data balance?
|
| 39 |
+
- You: What’s my data balance?
|
| 40 |
+
- Agent: You’ve used 12.5 GB this cycle on Standard 40GB 5G; 27.5 GB remaining. Anything else?
|
| 41 |
+
- You: I’m traveling to Italy. Roaming?
|
| 42 |
+
- Agent: Italy is included by your plan; passes also available (EU Day 1GB €5, Week 5GB €15). Purchase a pass?
|
| 43 |
+
- You: Recommend a cheaper plan under $50, 5G.
|
| 44 |
+
- Agent: Suggesting Travelers EU 20GB ($45): 5G, EU roaming included. Estimated monthly cost $45.
|
| 45 |
+
|
| 46 |
+
## Extended actions (tools)
|
| 47 |
+
|
| 48 |
+
- List addons: active roaming passes
|
| 49 |
+
- Purchase roaming pass: e.g., country `IT`, pass `EU-WEEK-5GB`
|
| 50 |
+
- Change package: `change_package(now|next_cycle)`
|
| 51 |
+
- Billing summary: monthly fee and last bill
|
| 52 |
+
- Set data alerts: by percent and/or GB
|
| 53 |
+
|
| 54 |
+
## Notes
|
| 55 |
+
|
| 56 |
+
- OTP is required before any account operations.
|
| 57 |
+
- Recommendations use recent usage history to estimate monthly costs.
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Telco Assistant Agent (ReAct)
|
| 2 |
+
|
| 3 |
+
This package contains a LangGraph ReAct-based assistant for a mobile operator.
|
| 4 |
+
It verifies callers via SMS OTP, can review current plans and data usage,
|
| 5 |
+
answer roaming questions, recommend packages, and close contracts (mock).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from .react_agent import agent # noqa: F401
|
| 9 |
+
|
| 10 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/example_multi_thread.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Example script demonstrating multi-threaded telco agent usage.
|
| 4 |
+
|
| 5 |
+
This script shows how to:
|
| 6 |
+
1. Start a long-running operation (main thread)
|
| 7 |
+
2. Handle interim queries (secondary thread) while the operation runs
|
| 8 |
+
3. Let the agent synthesize the final response
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python example_multi_thread.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
import uuid
|
| 18 |
+
import threading
|
| 19 |
+
import queue
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
# Add parent directory to path for imports
|
| 23 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 24 |
+
|
| 25 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 26 |
+
from langgraph.store.memory import InMemoryStore
|
| 27 |
+
|
| 28 |
+
# Import the agent
|
| 29 |
+
from react_agent import agent
|
| 30 |
+
|
| 31 |
+
# Setup
|
| 32 |
+
store = InMemoryStore()
|
| 33 |
+
thread_id_main = str(uuid.uuid4())
|
| 34 |
+
thread_id_secondary = str(uuid.uuid4())
|
| 35 |
+
|
| 36 |
+
user_id = "demo_user"
|
| 37 |
+
namespace_for_memory = (user_id, "telco_operations")
|
| 38 |
+
|
| 39 |
+
config_main = {
|
| 40 |
+
"configurable": {
|
| 41 |
+
"thread_id": thread_id_main,
|
| 42 |
+
"namespace_for_memory": namespace_for_memory
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
config_secondary = {
|
| 47 |
+
"configurable": {
|
| 48 |
+
"thread_id": thread_id_secondary,
|
| 49 |
+
"namespace_for_memory": namespace_for_memory
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
print("=" * 60)
|
| 54 |
+
print("Multi-Threaded Telco Agent Demo")
|
| 55 |
+
print("=" * 60)
|
| 56 |
+
print(f"Main Thread ID: {thread_id_main}")
|
| 57 |
+
print(f"Secondary Thread ID: {thread_id_secondary}")
|
| 58 |
+
print(f"Namespace: {namespace_for_memory}")
|
| 59 |
+
print("=" * 60)
|
| 60 |
+
print()
|
| 61 |
+
|
| 62 |
+
# Thread-safe printing
|
| 63 |
+
print_lock = threading.Lock()
|
| 64 |
+
|
| 65 |
+
def safe_print(text: str) -> None:
|
| 66 |
+
with print_lock:
|
| 67 |
+
print(text)
|
| 68 |
+
|
| 69 |
+
def run_agent_stream(user_text: str, thread_type: str, config: dict, interim_reset: bool) -> None:
|
| 70 |
+
"""Run agent and print results."""
|
| 71 |
+
messages = [HumanMessage(content=user_text)]
|
| 72 |
+
try:
|
| 73 |
+
for mode, chunk in agent.stream(
|
| 74 |
+
{
|
| 75 |
+
"messages": messages,
|
| 76 |
+
"thread_type": thread_type,
|
| 77 |
+
"interim_messages_reset": interim_reset
|
| 78 |
+
},
|
| 79 |
+
stream_mode=["custom", "values"],
|
| 80 |
+
config=config,
|
| 81 |
+
store=store
|
| 82 |
+
):
|
| 83 |
+
if isinstance(chunk, list) and chunk:
|
| 84 |
+
ai_messages = [m for m in chunk if isinstance(m, AIMessage)]
|
| 85 |
+
if ai_messages:
|
| 86 |
+
safe_print(f"[{thread_type}] {ai_messages[-1].content}")
|
| 87 |
+
elif isinstance(chunk, str):
|
| 88 |
+
safe_print(f"[{thread_type}] {chunk}")
|
| 89 |
+
except Exception as e:
|
| 90 |
+
safe_print(f"[{thread_type} ERROR] {e!r}")
|
| 91 |
+
|
| 92 |
+
# ============================================================================
|
| 93 |
+
# Demo Scenario 1: Long operation with status checks
|
| 94 |
+
# ============================================================================
|
| 95 |
+
|
| 96 |
+
print("SCENARIO 1: Long operation with interim status checks")
|
| 97 |
+
print("-" * 60)
|
| 98 |
+
|
| 99 |
+
# Start a long-running operation in the background
|
| 100 |
+
print("\n>>> User: 'Change my package to Premium Plus'")
|
| 101 |
+
print(">>> (Starting main thread in background...)")
|
| 102 |
+
print()
|
| 103 |
+
|
| 104 |
+
main_job = threading.Thread(
|
| 105 |
+
target=run_agent_stream,
|
| 106 |
+
args=("Change my package to Premium Plus", "main", config_main, True),
|
| 107 |
+
daemon=True
|
| 108 |
+
)
|
| 109 |
+
main_job.start()
|
| 110 |
+
|
| 111 |
+
# Wait a bit for the operation to start
|
| 112 |
+
time.sleep(3)
|
| 113 |
+
|
| 114 |
+
# Now user asks about status (secondary thread)
|
| 115 |
+
print("\n>>> User: 'What's the status of my request?'")
|
| 116 |
+
print(">>> (Handled by secondary thread...)")
|
| 117 |
+
print()
|
| 118 |
+
run_agent_stream("What's the status of my request?", "secondary", config_secondary, False)
|
| 119 |
+
|
| 120 |
+
# Another query while main is still running
|
| 121 |
+
time.sleep(2)
|
| 122 |
+
print("\n>>> User: 'How much data do I have left?'")
|
| 123 |
+
print(">>> (Handled by secondary thread...)")
|
| 124 |
+
print()
|
| 125 |
+
run_agent_stream("How much data do I have left?", "secondary", config_secondary, False)
|
| 126 |
+
|
| 127 |
+
# Wait for main operation to complete
|
| 128 |
+
main_job.join()
|
| 129 |
+
|
| 130 |
+
print("\n" + "=" * 60)
|
| 131 |
+
print("Main operation completed and synthesized with interim conversation!")
|
| 132 |
+
print("=" * 60)
|
| 133 |
+
|
| 134 |
+
# ============================================================================
|
| 135 |
+
# Demo Scenario 2: Quick query (no multi-threading needed)
|
| 136 |
+
# ============================================================================
|
| 137 |
+
|
| 138 |
+
print("\n\nSCENARIO 2: Quick query (synchronous)")
|
| 139 |
+
print("-" * 60)
|
| 140 |
+
|
| 141 |
+
print("\n>>> User: 'What's my current package?'")
|
| 142 |
+
print(">>> (Quick query, handled synchronously...)")
|
| 143 |
+
print()
|
| 144 |
+
run_agent_stream("What's my current package?", "main", config_main, True)
|
| 145 |
+
|
| 146 |
+
# ============================================================================
|
| 147 |
+
# Demo Scenario 3: Interactive mode
|
| 148 |
+
# ============================================================================
|
| 149 |
+
|
| 150 |
+
print("\n\nSCENARIO 3: Interactive mode")
|
| 151 |
+
print("-" * 60)
|
| 152 |
+
print("Type your messages. Long operations will run in background.")
|
| 153 |
+
print("Type 'exit' to quit.")
|
| 154 |
+
print("-" * 60)
|
| 155 |
+
|
| 156 |
+
input_queue: "queue.Queue[str]" = queue.Queue()
|
| 157 |
+
stop_event = threading.Event()
|
| 158 |
+
main_job_active = None
|
| 159 |
+
interim_reset = True
|
| 160 |
+
|
| 161 |
+
def input_reader() -> None:
|
| 162 |
+
try:
|
| 163 |
+
while not stop_event.is_set():
|
| 164 |
+
try:
|
| 165 |
+
user_text = input("\nYou: ").strip()
|
| 166 |
+
except (KeyboardInterrupt, EOFError):
|
| 167 |
+
user_text = "exit"
|
| 168 |
+
if not user_text:
|
| 169 |
+
continue
|
| 170 |
+
input_queue.put(user_text)
|
| 171 |
+
if user_text.lower() in {"exit", "quit"}:
|
| 172 |
+
break
|
| 173 |
+
finally:
|
| 174 |
+
pass
|
| 175 |
+
|
| 176 |
+
reader_thread = threading.Thread(target=input_reader, daemon=True)
|
| 177 |
+
reader_thread.start()
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
while True:
|
| 181 |
+
try:
|
| 182 |
+
user_text = input_queue.get(timeout=0.1)
|
| 183 |
+
except queue.Empty:
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
if not user_text:
|
| 187 |
+
continue
|
| 188 |
+
|
| 189 |
+
if user_text.lower() in {"exit", "quit"}:
|
| 190 |
+
stop_event.set()
|
| 191 |
+
break
|
| 192 |
+
|
| 193 |
+
# Check if main thread is active
|
| 194 |
+
main_active = main_job_active is not None and main_job_active.is_alive()
|
| 195 |
+
|
| 196 |
+
# Check store for running status
|
| 197 |
+
memories = store.search(namespace_for_memory)
|
| 198 |
+
current_status = None
|
| 199 |
+
if memories:
|
| 200 |
+
try:
|
| 201 |
+
md = list(memories)[-1].value
|
| 202 |
+
current_status = md.get("status")
|
| 203 |
+
except Exception:
|
| 204 |
+
pass
|
| 205 |
+
|
| 206 |
+
if current_status == "running" or main_active:
|
| 207 |
+
# Secondary thread (synchronous)
|
| 208 |
+
safe_print("\n>>> (Long operation in progress, using secondary thread...)")
|
| 209 |
+
run_agent_stream(user_text, "secondary", config_secondary, False)
|
| 210 |
+
interim_reset = False
|
| 211 |
+
else:
|
| 212 |
+
# Main thread (background)
|
| 213 |
+
safe_print("\n>>> (Starting operation in background...)")
|
| 214 |
+
interim_reset = True
|
| 215 |
+
t = threading.Thread(
|
| 216 |
+
target=run_agent_stream,
|
| 217 |
+
args=(user_text, "main", config_main, interim_reset),
|
| 218 |
+
daemon=True
|
| 219 |
+
)
|
| 220 |
+
main_job_active = t
|
| 221 |
+
t.start()
|
| 222 |
+
|
| 223 |
+
except Exception as e:
|
| 224 |
+
safe_print(f"\n[FATAL ERROR] {e!r}")
|
| 225 |
+
finally:
|
| 226 |
+
stop_event.set()
|
| 227 |
+
if main_job_active is not None:
|
| 228 |
+
main_job_active.join(timeout=5)
|
| 229 |
+
|
| 230 |
+
print("\n\nDemo completed!")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/logic.py
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# Telco in-memory stores and fixture cache
|
| 10 |
+
_FIXTURE_CACHE: Dict[str, Any] = {}
|
| 11 |
+
_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
| 12 |
+
_OTP_DB: Dict[str, Dict[str, Any]] = {}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _fixtures_dir() -> Path:
|
| 16 |
+
return Path(__file__).parent / "mock_data"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _load_fixture(name: str) -> Any:
|
| 20 |
+
if name in _FIXTURE_CACHE:
|
| 21 |
+
return _FIXTURE_CACHE[name]
|
| 22 |
+
p = _fixtures_dir() / name
|
| 23 |
+
with p.open("r", encoding="utf-8") as f:
|
| 24 |
+
data = json.load(f)
|
| 25 |
+
_FIXTURE_CACHE[name] = data
|
| 26 |
+
return data
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _normalize_msisdn(msisdn: Optional[str]) -> Optional[str]:
|
| 30 |
+
if not isinstance(msisdn, str) or not msisdn.strip():
|
| 31 |
+
return None
|
| 32 |
+
s = msisdn.strip()
|
| 33 |
+
digits = ''.join(ch for ch in s if ch.isdigit() or ch == '+')
|
| 34 |
+
if digits.startswith('+'):
|
| 35 |
+
return digits
|
| 36 |
+
return f"+{digits}"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _get_customer(msisdn: str) -> Dict[str, Any]:
|
| 40 |
+
ms = _normalize_msisdn(msisdn) or ""
|
| 41 |
+
data = _load_fixture("customers.json")
|
| 42 |
+
return dict((data.get("customers", {}) or {}).get(ms, {}))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _get_package(package_id: str) -> Dict[str, Any]:
|
| 46 |
+
pkgs = _load_fixture("packages.json").get("packages", [])
|
| 47 |
+
for p in pkgs:
|
| 48 |
+
if str(p.get("id")) == str(package_id):
|
| 49 |
+
return dict(p)
|
| 50 |
+
return {}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _get_roaming_country(country_code: str) -> Dict[str, Any]:
|
| 54 |
+
data = _load_fixture("roaming_rates.json")
|
| 55 |
+
return dict((data.get("countries", {}) or {}).get((country_code or "").upper(), {}))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _mask_phone(msisdn: str) -> str:
|
| 59 |
+
s = _normalize_msisdn(msisdn) or ""
|
| 60 |
+
tail = s[-2:] if len(s) >= 2 else s
|
| 61 |
+
return f"***-***-**{tail}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# --- Identity via SMS OTP ---
|
| 65 |
+
|
| 66 |
+
def start_login(session_id: str, msisdn: str) -> Dict[str, Any]:
|
| 67 |
+
ms = _normalize_msisdn(msisdn)
|
| 68 |
+
if not ms:
|
| 69 |
+
return {"sent": False, "error": "invalid_msisdn"}
|
| 70 |
+
cust = _get_customer(ms)
|
| 71 |
+
if not cust:
|
| 72 |
+
return {"sent": False, "reason": "not_found"}
|
| 73 |
+
static = None
|
| 74 |
+
try:
|
| 75 |
+
data = _load_fixture("otps.json")
|
| 76 |
+
if isinstance(data, dict):
|
| 77 |
+
byn = data.get("by_number", {}) or {}
|
| 78 |
+
static = byn.get(ms) or data.get("default")
|
| 79 |
+
except Exception:
|
| 80 |
+
static = None
|
| 81 |
+
code = str(static or f"{uuid.uuid4().int % 1000000:06d}").zfill(6)
|
| 82 |
+
_OTP_DB[ms] = {"otp": code, "created_at": datetime.utcnow().isoformat() + "Z"}
|
| 83 |
+
_SESSIONS[session_id] = {"verified": False, "msisdn": ms}
|
| 84 |
+
resp: Dict[str, Any] = {"sent": True, "masked": _mask_phone(ms), "destination": "sms"}
|
| 85 |
+
try:
|
| 86 |
+
if os.getenv("TELCO_DEBUG_OTP", "0").lower() not in ("", "0", "false"):
|
| 87 |
+
resp["debug_code"] = code
|
| 88 |
+
except Exception:
|
| 89 |
+
pass
|
| 90 |
+
return resp
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def verify_login(session_id: str, msisdn: str, otp: str) -> Dict[str, Any]:
|
| 94 |
+
ms = _normalize_msisdn(msisdn) or ""
|
| 95 |
+
rec = _OTP_DB.get(ms) or {}
|
| 96 |
+
ok = str(rec.get("otp")) == str(otp)
|
| 97 |
+
sess = _SESSIONS.get(session_id) or {"verified": False}
|
| 98 |
+
if ok:
|
| 99 |
+
rec["used_at"] = datetime.utcnow().isoformat() + "Z"
|
| 100 |
+
_OTP_DB[ms] = rec
|
| 101 |
+
sess["verified"] = True
|
| 102 |
+
sess["msisdn"] = ms
|
| 103 |
+
_SESSIONS[session_id] = sess
|
| 104 |
+
return {"session_id": session_id, "verified": ok, "msisdn": ms}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# --- Customer and package information ---
|
| 108 |
+
|
| 109 |
+
def get_current_package(msisdn: str) -> Dict[str, Any]:
|
| 110 |
+
cust = _get_customer(msisdn)
|
| 111 |
+
if not cust:
|
| 112 |
+
return {"error": "not_found"}
|
| 113 |
+
pkg = _get_package(cust.get("package_id", ""))
|
| 114 |
+
return {
|
| 115 |
+
"msisdn": _normalize_msisdn(msisdn),
|
| 116 |
+
"package": pkg,
|
| 117 |
+
"contract": cust.get("contract"),
|
| 118 |
+
"addons": list(cust.get("addons", [])),
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def get_data_balance(msisdn: str) -> Dict[str, Any]:
|
| 123 |
+
cust = _get_customer(msisdn)
|
| 124 |
+
if not cust:
|
| 125 |
+
return {"error": "not_found"}
|
| 126 |
+
pkg = _get_package(cust.get("package_id", ""))
|
| 127 |
+
usage = (cust.get("usage", {}) or {}).get("current_month", {})
|
| 128 |
+
included = float(pkg.get("data_gb", 0)) if not bool(pkg.get("unlimited", False)) else None
|
| 129 |
+
used = float(usage.get("data_gb_used", 0.0))
|
| 130 |
+
remaining = None if included is None else max(0.0, included - used)
|
| 131 |
+
return {
|
| 132 |
+
"msisdn": _normalize_msisdn(msisdn),
|
| 133 |
+
"unlimited": bool(pkg.get("unlimited", False)),
|
| 134 |
+
"included_gb": included,
|
| 135 |
+
"used_gb": round(used, 2),
|
| 136 |
+
"remaining_gb": (None if remaining is None else round(remaining, 2)),
|
| 137 |
+
"resets_day": int((cust.get("billing", {}) or {}).get("cycle_day", 1)),
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def list_available_packages() -> List[Dict[str, Any]]:
|
| 142 |
+
return list(_load_fixture("packages.json").get("packages", []))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _estimate_monthly_cost_for_usage(pkg: Dict[str, Any], avg_data_gb: float, avg_minutes: int, avg_sms: int) -> float:
|
| 146 |
+
if bool(pkg.get("unlimited", False)):
|
| 147 |
+
return float(pkg.get("monthly_fee", 0.0))
|
| 148 |
+
fee = float(pkg.get("monthly_fee", 0.0))
|
| 149 |
+
data_over = max(0.0, avg_data_gb - float(pkg.get("data_gb", 0.0)))
|
| 150 |
+
min_over = max(0, avg_minutes - int(pkg.get("minutes", 0)))
|
| 151 |
+
sms_over = max(0, avg_sms - int(pkg.get("sms", 0)))
|
| 152 |
+
rates = pkg.get("overage", {}) or {}
|
| 153 |
+
over = data_over * float(rates.get("per_gb", 0.0)) + min_over * float(rates.get("per_min", 0.0)) + sms_over * float(rates.get("per_sms", 0.0))
|
| 154 |
+
return round(fee + over, 2)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def recommend_packages(msisdn: str, preferences: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 158 |
+
cust = _get_customer(msisdn)
|
| 159 |
+
if not cust:
|
| 160 |
+
return {"error": "not_found"}
|
| 161 |
+
prefs = preferences or {}
|
| 162 |
+
hist = (cust.get("usage", {}) or {}).get("history", [])
|
| 163 |
+
if hist:
|
| 164 |
+
last = hist[-3:]
|
| 165 |
+
avg_data = sum(float(m.get("data_gb", 0)) for m in last) / len(last)
|
| 166 |
+
avg_min = int(sum(int(m.get("minutes", 0)) for m in last) / len(last))
|
| 167 |
+
avg_sms = int(sum(int(m.get("sms", 0)) for m in last) / len(last))
|
| 168 |
+
else:
|
| 169 |
+
avg = (cust.get("usage", {}) or {}).get("monthly_avg", {})
|
| 170 |
+
avg_data = float(avg.get("data_gb", 10.0))
|
| 171 |
+
avg_min = int(avg.get("minutes", 300))
|
| 172 |
+
avg_sms = int(avg.get("sms", 100))
|
| 173 |
+
wants_5g = bool(prefs.get("need_5g", False))
|
| 174 |
+
travel_country = (prefs.get("travel_country") or "").upper()
|
| 175 |
+
budget = float(prefs.get("budget", 9999))
|
| 176 |
+
|
| 177 |
+
pkgs = list_available_packages()
|
| 178 |
+
scored: List[Tuple[float, Dict[str, Any], Dict[str, Any]]] = []
|
| 179 |
+
for p in pkgs:
|
| 180 |
+
if wants_5g and not bool(p.get("fiveg", False)):
|
| 181 |
+
continue
|
| 182 |
+
if budget < float(p.get("monthly_fee", 0.0)):
|
| 183 |
+
continue
|
| 184 |
+
est = _estimate_monthly_cost_for_usage(p, avg_data, avg_min, avg_sms)
|
| 185 |
+
feature_bonus = 0.0
|
| 186 |
+
if travel_country:
|
| 187 |
+
if travel_country in set(p.get("roam_included_countries", [])):
|
| 188 |
+
feature_bonus -= 5.0
|
| 189 |
+
if bool(p.get("data_rollover", False)):
|
| 190 |
+
feature_bonus -= 1.0
|
| 191 |
+
score = est + feature_bonus
|
| 192 |
+
rationale = f"Estimated monthly cost {est:.2f}; {'5G' if p.get('fiveg') else '4G'}; "
|
| 193 |
+
if travel_country:
|
| 194 |
+
rationale += ("roam-included" if travel_country in (p.get("roam_included_countries") or []) else "roam-paygo")
|
| 195 |
+
scored.append((score, p, {"estimated_cost": est, "rationale": rationale}))
|
| 196 |
+
|
| 197 |
+
scored.sort(key=lambda x: x[0])
|
| 198 |
+
top = [
|
| 199 |
+
{
|
| 200 |
+
"package": pkg,
|
| 201 |
+
"estimated_monthly_cost": meta.get("estimated_cost"),
|
| 202 |
+
"rationale": meta.get("rationale"),
|
| 203 |
+
}
|
| 204 |
+
for _, pkg, meta in scored[:3]
|
| 205 |
+
]
|
| 206 |
+
return {
|
| 207 |
+
"msisdn": _normalize_msisdn(msisdn),
|
| 208 |
+
"based_on": {
|
| 209 |
+
"avg_data_gb": round(avg_data, 2),
|
| 210 |
+
"avg_minutes": avg_min,
|
| 211 |
+
"avg_sms": avg_sms,
|
| 212 |
+
"wants_5g": wants_5g,
|
| 213 |
+
"travel_country": travel_country or None,
|
| 214 |
+
"budget": budget
|
| 215 |
+
},
|
| 216 |
+
"recommendations": top,
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def get_roaming_info(msisdn: str, country_code: str) -> Dict[str, Any]:
|
| 221 |
+
cust = _get_customer(msisdn)
|
| 222 |
+
if not cust:
|
| 223 |
+
return {"error": "not_found"}
|
| 224 |
+
pkg = _get_package(cust.get("package_id", ""))
|
| 225 |
+
country = _get_roaming_country(country_code)
|
| 226 |
+
included = country_code.upper() in set((pkg.get("roam_included_countries") or [])) or (bool(pkg.get("eu_roaming", False)) and country.get("region") == "EU")
|
| 227 |
+
info = {
|
| 228 |
+
"country": country_code.upper(),
|
| 229 |
+
"included": bool(included),
|
| 230 |
+
"paygo": country.get("paygo"),
|
| 231 |
+
"passes": country.get("passes", []),
|
| 232 |
+
}
|
| 233 |
+
return {"msisdn": _normalize_msisdn(msisdn), "package": {"id": pkg.get("id"), "name": pkg.get("name")}, "roaming": info}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def close_contract(msisdn: str, confirm: bool = False) -> Dict[str, Any]:
|
| 237 |
+
cust = _get_customer(msisdn)
|
| 238 |
+
if not cust:
|
| 239 |
+
return {"error": "not_found"}
|
| 240 |
+
contract = dict(cust.get("contract", {}))
|
| 241 |
+
if contract.get("status") == "closed":
|
| 242 |
+
return {"status": "already_closed"}
|
| 243 |
+
try:
|
| 244 |
+
end = contract.get("end_date")
|
| 245 |
+
future = datetime.fromisoformat(end) if isinstance(end, str) and end else datetime.max
|
| 246 |
+
except Exception:
|
| 247 |
+
future = datetime.max
|
| 248 |
+
fee = float(contract.get("early_termination_fee", 0.0)) if future > datetime.utcnow() else 0.0
|
| 249 |
+
summary = {
|
| 250 |
+
"msisdn": _normalize_msisdn(msisdn),
|
| 251 |
+
"current_status": contract.get("status", "active"),
|
| 252 |
+
"early_termination_fee": round(fee, 2),
|
| 253 |
+
"will_cancel": bool(confirm),
|
| 254 |
+
}
|
| 255 |
+
if confirm:
|
| 256 |
+
contract["status"] = "closed"
|
| 257 |
+
contract["closed_at"] = datetime.utcnow().isoformat() + "Z"
|
| 258 |
+
cust["contract"] = contract
|
| 259 |
+
data = _load_fixture("customers.json")
|
| 260 |
+
ms = _normalize_msisdn(msisdn)
|
| 261 |
+
try:
|
| 262 |
+
data.setdefault("customers", {})[ms] = cust
|
| 263 |
+
_FIXTURE_CACHE["customers.json"] = data
|
| 264 |
+
except Exception:
|
| 265 |
+
pass
|
| 266 |
+
summary["new_status"] = "closed"
|
| 267 |
+
return summary
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# --- Extended utilities ---
|
| 271 |
+
|
| 272 |
+
def list_addons(msisdn: str) -> Dict[str, Any]:
|
| 273 |
+
cust = _get_customer(msisdn)
|
| 274 |
+
if not cust:
|
| 275 |
+
return {"error": "not_found"}
|
| 276 |
+
return {"msisdn": _normalize_msisdn(msisdn), "addons": list(cust.get("addons", []))}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def purchase_roaming_pass(msisdn: str, country_code: str, pass_id: str) -> Dict[str, Any]:
|
| 280 |
+
cust = _get_customer(msisdn)
|
| 281 |
+
if not cust:
|
| 282 |
+
return {"error": "not_found"}
|
| 283 |
+
country = _get_roaming_country(country_code)
|
| 284 |
+
passes = country.get("passes", [])
|
| 285 |
+
sel = None
|
| 286 |
+
for p in passes:
|
| 287 |
+
if str(p.get("id")) == str(pass_id):
|
| 288 |
+
sel = p
|
| 289 |
+
break
|
| 290 |
+
if not sel:
|
| 291 |
+
return {"error": "invalid_pass"}
|
| 292 |
+
valid_days = int(sel.get("valid_days", 1))
|
| 293 |
+
addon = {
|
| 294 |
+
"type": "roaming_pass",
|
| 295 |
+
"country": (country_code or "").upper(),
|
| 296 |
+
"data_mb": int(sel.get("data_mb", 0)),
|
| 297 |
+
"price": float(sel.get("price", 0.0)),
|
| 298 |
+
"purchased_at": datetime.utcnow().isoformat() + "Z",
|
| 299 |
+
"expires": (datetime.utcnow() + timedelta(days=valid_days)).date().isoformat()
|
| 300 |
+
}
|
| 301 |
+
try:
|
| 302 |
+
cust.setdefault("addons", []).append(addon)
|
| 303 |
+
data = _load_fixture("customers.json")
|
| 304 |
+
ms = _normalize_msisdn(msisdn)
|
| 305 |
+
data.setdefault("customers", {})[ms] = cust
|
| 306 |
+
_FIXTURE_CACHE["customers.json"] = data
|
| 307 |
+
except Exception:
|
| 308 |
+
pass
|
| 309 |
+
return {"msisdn": _normalize_msisdn(msisdn), "added": addon}
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def change_package(msisdn: str, package_id: str, effective: str = "next_cycle") -> Dict[str, Any]:
|
| 313 |
+
cust = _get_customer(msisdn)
|
| 314 |
+
if not cust:
|
| 315 |
+
return {"error": "not_found"}
|
| 316 |
+
new_pkg = _get_package(package_id)
|
| 317 |
+
if not new_pkg:
|
| 318 |
+
return {"error": "invalid_package"}
|
| 319 |
+
effective_when = (effective or "next_cycle").lower()
|
| 320 |
+
if effective_when not in ("now", "next_cycle"):
|
| 321 |
+
effective_when = "next_cycle"
|
| 322 |
+
summary = {
|
| 323 |
+
"msisdn": _normalize_msisdn(msisdn),
|
| 324 |
+
"current_package_id": cust.get("package_id"),
|
| 325 |
+
"new_package_id": new_pkg.get("id"),
|
| 326 |
+
"effective": effective_when,
|
| 327 |
+
}
|
| 328 |
+
data = _load_fixture("customers.json")
|
| 329 |
+
ms = _normalize_msisdn(msisdn)
|
| 330 |
+
if effective_when == "now":
|
| 331 |
+
cust["package_id"] = new_pkg.get("id")
|
| 332 |
+
summary["status"] = "changed"
|
| 333 |
+
else:
|
| 334 |
+
contract = dict(cust.get("contract", {}))
|
| 335 |
+
contract["pending_change"] = {"package_id": new_pkg.get("id"), "requested_at": datetime.utcnow().isoformat() + "Z"}
|
| 336 |
+
cust["contract"] = contract
|
| 337 |
+
summary["status"] = "scheduled"
|
| 338 |
+
try:
|
| 339 |
+
data.setdefault("customers", {})[ms] = cust
|
| 340 |
+
_FIXTURE_CACHE["customers.json"] = data
|
| 341 |
+
except Exception:
|
| 342 |
+
pass
|
| 343 |
+
return summary
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def get_billing_summary(msisdn: str) -> Dict[str, Any]:
|
| 347 |
+
cust = _get_customer(msisdn)
|
| 348 |
+
if not cust:
|
| 349 |
+
return {"error": "not_found"}
|
| 350 |
+
pkg = _get_package(cust.get("package_id", ""))
|
| 351 |
+
bill = dict(cust.get("billing", {}))
|
| 352 |
+
monthly_fee = float(pkg.get("monthly_fee", 0.0))
|
| 353 |
+
return {
|
| 354 |
+
"msisdn": _normalize_msisdn(msisdn),
|
| 355 |
+
"last_bill_amount": bill.get("last_bill_amount"),
|
| 356 |
+
"cycle_day": bill.get("cycle_day"),
|
| 357 |
+
"monthly_fee": monthly_fee,
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def set_data_alerts(msisdn: str, threshold_percent: Optional[int] = None, threshold_gb: Optional[float] = None) -> Dict[str, Any]:
|
| 362 |
+
if threshold_percent is None and threshold_gb is None:
|
| 363 |
+
return {"error": "invalid_threshold"}
|
| 364 |
+
cust = _get_customer(msisdn)
|
| 365 |
+
if not cust:
|
| 366 |
+
return {"error": "not_found"}
|
| 367 |
+
alerts = dict(cust.get("alerts", {}))
|
| 368 |
+
if isinstance(threshold_percent, int):
|
| 369 |
+
alerts["data_threshold_percent"] = max(1, min(100, threshold_percent))
|
| 370 |
+
if isinstance(threshold_gb, (int, float)):
|
| 371 |
+
alerts["data_threshold_gb"] = max(0.1, float(threshold_gb))
|
| 372 |
+
cust["alerts"] = alerts
|
| 373 |
+
try:
|
| 374 |
+
data = _load_fixture("customers.json")
|
| 375 |
+
ms = _normalize_msisdn(msisdn)
|
| 376 |
+
data.setdefault("customers", {})[ms] = cust
|
| 377 |
+
_FIXTURE_CACHE["customers.json"] = data
|
| 378 |
+
except Exception:
|
| 379 |
+
pass
|
| 380 |
+
return {"msisdn": _normalize_msisdn(msisdn), "alerts": alerts}
|
| 381 |
+
|
| 382 |
+
import os
|
| 383 |
+
import json
|
| 384 |
+
import uuid
|
| 385 |
+
from datetime import datetime
|
| 386 |
+
import os
|
| 387 |
+
from pathlib import Path
|
| 388 |
+
from typing import Any, Dict, List, Optional
|
| 389 |
+
|
| 390 |
+
from langchain_openai import ChatOpenAI
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
_FIXTURE_CACHE: Dict[str, Any] = {}
|
| 394 |
+
_DISPUTES_DB: Dict[str, Dict[str, Any]] = {}
|
| 395 |
+
_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
| 396 |
+
_OTP_DB: Dict[str, Dict[str, Any]] = {}
|
| 397 |
+
_QUOTES: Dict[str, Dict[str, Any]] = {}
|
| 398 |
+
_BENEFICIARIES_DB: Dict[str, List[Dict[str, Any]]] = {}
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _fixtures_dir() -> Path:
|
| 402 |
+
return Path(__file__).parent / "mock_data"
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _load_fixture(name: str) -> Any:
|
| 406 |
+
if name in _FIXTURE_CACHE:
|
| 407 |
+
return _FIXTURE_CACHE[name]
|
| 408 |
+
p = _fixtures_dir() / name
|
| 409 |
+
with p.open("r", encoding="utf-8") as f:
|
| 410 |
+
data = json.load(f)
|
| 411 |
+
_FIXTURE_CACHE[name] = data
|
| 412 |
+
return data
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def _parse_iso_date(text: Optional[str]) -> Optional[datetime]:
|
| 416 |
+
if not text:
|
| 417 |
+
return None
|
| 418 |
+
try:
|
| 419 |
+
return datetime.strptime(text, "%Y-%m-%d")
|
| 420 |
+
except Exception:
|
| 421 |
+
return None
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def _get_customer_blob(customer_id: str) -> Dict[str, Any]:
|
| 425 |
+
data = _load_fixture("accounts.json")
|
| 426 |
+
return dict(data.get("customers", {}).get(customer_id, {}))
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def get_accounts(customer_id: str) -> List[Dict[str, Any]]:
|
| 430 |
+
cust = _get_customer_blob(customer_id)
|
| 431 |
+
if isinstance(cust, list):
|
| 432 |
+
# backward-compat: old format was a list of accounts
|
| 433 |
+
return list(cust)
|
| 434 |
+
return list(cust.get("accounts", []))
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def get_profile(customer_id: str) -> Dict[str, Any]:
|
| 438 |
+
cust = _get_customer_blob(customer_id)
|
| 439 |
+
if isinstance(cust, dict):
|
| 440 |
+
return dict(cust.get("profile", {}))
|
| 441 |
+
return {}
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def find_customer_by_name(first_name: str, last_name: str) -> Dict[str, Any]:
|
| 445 |
+
data = _load_fixture("accounts.json")
|
| 446 |
+
customers = data.get("customers", {})
|
| 447 |
+
fn = (first_name or "").strip().lower()
|
| 448 |
+
ln = (last_name or "").strip().lower()
|
| 449 |
+
for cid, blob in customers.items():
|
| 450 |
+
prof = blob.get("profile") if isinstance(blob, dict) else None
|
| 451 |
+
if isinstance(prof, dict):
|
| 452 |
+
pfn = str(prof.get("first_name") or "").strip().lower()
|
| 453 |
+
pln = str(prof.get("last_name") or "").strip().lower()
|
| 454 |
+
if fn == pfn and ln == pln:
|
| 455 |
+
return {"customer_id": cid, "profile": prof}
|
| 456 |
+
return {}
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def find_customer_by_full_name(full_name: str) -> Dict[str, Any]:
|
| 460 |
+
data = _load_fixture("accounts.json")
|
| 461 |
+
customers = data.get("customers", {})
|
| 462 |
+
target = (full_name or "").strip().lower()
|
| 463 |
+
for cid, blob in customers.items():
|
| 464 |
+
prof = blob.get("profile") if isinstance(blob, dict) else None
|
| 465 |
+
if isinstance(prof, dict):
|
| 466 |
+
fn = f"{str(prof.get('first_name') or '').strip()} {str(prof.get('last_name') or '').strip()}".strip().lower()
|
| 467 |
+
ff = str(prof.get("full_name") or "").strip().lower()
|
| 468 |
+
if target and (target == fn or target == ff):
|
| 469 |
+
return {"customer_id": cid, "profile": prof}
|
| 470 |
+
return {}
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _normalize_dob(text: Optional[str]) -> Optional[str]:
|
| 474 |
+
if not isinstance(text, str) or not text.strip():
|
| 475 |
+
return None
|
| 476 |
+
t = text.strip().lower()
|
| 477 |
+
# YYYY-MM-DD
|
| 478 |
+
try:
|
| 479 |
+
if len(t) >= 10 and t[4] == '-' and t[7] == '-':
|
| 480 |
+
d = datetime.strptime(t[:10], "%Y-%m-%d")
|
| 481 |
+
return d.strftime("%Y-%m-%d")
|
| 482 |
+
except Exception:
|
| 483 |
+
pass
|
| 484 |
+
# YYYY MM DD or YYYY/MM/DD or YYYY.MM.DD (loosely)
|
| 485 |
+
try:
|
| 486 |
+
import re as _re
|
| 487 |
+
parts = _re.findall(r"\d+", t)
|
| 488 |
+
if len(parts) >= 3 and len(parts[0]) == 4:
|
| 489 |
+
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
|
| 490 |
+
if 1900 <= y <= 2100 and 1 <= m <= 12 and 1 <= d <= 31:
|
| 491 |
+
dt = datetime(y, m, d)
|
| 492 |
+
return dt.strftime("%Y-%m-%d")
|
| 493 |
+
except Exception:
|
| 494 |
+
pass
|
| 495 |
+
# Month name DD YYYY
|
| 496 |
+
MONTHS = {
|
| 497 |
+
"jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
|
| 498 |
+
"apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
|
| 499 |
+
"aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9,
|
| 500 |
+
"oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
|
| 501 |
+
}
|
| 502 |
+
try:
|
| 503 |
+
parts = t.replace(',', ' ').split()
|
| 504 |
+
if len(parts) >= 3 and parts[0] in MONTHS:
|
| 505 |
+
m = MONTHS[parts[0]]
|
| 506 |
+
day = int(''.join(ch for ch in parts[1] if ch.isdigit()))
|
| 507 |
+
year = int(parts[2])
|
| 508 |
+
d = datetime(year, m, day)
|
| 509 |
+
return d.strftime("%Y-%m-%d")
|
| 510 |
+
except Exception:
|
| 511 |
+
pass
|
| 512 |
+
# DD/MM/YYYY or MM/DD/YYYY
|
| 513 |
+
try:
|
| 514 |
+
for sep in ('/', '-'):
|
| 515 |
+
if sep in t and t.count(sep) == 2:
|
| 516 |
+
a, b, c = t.split(sep)[:3]
|
| 517 |
+
if len(c) == 4 and a.isdigit() and b.isdigit() and c.isdigit():
|
| 518 |
+
da, db, dy = int(a), int(b), int(c)
|
| 519 |
+
# If first looks like month, assume MM/DD
|
| 520 |
+
if 1 <= da <= 12 and 1 <= db <= 31:
|
| 521 |
+
d = datetime(dy, da, db)
|
| 522 |
+
else:
|
| 523 |
+
# assume DD/MM
|
| 524 |
+
d = datetime(dy, db, da)
|
| 525 |
+
return d.strftime("%Y-%m-%d")
|
| 526 |
+
except Exception:
|
| 527 |
+
pass
|
| 528 |
+
return None
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def _find_account_by_id(account_id: str) -> Optional[Dict[str, Any]]:
|
| 532 |
+
data = _load_fixture("accounts.json")
|
| 533 |
+
customers = data.get("customers", {})
|
| 534 |
+
for _, blob in customers.items():
|
| 535 |
+
accts = (blob or {}).get("accounts", [])
|
| 536 |
+
for a in accts or []:
|
| 537 |
+
if str(a.get("account_id")) == account_id:
|
| 538 |
+
return a
|
| 539 |
+
return None
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def get_account_balance(account_id: str) -> Dict[str, Any]:
|
| 543 |
+
acc = _find_account_by_id(account_id) or {}
|
| 544 |
+
return {
|
| 545 |
+
"account_id": account_id,
|
| 546 |
+
"currency": acc.get("currency"),
|
| 547 |
+
"balance": float(acc.get("balance", 0.0)),
|
| 548 |
+
"daily_wire_limit": float(acc.get("daily_wire_limit", 0.0)),
|
| 549 |
+
"wire_enabled": bool(acc.get("wire_enabled", False)),
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
def get_exchange_rate(from_currency: str, to_currency: str, amount: float) -> Dict[str, Any]:
|
| 554 |
+
if from_currency.upper() == to_currency.upper():
|
| 555 |
+
return {
|
| 556 |
+
"from": from_currency.upper(),
|
| 557 |
+
"to": to_currency.upper(),
|
| 558 |
+
"mid_rate": 1.0,
|
| 559 |
+
"applied_rate": 1.0,
|
| 560 |
+
"margin_bps": 0,
|
| 561 |
+
"converted_amount": round(float(amount), 2),
|
| 562 |
+
}
|
| 563 |
+
data = _load_fixture("exchange_rates.json")
|
| 564 |
+
pairs = data.get("pairs", [])
|
| 565 |
+
mid = None
|
| 566 |
+
bps = 150
|
| 567 |
+
fc = from_currency.upper()
|
| 568 |
+
tc = to_currency.upper()
|
| 569 |
+
for p in pairs:
|
| 570 |
+
if str(p.get("from")).upper() == fc and str(p.get("to")).upper() == tc:
|
| 571 |
+
mid = float(p.get("mid_rate"))
|
| 572 |
+
bps = int(p.get("margin_bps", bps))
|
| 573 |
+
break
|
| 574 |
+
if mid is None:
|
| 575 |
+
# naive inverse lookup
|
| 576 |
+
for p in pairs:
|
| 577 |
+
if str(p.get("from")).upper() == tc and str(p.get("to")).upper() == fc:
|
| 578 |
+
inv = float(p.get("mid_rate"))
|
| 579 |
+
mid = 1.0 / inv if inv else None
|
| 580 |
+
bps = int(p.get("margin_bps", bps))
|
| 581 |
+
break
|
| 582 |
+
if mid is None:
|
| 583 |
+
mid = 1.0
|
| 584 |
+
applied = mid * (1.0 - bps / 10000.0)
|
| 585 |
+
converted = float(amount) * applied
|
| 586 |
+
return {
|
| 587 |
+
"from": fc,
|
| 588 |
+
"to": tc,
|
| 589 |
+
"mid_rate": round(mid, 6),
|
| 590 |
+
"applied_rate": round(applied, 6),
|
| 591 |
+
"margin_bps": bps,
|
| 592 |
+
"converted_amount": round(converted, 2),
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
def calculate_wire_fee(kind: str, amount: float, from_currency: str, to_currency: str, payer: str) -> Dict[str, Any]:
|
| 597 |
+
fees = _load_fixture("fee_schedules.json")
|
| 598 |
+
k = (kind or "").strip().upper()
|
| 599 |
+
payer_opt = (payer or "SHA").strip().upper()
|
| 600 |
+
if k not in ("DOMESTIC", "INTERNATIONAL"):
|
| 601 |
+
return {"error": "invalid_type", "message": "type must be DOMESTIC or INTERNATIONAL"}
|
| 602 |
+
if payer_opt not in ("OUR", "SHA", "BEN"):
|
| 603 |
+
return {"error": "invalid_payer", "message": "payer must be OUR, SHA, or BEN"}
|
| 604 |
+
breakdown: Dict[str, float] = {}
|
| 605 |
+
if k == "DOMESTIC":
|
| 606 |
+
breakdown["DOMESTIC_BASE"] = float(fees.get("DOMESTIC", {}).get("base_fee", 15.0))
|
| 607 |
+
else:
|
| 608 |
+
intl = fees.get("INTERNATIONAL", {})
|
| 609 |
+
breakdown["INTERNATIONAL_BASE"] = float(intl.get("base_fee", 25.0))
|
| 610 |
+
breakdown["SWIFT"] = float(intl.get("swift_network_fee", 5.0))
|
| 611 |
+
breakdown["CORRESPONDENT"] = float(intl.get("correspondent_fee", 10.0))
|
| 612 |
+
breakdown["LIFTING"] = float(intl.get("lifting_fee", 5.0))
|
| 613 |
+
|
| 614 |
+
initiator = 0.0
|
| 615 |
+
recipient = 0.0
|
| 616 |
+
for code, fee in breakdown.items():
|
| 617 |
+
if payer_opt == "OUR":
|
| 618 |
+
initiator += fee
|
| 619 |
+
elif payer_opt == "SHA":
|
| 620 |
+
# Sender pays origin bank fees (base, swift); recipient pays intermediary (correspondent/lifting)
|
| 621 |
+
if code in ("DOMESTIC_BASE", "INTERNATIONAL_BASE", "SWIFT"):
|
| 622 |
+
initiator += fee
|
| 623 |
+
else:
|
| 624 |
+
recipient += fee
|
| 625 |
+
elif payer_opt == "BEN":
|
| 626 |
+
recipient += fee
|
| 627 |
+
return {
|
| 628 |
+
"type": k,
|
| 629 |
+
"payer": payer_opt,
|
| 630 |
+
"from_currency": from_currency.upper(),
|
| 631 |
+
"to_currency": to_currency.upper(),
|
| 632 |
+
"amount": float(amount),
|
| 633 |
+
"initiator_fees_total": round(initiator, 2),
|
| 634 |
+
"recipient_fees_total": round(recipient, 2),
|
| 635 |
+
"breakdown": {k: round(v, 2) for k, v in breakdown.items()},
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def screen_sanctions(name: str, country: str) -> Dict[str, Any]:
|
| 640 |
+
data = _load_fixture("sanctions_list.json")
|
| 641 |
+
blocked = data.get("blocked", [])
|
| 642 |
+
nm = (name or "").strip().lower()
|
| 643 |
+
cc = (country or "").strip().upper()
|
| 644 |
+
for e in blocked:
|
| 645 |
+
if str(e.get("name", "")).strip().lower() == nm and str(e.get("country", "")).strip().upper() == cc:
|
| 646 |
+
return {"cleared": False, "reason": "Sanctions match"}
|
| 647 |
+
return {"cleared": True}
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
def check_wire_limits(account_id: str, amount: float) -> Dict[str, Any]:
|
| 651 |
+
acc = _find_account_by_id(account_id) or {}
|
| 652 |
+
if not acc:
|
| 653 |
+
return {"ok": False, "reason": "account_not_found"}
|
| 654 |
+
bal = float(acc.get("balance", 0.0))
|
| 655 |
+
lim = float(acc.get("daily_wire_limit", 0.0))
|
| 656 |
+
if not bool(acc.get("wire_enabled", False)):
|
| 657 |
+
return {"ok": False, "reason": "wire_not_enabled"}
|
| 658 |
+
if amount > lim:
|
| 659 |
+
return {"ok": False, "reason": "exceeds_daily_limit", "limit": lim}
|
| 660 |
+
if amount > bal:
|
| 661 |
+
return {"ok": False, "reason": "insufficient_funds", "balance": bal}
|
| 662 |
+
return {"ok": True, "balance": bal, "limit": lim}
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
def get_cutoff_and_eta(kind: str, country: str) -> Dict[str, Any]:
|
| 666 |
+
cfg = _load_fixture("cutoff_times.json")
|
| 667 |
+
k = (kind or "").strip().upper()
|
| 668 |
+
key = "DOMESTIC" if k == "DOMESTIC" else "INTERNATIONAL"
|
| 669 |
+
info = cfg.get(key, {})
|
| 670 |
+
return {
|
| 671 |
+
"cutoff_local": info.get("cutoff_local", "17:00"),
|
| 672 |
+
"eta_hours": list(info.get("eta_hours", [24, 72])),
|
| 673 |
+
"country": country
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
def get_country_requirements(code: str) -> List[str]:
|
| 678 |
+
data = _load_fixture("country_requirements.json")
|
| 679 |
+
return list(data.get(code.upper(), []))
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def validate_beneficiary(country_code: str, beneficiary: Dict[str, Any]) -> Dict[str, Any]:
|
| 683 |
+
required = get_country_requirements(country_code)
|
| 684 |
+
missing: List[str] = []
|
| 685 |
+
for field in required:
|
| 686 |
+
if not isinstance(beneficiary.get(field), str) or not str(beneficiary.get(field)).strip():
|
| 687 |
+
missing.append(field)
|
| 688 |
+
return {"ok": len(missing) == 0, "missing": missing}
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
def save_beneficiary(customer_id: str, beneficiary: Dict[str, Any]) -> Dict[str, Any]:
|
| 692 |
+
arr = _BENEFICIARIES_DB.setdefault(customer_id, [])
|
| 693 |
+
bid = beneficiary.get("beneficiary_id") or f"B-{uuid.uuid4().hex[:6]}"
|
| 694 |
+
entry = dict(beneficiary)
|
| 695 |
+
entry["beneficiary_id"] = bid
|
| 696 |
+
arr.append(entry)
|
| 697 |
+
return {"beneficiary_id": bid}
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
def generate_otp(customer_id: str) -> Dict[str, Any]:
|
| 701 |
+
# Prefer static OTP from fixture for predictable testing
|
| 702 |
+
static = None
|
| 703 |
+
try:
|
| 704 |
+
data = _load_fixture("otps.json")
|
| 705 |
+
if isinstance(data, dict):
|
| 706 |
+
byc = data.get("by_customer", {}) or {}
|
| 707 |
+
static = byc.get(customer_id) or data.get("default")
|
| 708 |
+
except Exception:
|
| 709 |
+
static = None
|
| 710 |
+
code = str(static or f"{uuid.uuid4().int % 1000000:06d}").zfill(6)
|
| 711 |
+
_OTP_DB[customer_id] = {"otp": code, "created_at": datetime.utcnow().isoformat() + "Z"}
|
| 712 |
+
# In real world, send to phone/email; here we mask
|
| 713 |
+
resp = {"sent": True, "destination": "on-file", "masked": "***-***-****"}
|
| 714 |
+
try:
|
| 715 |
+
if os.getenv("WIRE_DEBUG_OTP", "0").lower() not in ("", "0", "false"): # dev convenience
|
| 716 |
+
resp["debug_code"] = code
|
| 717 |
+
except Exception:
|
| 718 |
+
pass
|
| 719 |
+
return resp
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
def verify_otp(customer_id: str, otp: str) -> Dict[str, Any]:
|
| 723 |
+
rec = _OTP_DB.get(customer_id) or {}
|
| 724 |
+
ok = str(rec.get("otp")) == str(otp)
|
| 725 |
+
if ok:
|
| 726 |
+
rec["used_at"] = datetime.utcnow().isoformat() + "Z"
|
| 727 |
+
_OTP_DB[customer_id] = rec
|
| 728 |
+
return {"verified": ok}
|
| 729 |
+
|
| 730 |
+
|
| 731 |
+
def authenticate_user_wire(session_id: str, customer_id: Optional[str], full_name: Optional[str], dob_yyyy_mm_dd: Optional[str], ssn_last4: Optional[str], secret_answer: Optional[str]) -> Dict[str, Any]:
|
| 732 |
+
session = _SESSIONS.get(session_id) or {"verified": False, "customer_id": customer_id, "name": full_name}
|
| 733 |
+
if isinstance(customer_id, str) and customer_id:
|
| 734 |
+
session["customer_id"] = customer_id
|
| 735 |
+
if isinstance(full_name, str) and full_name:
|
| 736 |
+
session["name"] = full_name
|
| 737 |
+
if isinstance(dob_yyyy_mm_dd, str) and dob_yyyy_mm_dd:
|
| 738 |
+
session["dob"] = dob_yyyy_mm_dd
|
| 739 |
+
if isinstance(ssn_last4, str) and ssn_last4:
|
| 740 |
+
session["ssn_last4"] = ssn_last4
|
| 741 |
+
if isinstance(secret_answer, str) and secret_answer:
|
| 742 |
+
session["secret"] = secret_answer
|
| 743 |
+
|
| 744 |
+
ok = False
|
| 745 |
+
cid = session.get("customer_id")
|
| 746 |
+
if isinstance(cid, str):
|
| 747 |
+
prof = get_profile(cid)
|
| 748 |
+
user_dob_norm = _normalize_dob(session.get("dob"))
|
| 749 |
+
prof_dob_norm = _normalize_dob(prof.get("dob"))
|
| 750 |
+
dob_ok = (user_dob_norm is not None) and (user_dob_norm == prof_dob_norm)
|
| 751 |
+
ssn_ok = str(session.get("ssn_last4") or "") == str(prof.get("ssn_last4") or "")
|
| 752 |
+
def _norm(x: Optional[str]) -> str:
|
| 753 |
+
return (x or "").strip().lower()
|
| 754 |
+
secret_ok = _norm(session.get("secret")) == _norm(prof.get("secret_answer"))
|
| 755 |
+
if dob_ok and (ssn_ok or secret_ok):
|
| 756 |
+
ok = True
|
| 757 |
+
session["verified"] = ok
|
| 758 |
+
_SESSIONS[session_id] = session
|
| 759 |
+
need: List[str] = []
|
| 760 |
+
if _normalize_dob(session.get("dob")) is None:
|
| 761 |
+
need.append("dob")
|
| 762 |
+
if not session.get("ssn_last4") and not session.get("secret"):
|
| 763 |
+
need.append("ssn_last4_or_secret")
|
| 764 |
+
if not session.get("customer_id"):
|
| 765 |
+
need.append("customer")
|
| 766 |
+
resp: Dict[str, Any] = {"session_id": session_id, "verified": ok, "needs": need, "profile": {"name": session.get("name")}}
|
| 767 |
+
try:
|
| 768 |
+
if isinstance(session.get("customer_id"), str):
|
| 769 |
+
prof = get_profile(session.get("customer_id"))
|
| 770 |
+
if isinstance(prof, dict) and prof.get("secret_question"):
|
| 771 |
+
resp["question"] = prof.get("secret_question")
|
| 772 |
+
except Exception:
|
| 773 |
+
pass
|
| 774 |
+
return resp
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
def quote_wire(kind: str, from_account_id: str, beneficiary: Dict[str, Any], amount: float, from_currency: str, to_currency: str, payer: str) -> Dict[str, Any]:
|
| 778 |
+
# FX
|
| 779 |
+
fx = get_exchange_rate(from_currency, to_currency, amount)
|
| 780 |
+
converted_amount = fx["converted_amount"]
|
| 781 |
+
# Fees
|
| 782 |
+
fee = calculate_wire_fee(kind, amount, from_currency, to_currency, payer)
|
| 783 |
+
# Limits and balance
|
| 784 |
+
limits = check_wire_limits(from_account_id, amount)
|
| 785 |
+
if not limits.get("ok"):
|
| 786 |
+
return {"error": "limit_or_balance", "details": limits}
|
| 787 |
+
# Sanctions
|
| 788 |
+
sanc = screen_sanctions(str(beneficiary.get("account_name") or beneficiary.get("name") or ""), str(beneficiary.get("country") or ""))
|
| 789 |
+
if not sanc.get("cleared"):
|
| 790 |
+
return {"error": "sanctions", "details": sanc}
|
| 791 |
+
# ETA
|
| 792 |
+
eta = get_cutoff_and_eta(kind, str(beneficiary.get("country") or ""))
|
| 793 |
+
|
| 794 |
+
payer_opt = (payer or "SHA").upper()
|
| 795 |
+
initiator_fees = float(fee.get("initiator_fees_total", 0.0))
|
| 796 |
+
recipient_fees = float(fee.get("recipient_fees_total", 0.0))
|
| 797 |
+
net_sent = float(amount) + (initiator_fees if payer_opt in ("OUR", "SHA") else 0.0)
|
| 798 |
+
# recipient side fees reduce the amount received when SHA/BEN
|
| 799 |
+
net_received = float(converted_amount)
|
| 800 |
+
if payer_opt in ("SHA", "BEN"):
|
| 801 |
+
net_received = max(0.0, net_received - recipient_fees)
|
| 802 |
+
|
| 803 |
+
qid = f"Q-{uuid.uuid4().hex[:8]}"
|
| 804 |
+
quote = {
|
| 805 |
+
"quote_id": qid,
|
| 806 |
+
"type": kind.upper(),
|
| 807 |
+
"from_account_id": from_account_id,
|
| 808 |
+
"amount": float(amount),
|
| 809 |
+
"from_currency": from_currency.upper(),
|
| 810 |
+
"to_currency": to_currency.upper(),
|
| 811 |
+
"payer": payer_opt,
|
| 812 |
+
"fx": fx,
|
| 813 |
+
"fees": fee,
|
| 814 |
+
"net_sent": round(net_sent, 2),
|
| 815 |
+
"net_received": round(net_received, 2),
|
| 816 |
+
"eta": eta,
|
| 817 |
+
"created_at": datetime.utcnow().isoformat() + "Z",
|
| 818 |
+
"expires_at": (datetime.utcnow().isoformat() + "Z")
|
| 819 |
+
}
|
| 820 |
+
_QUOTES[qid] = quote
|
| 821 |
+
return quote
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def wire_transfer_domestic(quote_id: str, otp: str) -> Dict[str, Any]:
|
| 825 |
+
q = _QUOTES.get(quote_id)
|
| 826 |
+
if not q or q.get("type") != "DOMESTIC":
|
| 827 |
+
return {"error": "invalid_quote"}
|
| 828 |
+
# OTP expected: we need customer_id context; skip and assume OTP verified externally
|
| 829 |
+
conf = f"WD-{uuid.uuid4().hex[:8]}"
|
| 830 |
+
return {"confirmation_id": conf, "status": "submitted"}
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
def wire_transfer_international(quote_id: str, otp: str) -> Dict[str, Any]:
|
| 834 |
+
q = _QUOTES.get(quote_id)
|
| 835 |
+
if not q or q.get("type") != "INTERNATIONAL":
|
| 836 |
+
return {"error": "invalid_quote"}
|
| 837 |
+
conf = f"WI-{uuid.uuid4().hex[:8]}"
|
| 838 |
+
return {"confirmation_id": conf, "status": "submitted"}
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
def list_transactions(account_id: str, start: Optional[str], end: Optional[str]) -> List[Dict[str, Any]]:
|
| 842 |
+
data = _load_fixture("transactions.json")
|
| 843 |
+
txns = list(data.get(account_id, []))
|
| 844 |
+
if start or end:
|
| 845 |
+
start_dt = _parse_iso_date(start) or datetime.min
|
| 846 |
+
end_dt = _parse_iso_date(end) or datetime.max
|
| 847 |
+
out: List[Dict[str, Any]] = []
|
| 848 |
+
for t in txns:
|
| 849 |
+
td = _parse_iso_date(t.get("date"))
|
| 850 |
+
if td and start_dt <= td <= end_dt:
|
| 851 |
+
out.append(t)
|
| 852 |
+
return out
|
| 853 |
+
return txns
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
def get_fee_schedule(product_type: str) -> Dict[str, Any]:
|
| 857 |
+
data = _load_fixture("fee_schedules.json")
|
| 858 |
+
return dict(data.get(product_type.upper(), {}))
|
| 859 |
+
|
| 860 |
+
|
| 861 |
+
def detect_fees(transactions: List[Dict[str, Any]], schedule: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 862 |
+
results: List[Dict[str, Any]] = []
|
| 863 |
+
for t in transactions:
|
| 864 |
+
if str(t.get("entry_type")).upper() == "FEE":
|
| 865 |
+
fee_code = (t.get("fee_code") or "").upper()
|
| 866 |
+
sched_entry = None
|
| 867 |
+
for s in schedule.get("fees", []) or []:
|
| 868 |
+
if str(s.get("code", "")).upper() == fee_code:
|
| 869 |
+
sched_entry = s
|
| 870 |
+
break
|
| 871 |
+
evt = {
|
| 872 |
+
"id": t.get("id") or str(uuid.uuid4()),
|
| 873 |
+
"posted_date": t.get("date"),
|
| 874 |
+
"amount": float(t.get("amount", 0)),
|
| 875 |
+
"description": t.get("description") or fee_code,
|
| 876 |
+
"fee_code": fee_code,
|
| 877 |
+
"schedule": sched_entry or None,
|
| 878 |
+
}
|
| 879 |
+
results.append(evt)
|
| 880 |
+
try:
|
| 881 |
+
results.sort(key=lambda x: x.get("posted_date") or "")
|
| 882 |
+
except Exception:
|
| 883 |
+
pass
|
| 884 |
+
return results
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
def explain_fee(fee_event: Dict[str, Any]) -> str:
|
| 888 |
+
openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 889 |
+
code = (fee_event.get("fee_code") or "").upper()
|
| 890 |
+
name = fee_event.get("schedule", {}).get("name") or code.title()
|
| 891 |
+
posted = fee_event.get("posted_date") or ""
|
| 892 |
+
amount = float(fee_event.get("amount") or 0)
|
| 893 |
+
policy = fee_event.get("schedule", {}).get("policy") or ""
|
| 894 |
+
if not openai_api_key:
|
| 895 |
+
base = f"You were charged {name} on {posted} for CAD {amount:.2f}."
|
| 896 |
+
if code == "NSF":
|
| 897 |
+
return base + " This is applied when a payment is attempted but the account balance was insufficient."
|
| 898 |
+
if code == "MAINTENANCE":
|
| 899 |
+
return base + " This is the monthly account fee as per your account plan."
|
| 900 |
+
if code == "ATM":
|
| 901 |
+
return base + " This fee applies to certain ATM withdrawals."
|
| 902 |
+
return base + " This fee was identified based on your recent transactions."
|
| 903 |
+
|
| 904 |
+
llm = ChatOpenAI(model=os.getenv("EXPLAIN_MODEL", "gpt-4o"), api_key=openai_api_key)
|
| 905 |
+
chain = EXPLAIN_FEE_PROMPT | llm
|
| 906 |
+
out = chain.invoke(
|
| 907 |
+
{
|
| 908 |
+
"fee_code": code,
|
| 909 |
+
"posted_date": posted,
|
| 910 |
+
"amount": f"{amount:.2f}",
|
| 911 |
+
"schedule_name": name,
|
| 912 |
+
"schedule_policy": policy,
|
| 913 |
+
}
|
| 914 |
+
)
|
| 915 |
+
text = getattr(out, "content", None)
|
| 916 |
+
return text if isinstance(text, str) and text.strip() else f"You were charged {name} on {posted} for CAD {amount:.2f}."
|
| 917 |
+
|
| 918 |
+
|
| 919 |
+
def check_dispute_eligibility(fee_event: Dict[str, Any]) -> Dict[str, Any]:
|
| 920 |
+
code = (fee_event.get("fee_code") or "").upper()
|
| 921 |
+
amount = float(fee_event.get("amount", 0))
|
| 922 |
+
first_time = bool(fee_event.get("first_time_90d", False))
|
| 923 |
+
eligible = False
|
| 924 |
+
reason = ""
|
| 925 |
+
if code in {"NSF", "ATM", "MAINTENANCE", "WITHDRAWAL"} and amount <= 20.0 and first_time:
|
| 926 |
+
eligible = True
|
| 927 |
+
reason = "First occurrence in 90 days and small amount"
|
| 928 |
+
return {"eligible": eligible, "reason": reason}
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
def create_dispute_case(fee_event: Dict[str, Any], idempotency_key: str) -> Dict[str, Any]:
|
| 932 |
+
if idempotency_key in _DISPUTES_DB:
|
| 933 |
+
return _DISPUTES_DB[idempotency_key]
|
| 934 |
+
case = {
|
| 935 |
+
"case_id": str(uuid.uuid4()),
|
| 936 |
+
"status": "submitted",
|
| 937 |
+
"fee_id": fee_event.get("id"),
|
| 938 |
+
"created_at": datetime.utcnow().isoformat() + "Z",
|
| 939 |
+
}
|
| 940 |
+
_DISPUTES_DB[idempotency_key] = case
|
| 941 |
+
return case
|
| 942 |
+
|
| 943 |
+
|
| 944 |
+
def authenticate_user(session_id: str, name: Optional[str], dob_yyyy_mm_dd: Optional[str], last4: Optional[str], secret_answer: Optional[str], customer_id: Optional[str] = None) -> Dict[str, Any]:
|
| 945 |
+
"""Mock identity verification.
|
| 946 |
+
|
| 947 |
+
Rules (mock):
|
| 948 |
+
- If dob == 1990-01-01 and last4 == 6001 or secret_answer == "blue", auth succeeds.
|
| 949 |
+
- Otherwise, remains pending with which fields are still missing.
|
| 950 |
+
Persists per session_id.
|
| 951 |
+
"""
|
| 952 |
+
session = _SESSIONS.get(session_id) or {"verified": False, "name": name, "customer_id": customer_id}
|
| 953 |
+
if isinstance(name, str) and name:
|
| 954 |
+
session["name"] = name
|
| 955 |
+
if isinstance(customer_id, str) and customer_id:
|
| 956 |
+
session["customer_id"] = customer_id
|
| 957 |
+
if isinstance(dob_yyyy_mm_dd, str) and dob_yyyy_mm_dd:
|
| 958 |
+
# Normalize DOB to YYYY-MM-DD
|
| 959 |
+
norm = _normalize_dob(dob_yyyy_mm_dd)
|
| 960 |
+
session["dob"] = norm or dob_yyyy_mm_dd
|
| 961 |
+
if isinstance(last4, str) and last4:
|
| 962 |
+
session["last4"] = last4
|
| 963 |
+
if isinstance(secret_answer, str) and secret_answer:
|
| 964 |
+
session["secret"] = secret_answer
|
| 965 |
+
|
| 966 |
+
ok = False
|
| 967 |
+
# If a specific customer is in context, validate against their profile and accounts
|
| 968 |
+
if isinstance(session.get("customer_id"), str):
|
| 969 |
+
prof = get_profile(session.get("customer_id"))
|
| 970 |
+
accts = get_accounts(session.get("customer_id"))
|
| 971 |
+
dob_ok = _normalize_dob(session.get("dob")) == _normalize_dob(prof.get("dob")) and bool(session.get("dob"))
|
| 972 |
+
last4s = {str(a.get("account_number"))[-4:] for a in accts if a.get("account_number")}
|
| 973 |
+
last4_ok = isinstance(session.get("last4"), str) and session.get("last4") in last4s
|
| 974 |
+
def _norm_secret(x: Optional[str]) -> str:
|
| 975 |
+
return (x or "").strip().lower()
|
| 976 |
+
secret_ok = _norm_secret(session.get("secret")) == _norm_secret(prof.get("secret_answer"))
|
| 977 |
+
if dob_ok and (last4_ok or secret_ok):
|
| 978 |
+
ok = True
|
| 979 |
+
else:
|
| 980 |
+
# Optional demo fallback (disabled by default)
|
| 981 |
+
allow_fallback = os.getenv("RBC_FEES_ALLOW_GLOBAL_FALLBACK", "0") not in ("", "0", "false", "False")
|
| 982 |
+
if allow_fallback and session.get("dob") == "1990-01-01" and (session.get("last4") == "6001" or (session.get("secret") or "").strip().lower() == "blue"):
|
| 983 |
+
ok = True
|
| 984 |
+
session["verified"] = ok
|
| 985 |
+
_SESSIONS[session_id] = session
|
| 986 |
+
need: list[str] = []
|
| 987 |
+
if not session.get("dob"):
|
| 988 |
+
need.append("dob")
|
| 989 |
+
if not session.get("last4") and not session.get("secret"):
|
| 990 |
+
need.append("last4_or_secret")
|
| 991 |
+
if not session.get("customer_id"):
|
| 992 |
+
need.append("customer")
|
| 993 |
+
resp: Dict[str, Any] = {"session_id": session_id, "verified": ok, "needs": need, "profile": {"name": session.get("name")}}
|
| 994 |
+
try:
|
| 995 |
+
if isinstance(session.get("customer_id"), str):
|
| 996 |
+
prof = get_profile(session.get("customer_id"))
|
| 997 |
+
if isinstance(prof, dict) and prof.get("secret_question"):
|
| 998 |
+
resp["question"] = prof.get("secret_question")
|
| 999 |
+
except Exception:
|
| 1000 |
+
pass
|
| 1001 |
+
return resp
|
| 1002 |
+
|
| 1003 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/customers.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"customers": {
|
| 3 |
+
"+15551234567": {
|
| 4 |
+
"profile": {
|
| 5 |
+
"first_name": "Alex",
|
| 6 |
+
"last_name": "Lee",
|
| 7 |
+
"full_name": "Alex Lee",
|
| 8 |
+
"dob": "1988-05-22",
|
| 9 |
+
"email": "alex.lee@example.com"
|
| 10 |
+
},
|
| 11 |
+
"msisdn": "+15551234567",
|
| 12 |
+
"package_id": "P-40",
|
| 13 |
+
"contract": {
|
| 14 |
+
"status": "active",
|
| 15 |
+
"start_date": "2024-01-10",
|
| 16 |
+
"end_date": "2026-01-10",
|
| 17 |
+
"early_termination_fee": 150.0,
|
| 18 |
+
"auto_renew": true
|
| 19 |
+
},
|
| 20 |
+
"usage": {
|
| 21 |
+
"current_month": { "data_gb_used": 12.5 },
|
| 22 |
+
"monthly_avg": { "data_gb": 28.0, "minutes": 600, "sms": 200 },
|
| 23 |
+
"history": [
|
| 24 |
+
{ "month": "2025-07", "data_gb": 31.2, "minutes": 640, "sms": 210 },
|
| 25 |
+
{ "month": "2025-08", "data_gb": 27.5, "minutes": 580, "sms": 190 },
|
| 26 |
+
{ "month": "2025-09", "data_gb": 25.3, "minutes": 590, "sms": 200 }
|
| 27 |
+
]
|
| 28 |
+
},
|
| 29 |
+
"addons": [
|
| 30 |
+
{ "type": "roaming_pass", "country": "IT", "expires": "2025-09-30", "data_mb": 1024, "price": 10.0 }
|
| 31 |
+
],
|
| 32 |
+
"billing": { "cycle_day": 5, "last_bill_amount": 45.0 }
|
| 33 |
+
},
|
| 34 |
+
"+447911123456": {
|
| 35 |
+
"profile": {
|
| 36 |
+
"first_name": "Sam",
|
| 37 |
+
"last_name": "Taylor",
|
| 38 |
+
"full_name": "Sam Taylor",
|
| 39 |
+
"dob": "1992-03-14",
|
| 40 |
+
"email": "sam.taylor@example.co.uk"
|
| 41 |
+
},
|
| 42 |
+
"msisdn": "+447911123456",
|
| 43 |
+
"package_id": "P-10",
|
| 44 |
+
"contract": {
|
| 45 |
+
"status": "active",
|
| 46 |
+
"start_date": "2024-06-01",
|
| 47 |
+
"end_date": "2025-12-01",
|
| 48 |
+
"early_termination_fee": 80.0,
|
| 49 |
+
"auto_renew": false
|
| 50 |
+
},
|
| 51 |
+
"usage": {
|
| 52 |
+
"current_month": { "data_gb_used": 8.1 },
|
| 53 |
+
"monthly_avg": { "data_gb": 9.0, "minutes": 300, "sms": 90 },
|
| 54 |
+
"history": [
|
| 55 |
+
{ "month": "2025-07", "data_gb": 10.2, "minutes": 320, "sms": 100 },
|
| 56 |
+
{ "month": "2025-08", "data_gb": 8.5, "minutes": 280, "sms": 85 },
|
| 57 |
+
{ "month": "2025-09", "data_gb": 8.3, "minutes": 305, "sms": 88 }
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
"addons": [],
|
| 61 |
+
"billing": { "cycle_day": 12, "last_bill_amount": 29.0 }
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/otps.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"default": "123456",
|
| 3 |
+
"by_customer": {
|
| 4 |
+
"cust_test": "123456",
|
| 5 |
+
"cust_alice": "123456",
|
| 6 |
+
"cust_bob": "123456",
|
| 7 |
+
"cust_carla": "123456",
|
| 8 |
+
"cust_dave": "123456",
|
| 9 |
+
"cust_eve": "123456"
|
| 10 |
+
},
|
| 11 |
+
"by_number": {
|
| 12 |
+
"+15551234567": "246810",
|
| 13 |
+
"+447911123456": "135790"
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/packages.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"packages": [
|
| 3 |
+
{
|
| 4 |
+
"id": "P-10",
|
| 5 |
+
"name": "Lite 10GB 4G",
|
| 6 |
+
"monthly_fee": 25.0,
|
| 7 |
+
"data_gb": 10,
|
| 8 |
+
"minutes": 500,
|
| 9 |
+
"sms": 500,
|
| 10 |
+
"fiveg": false,
|
| 11 |
+
"data_rollover": true,
|
| 12 |
+
"eu_roaming": false,
|
| 13 |
+
"roam_included_countries": [],
|
| 14 |
+
"overage": { "per_gb": 8.0, "per_min": 0.05, "per_sms": 0.02 }
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"id": "P-40",
|
| 18 |
+
"name": "Standard 40GB 5G",
|
| 19 |
+
"monthly_fee": 40.0,
|
| 20 |
+
"data_gb": 40,
|
| 21 |
+
"minutes": 1000,
|
| 22 |
+
"sms": 1000,
|
| 23 |
+
"fiveg": true,
|
| 24 |
+
"data_rollover": false,
|
| 25 |
+
"eu_roaming": true,
|
| 26 |
+
"roam_included_countries": ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE"],
|
| 27 |
+
"overage": { "per_gb": 6.0, "per_min": 0.03, "per_sms": 0.02 }
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"id": "P-UNL-B",
|
| 31 |
+
"name": "Unlimited Basic 5G (100GB high-speed)",
|
| 32 |
+
"monthly_fee": 55.0,
|
| 33 |
+
"unlimited": true,
|
| 34 |
+
"highspeed_cap_gb": 100,
|
| 35 |
+
"throttle_mbps": 3,
|
| 36 |
+
"minutes": 2000,
|
| 37 |
+
"sms": 2000,
|
| 38 |
+
"fiveg": true,
|
| 39 |
+
"eu_roaming": true,
|
| 40 |
+
"roam_included_countries": ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE"],
|
| 41 |
+
"overage": { "per_gb": 0.0, "per_min": 0.0, "per_sms": 0.0 }
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"id": "P-UNL-P",
|
| 45 |
+
"name": "Unlimited Premium 5G (300GB high-speed)",
|
| 46 |
+
"monthly_fee": 70.0,
|
| 47 |
+
"unlimited": true,
|
| 48 |
+
"highspeed_cap_gb": 300,
|
| 49 |
+
"throttle_mbps": 10,
|
| 50 |
+
"minutes": 5000,
|
| 51 |
+
"sms": 5000,
|
| 52 |
+
"fiveg": true,
|
| 53 |
+
"eu_roaming": true,
|
| 54 |
+
"roam_included_countries": ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE"],
|
| 55 |
+
"overage": { "per_gb": 0.0, "per_min": 0.0, "per_sms": 0.0 }
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"id": "P-TRAVEL-EU-20",
|
| 59 |
+
"name": "Travelers EU 20GB",
|
| 60 |
+
"monthly_fee": 45.0,
|
| 61 |
+
"data_gb": 20,
|
| 62 |
+
"minutes": 1000,
|
| 63 |
+
"sms": 1000,
|
| 64 |
+
"fiveg": true,
|
| 65 |
+
"data_rollover": false,
|
| 66 |
+
"eu_roaming": true,
|
| 67 |
+
"roam_included_countries": ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE"],
|
| 68 |
+
"overage": { "per_gb": 6.0, "per_min": 0.03, "per_sms": 0.02 }
|
| 69 |
+
}
|
| 70 |
+
]
|
| 71 |
+
}
|
| 72 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/roaming_rates.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"countries": {
|
| 3 |
+
"IT": {
|
| 4 |
+
"region": "EU",
|
| 5 |
+
"paygo": { "data_per_mb": 0.02, "call_per_min": 0.05, "sms": 0.02 },
|
| 6 |
+
"passes": [
|
| 7 |
+
{ "id": "EU-DAY-1GB", "name": "EU Day Pass 1GB", "price": 5.0, "data_mb": 1024, "valid_days": 1 },
|
| 8 |
+
{ "id": "EU-WEEK-5GB", "name": "EU Week Pass 5GB", "price": 15.0, "data_mb": 5120, "valid_days": 7 }
|
| 9 |
+
]
|
| 10 |
+
},
|
| 11 |
+
"FR": {
|
| 12 |
+
"region": "EU",
|
| 13 |
+
"paygo": { "data_per_mb": 0.02, "call_per_min": 0.05, "sms": 0.02 },
|
| 14 |
+
"passes": [
|
| 15 |
+
{ "id": "EU-DAY-1GB", "name": "EU Day Pass 1GB", "price": 5.0, "data_mb": 1024, "valid_days": 1 },
|
| 16 |
+
{ "id": "EU-WEEK-5GB", "name": "EU Week Pass 5GB", "price": 15.0, "data_mb": 5120, "valid_days": 7 }
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
"US": {
|
| 20 |
+
"region": "NA",
|
| 21 |
+
"paygo": { "data_per_mb": 0.15, "call_per_min": 0.20, "sms": 0.10 },
|
| 22 |
+
"passes": [
|
| 23 |
+
{ "id": "NA-DAY-500MB", "name": "North America Day 500MB", "price": 7.0, "data_mb": 512, "valid_days": 1 },
|
| 24 |
+
{ "id": "NA-WEEK-2GB", "name": "North America Week 2GB", "price": 20.0, "data_mb": 2048, "valid_days": 7 }
|
| 25 |
+
]
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/prompts.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 2 |
+
|
| 3 |
+
# Generic short explanation prompt adapted for telco contexts (kept for parity)
|
| 4 |
+
EXPLAIN_FEE_PROMPT = ChatPromptTemplate.from_messages([
|
| 5 |
+
(
|
| 6 |
+
"system",
|
| 7 |
+
"""
|
| 8 |
+
You are a warm, helpful phone assistant. Use a friendly, empathetic tone.
|
| 9 |
+
Guidelines:
|
| 10 |
+
- Keep it concise (2-3 sentences), plain language, no jargon.
|
| 11 |
+
- Offer help-oriented phrasing ("we can check options"), no blame.
|
| 12 |
+
- TTS SAFETY: Output must be plain text. Do not use markdown, bullets, asterisks, emojis, or special typography. Use only ASCII punctuation and straight quotes.
|
| 13 |
+
""",
|
| 14 |
+
),
|
| 15 |
+
(
|
| 16 |
+
"human",
|
| 17 |
+
"""
|
| 18 |
+
Context:
|
| 19 |
+
- code: {fee_code}
|
| 20 |
+
- posted_date: {posted_date}
|
| 21 |
+
- amount: {amount}
|
| 22 |
+
- schedule_name: {schedule_name}
|
| 23 |
+
- schedule_policy: {schedule_policy}
|
| 24 |
+
|
| 25 |
+
Write a concise explanation (2-3 sentences) suitable for a phone TTS.
|
| 26 |
+
""",
|
| 27 |
+
),
|
| 28 |
+
])
|
| 29 |
+
|
| 30 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/react_agent.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List
|
| 8 |
+
|
| 9 |
+
from langgraph.func import entrypoint, task
|
| 10 |
+
from langgraph.graph import add_messages
|
| 11 |
+
from langchain_openai import ChatOpenAI
|
| 12 |
+
from langchain_core.messages import (
|
| 13 |
+
SystemMessage,
|
| 14 |
+
HumanMessage,
|
| 15 |
+
AIMessage,
|
| 16 |
+
BaseMessage,
|
| 17 |
+
ToolCall,
|
| 18 |
+
ToolMessage,
|
| 19 |
+
)
|
| 20 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 21 |
+
from langgraph.store.base import BaseStore
|
| 22 |
+
from langgraph.config import RunnableConfig
|
| 23 |
+
from langchain_core.runnables.config import ensure_config
|
| 24 |
+
from langgraph.config import get_store
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ---- Tools (telco) ----
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
from . import tools as telco_tools # type: ignore
|
| 31 |
+
except Exception:
|
| 32 |
+
import importlib.util as _ilu
|
| 33 |
+
_dir = os.path.dirname(__file__)
|
| 34 |
+
_tools_path = os.path.join(_dir, "tools.py")
|
| 35 |
+
_spec = _ilu.spec_from_file_location("telco_agent_tools", _tools_path)
|
| 36 |
+
telco_tools = _ilu.module_from_spec(_spec) # type: ignore
|
| 37 |
+
assert _spec and _spec.loader
|
| 38 |
+
_spec.loader.exec_module(telco_tools) # type: ignore
|
| 39 |
+
|
| 40 |
+
# Aliases for tool functions
|
| 41 |
+
start_login_tool = telco_tools.start_login_tool
|
| 42 |
+
verify_login_tool = telco_tools.verify_login_tool
|
| 43 |
+
get_current_package_tool = telco_tools.get_current_package_tool
|
| 44 |
+
get_data_balance_tool = telco_tools.get_data_balance_tool
|
| 45 |
+
list_available_packages_tool = telco_tools.list_available_packages_tool
|
| 46 |
+
recommend_packages_tool = telco_tools.recommend_packages_tool
|
| 47 |
+
get_roaming_info_tool = telco_tools.get_roaming_info_tool
|
| 48 |
+
close_contract_tool = telco_tools.close_contract_tool
|
| 49 |
+
list_addons_tool = telco_tools.list_addons_tool
|
| 50 |
+
purchase_roaming_pass_tool = telco_tools.purchase_roaming_pass_tool
|
| 51 |
+
change_package_tool = telco_tools.change_package_tool
|
| 52 |
+
get_billing_summary_tool = telco_tools.get_billing_summary_tool
|
| 53 |
+
set_data_alerts_tool = telco_tools.set_data_alerts_tool
|
| 54 |
+
check_status = telco_tools.check_status
|
| 55 |
+
|
| 56 |
+
# Import helper functions
|
| 57 |
+
try:
|
| 58 |
+
from ..helper_functions import write_status, reset_status
|
| 59 |
+
except Exception:
|
| 60 |
+
import importlib.util as _ilu
|
| 61 |
+
_dir = os.path.dirname(os.path.dirname(__file__))
|
| 62 |
+
_helper_path = os.path.join(_dir, "helper_functions.py")
|
| 63 |
+
_spec = _ilu.spec_from_file_location("helper_functions", _helper_path)
|
| 64 |
+
_helper_module = _ilu.module_from_spec(_spec) # type: ignore
|
| 65 |
+
assert _spec and _spec.loader
|
| 66 |
+
_spec.loader.exec_module(_helper_module) # type: ignore
|
| 67 |
+
write_status = _helper_module.write_status
|
| 68 |
+
reset_status = _helper_module.reset_status
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
"""ReAct agent entrypoint and system prompt for Telco assistant."""
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
SYSTEM_PROMPT = (
|
| 75 |
+
"You are a warm, helpful mobile operator assistant. Greet briefly, then ask for the caller's mobile number (MSISDN). "
|
| 76 |
+
"IDENTITY IS MANDATORY: After collecting the number, call start_login_tool to send a one-time code via SMS, then ask for the 6-digit code. "
|
| 77 |
+
"Call verify_login_tool with the code. Do NOT proceed unless verified=true. If not verified, ask ONLY for the next missing item and retry. "
|
| 78 |
+
"AFTER VERIFIED: Support these tasks and ask one question per turn: "
|
| 79 |
+
"(1) Show current package and contract; (2) Check current data balance; (3) Explain roaming in a country and available passes; (4) Recommend packages with costs based on usage/preferences; (5) Close contract (require explicit yes/no confirmation). "
|
| 80 |
+
"When recommending, include monthly fees and key features, and keep answers concise. When closing contracts, summarize any early termination fee before asking for confirmation. "
|
| 81 |
+
"STYLE: Concise (1–2 sentences), friendly, and action-oriented. "
|
| 82 |
+
"TTS SAFETY: Output must be plain text suitable for text-to-speech. Do not use markdown, bullets, asterisks, emojis, or special typography. Use only ASCII punctuation and straight quotes."
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
SECONDARY_SYSTEM_PROMPT = (
|
| 86 |
+
"You are a friendly mobile operator assistant engaging in light conversation while a long-running task is being processed. "
|
| 87 |
+
"You can: (1) Check status of the ongoing task using check_status tool; (2) Answer general questions about packages, data balance, or roaming; (3) Provide light chit-chat. "
|
| 88 |
+
"DO NOT attempt to perform any long operations like changing packages, closing contracts, or purchasing passes - explain that another operation is in progress. "
|
| 89 |
+
"STYLE: Brief (1-2 sentences), friendly, and reassuring. "
|
| 90 |
+
"TTS SAFETY: Output must be plain text suitable for text-to-speech. Do not use markdown, bullets, asterisks, emojis, or special typography. Use only ASCII punctuation and straight quotes."
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
_MODEL_NAME = os.getenv("REACT_MODEL", os.getenv("CLARIFY_MODEL", "gpt-4o"))
|
| 95 |
+
_LLM = ChatOpenAI(model=_MODEL_NAME, temperature=0.3)
|
| 96 |
+
_HELPER_LLM = ChatOpenAI(model=_MODEL_NAME, temperature=0.7)
|
| 97 |
+
|
| 98 |
+
# Main thread tools (all tools)
|
| 99 |
+
_MAIN_TOOLS = [
|
| 100 |
+
start_login_tool,
|
| 101 |
+
verify_login_tool,
|
| 102 |
+
get_current_package_tool,
|
| 103 |
+
get_data_balance_tool,
|
| 104 |
+
list_available_packages_tool,
|
| 105 |
+
recommend_packages_tool,
|
| 106 |
+
get_roaming_info_tool,
|
| 107 |
+
close_contract_tool,
|
| 108 |
+
list_addons_tool,
|
| 109 |
+
purchase_roaming_pass_tool,
|
| 110 |
+
change_package_tool,
|
| 111 |
+
get_billing_summary_tool,
|
| 112 |
+
set_data_alerts_tool,
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
# Secondary thread tools (limited to safe, quick operations)
|
| 116 |
+
_SECONDARY_TOOLS = [
|
| 117 |
+
check_status,
|
| 118 |
+
get_current_package_tool,
|
| 119 |
+
get_data_balance_tool,
|
| 120 |
+
list_available_packages_tool,
|
| 121 |
+
get_roaming_info_tool,
|
| 122 |
+
list_addons_tool,
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
_LLM_WITH_TOOLS = _LLM.bind_tools(_MAIN_TOOLS)
|
| 126 |
+
_HELPER_LLM_WITH_TOOLS = _HELPER_LLM.bind_tools(_SECONDARY_TOOLS)
|
| 127 |
+
_ALL_TOOLS_BY_NAME = {t.name: t for t in (_MAIN_TOOLS + [check_status])}
|
| 128 |
+
|
| 129 |
+
# Synthesis chain for merging tool results with interim conversation
|
| 130 |
+
_SYNTHESIS_PROMPT = ChatPromptTemplate.from_messages(
|
| 131 |
+
[
|
| 132 |
+
(
|
| 133 |
+
"system",
|
| 134 |
+
"You are a helpful mobile operator assistant. A long-running operation you were executing has just finished. Your goal is to synthesize the result with the conversation that happened while the operation was running.",
|
| 135 |
+
),
|
| 136 |
+
("user", "Here is the result from the operation:\n\n{tool_result}"),
|
| 137 |
+
(
|
| 138 |
+
"user",
|
| 139 |
+
"While the operation was running, we had this conversation:\n\n{interim_conversation}",
|
| 140 |
+
),
|
| 141 |
+
(
|
| 142 |
+
"user",
|
| 143 |
+
"Now, please craft a single, natural-sounding response that seamlessly continues the conversation. Start by acknowledging the last message if appropriate, and then present the operation result. The response should feel like a direct and fluid continuation of the chat, smoothly integrating the outcome. Keep it brief (1-2 sentences) and TTS-safe (no markdown or special formatting).",
|
| 144 |
+
),
|
| 145 |
+
]
|
| 146 |
+
)
|
| 147 |
+
_SYNTHESIS_LLM = ChatOpenAI(model=_MODEL_NAME, temperature=0.7)
|
| 148 |
+
_SYNTHESIS_CHAIN = _SYNTHESIS_PROMPT | _SYNTHESIS_LLM
|
| 149 |
+
|
| 150 |
+
# Simple per-run context storage (thread-safe enough for local dev worker)
|
| 151 |
+
_CURRENT_THREAD_ID: str | None = None
|
| 152 |
+
_CURRENT_MSISDN: str | None = None
|
| 153 |
+
|
| 154 |
+
# ---- Logger ----
|
| 155 |
+
logger = logging.getLogger("TelcoAgent")
|
| 156 |
+
if not logger.handlers:
|
| 157 |
+
_stream = logging.StreamHandler()
|
| 158 |
+
_stream.setLevel(logging.INFO)
|
| 159 |
+
_fmt = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 160 |
+
_stream.setFormatter(_fmt)
|
| 161 |
+
logger.addHandler(_stream)
|
| 162 |
+
try:
|
| 163 |
+
_file = logging.FileHandler(str(Path(__file__).resolve().parents[2] / "app.log"))
|
| 164 |
+
_file.setLevel(logging.INFO)
|
| 165 |
+
_file.setFormatter(_fmt)
|
| 166 |
+
logger.addHandler(_file)
|
| 167 |
+
except Exception:
|
| 168 |
+
pass
|
| 169 |
+
logger.setLevel(logging.INFO)
|
| 170 |
+
_DEBUG = os.getenv("TELCO_DEBUG", "0") not in ("", "0", "false", "False")
|
| 171 |
+
|
| 172 |
+
def _get_thread_id(config: Dict[str, Any] | None, messages: List[BaseMessage]) -> str:
|
| 173 |
+
cfg = config or {}
|
| 174 |
+
# Try dict-like and attribute-like access
|
| 175 |
+
def _safe_get(container: Any, key: str, default: Any = None) -> Any:
|
| 176 |
+
try:
|
| 177 |
+
if isinstance(container, dict):
|
| 178 |
+
return container.get(key, default)
|
| 179 |
+
if hasattr(container, "get"):
|
| 180 |
+
return container.get(key, default)
|
| 181 |
+
if hasattr(container, key):
|
| 182 |
+
return getattr(container, key, default)
|
| 183 |
+
except Exception:
|
| 184 |
+
return default
|
| 185 |
+
return default
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
conf = _safe_get(cfg, "configurable", {}) or {}
|
| 189 |
+
for key in ("thread_id", "session_id", "thread"):
|
| 190 |
+
val = _safe_get(conf, key)
|
| 191 |
+
if isinstance(val, str) and val:
|
| 192 |
+
return val
|
| 193 |
+
except Exception:
|
| 194 |
+
pass
|
| 195 |
+
|
| 196 |
+
# Fallback: look for session_id on the latest human message additional_kwargs
|
| 197 |
+
try:
|
| 198 |
+
for m in reversed(messages or []):
|
| 199 |
+
addl = getattr(m, "additional_kwargs", None)
|
| 200 |
+
if isinstance(addl, dict) and isinstance(addl.get("session_id"), str) and addl.get("session_id"):
|
| 201 |
+
return addl.get("session_id")
|
| 202 |
+
if isinstance(m, dict):
|
| 203 |
+
ak = m.get("additional_kwargs") or {}
|
| 204 |
+
if isinstance(ak, dict) and isinstance(ak.get("session_id"), str) and ak.get("session_id"):
|
| 205 |
+
return ak.get("session_id")
|
| 206 |
+
except Exception:
|
| 207 |
+
pass
|
| 208 |
+
return "unknown"
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _trim_messages(messages: List[BaseMessage], max_messages: int = 40) -> List[BaseMessage]:
|
| 212 |
+
if len(messages) <= max_messages:
|
| 213 |
+
return messages
|
| 214 |
+
return messages[-max_messages:]
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _sanitize_conversation(messages: List[BaseMessage]) -> List[BaseMessage]:
|
| 218 |
+
"""Ensure tool messages only follow an assistant message with tool_calls.
|
| 219 |
+
|
| 220 |
+
Drops orphan tool messages that could cause OpenAI 400 errors.
|
| 221 |
+
"""
|
| 222 |
+
sanitized: List[BaseMessage] = []
|
| 223 |
+
pending_tool_ids: set[str] | None = None
|
| 224 |
+
for m in messages:
|
| 225 |
+
try:
|
| 226 |
+
if isinstance(m, AIMessage):
|
| 227 |
+
sanitized.append(m)
|
| 228 |
+
tool_calls = getattr(m, "tool_calls", None) or []
|
| 229 |
+
ids: set[str] = set()
|
| 230 |
+
for tc in tool_calls:
|
| 231 |
+
# ToolCall can be mapping-like or object-like
|
| 232 |
+
if isinstance(tc, dict):
|
| 233 |
+
_id = tc.get("id") or tc.get("tool_call_id")
|
| 234 |
+
else:
|
| 235 |
+
_id = getattr(tc, "id", None) or getattr(tc, "tool_call_id", None)
|
| 236 |
+
if isinstance(_id, str):
|
| 237 |
+
ids.add(_id)
|
| 238 |
+
pending_tool_ids = ids if ids else None
|
| 239 |
+
continue
|
| 240 |
+
if isinstance(m, ToolMessage):
|
| 241 |
+
if pending_tool_ids and isinstance(getattr(m, "tool_call_id", None), str) and m.tool_call_id in pending_tool_ids:
|
| 242 |
+
sanitized.append(m)
|
| 243 |
+
# keep accepting subsequent tool messages for the same assistant turn
|
| 244 |
+
continue
|
| 245 |
+
# Orphan tool message: drop
|
| 246 |
+
continue
|
| 247 |
+
# Any other message resets expectation
|
| 248 |
+
sanitized.append(m)
|
| 249 |
+
pending_tool_ids = None
|
| 250 |
+
except Exception:
|
| 251 |
+
# On any unexpected shape, include as-is but reset to avoid pairing issues
|
| 252 |
+
sanitized.append(m)
|
| 253 |
+
pending_tool_ids = None
|
| 254 |
+
# Ensure the conversation doesn't start with a ToolMessage
|
| 255 |
+
while sanitized and isinstance(sanitized[0], ToolMessage):
|
| 256 |
+
sanitized.pop(0)
|
| 257 |
+
return sanitized
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _today_string() -> str:
|
| 261 |
+
override = os.getenv("RBC_FEES_TODAY_OVERRIDE")
|
| 262 |
+
if isinstance(override, str) and override.strip():
|
| 263 |
+
try:
|
| 264 |
+
datetime.strptime(override.strip(), "%Y-%m-%d")
|
| 265 |
+
return override.strip()
|
| 266 |
+
except Exception:
|
| 267 |
+
pass
|
| 268 |
+
return datetime.utcnow().strftime("%Y-%m-%d")
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _system_messages() -> List[BaseMessage]:
|
| 272 |
+
today = _today_string()
|
| 273 |
+
return [SystemMessage(content=SYSTEM_PROMPT)]
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
@task()
|
| 277 |
+
def call_llm(messages: List[BaseMessage]) -> AIMessage:
|
| 278 |
+
"""LLM decides whether to call a tool or not."""
|
| 279 |
+
if _DEBUG:
|
| 280 |
+
try:
|
| 281 |
+
preview = [f"{getattr(m,'type', getattr(m,'role',''))}:{str(getattr(m,'content', m))[:80]}" for m in messages[-6:]]
|
| 282 |
+
logger.info("call_llm: messages_count=%s preview=%s", len(messages), preview)
|
| 283 |
+
except Exception:
|
| 284 |
+
logger.info("call_llm: messages_count=%s", len(messages))
|
| 285 |
+
resp = _LLM_WITH_TOOLS.invoke(_system_messages() + messages)
|
| 286 |
+
try:
|
| 287 |
+
# Log assistant content or tool calls for visibility
|
| 288 |
+
tool_calls = getattr(resp, "tool_calls", None) or []
|
| 289 |
+
if tool_calls:
|
| 290 |
+
names = []
|
| 291 |
+
for tc in tool_calls:
|
| 292 |
+
n = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
|
| 293 |
+
if isinstance(n, str):
|
| 294 |
+
names.append(n)
|
| 295 |
+
logger.info("LLM tool_calls: %s", names)
|
| 296 |
+
else:
|
| 297 |
+
txt = getattr(resp, "content", "") or ""
|
| 298 |
+
if isinstance(txt, str) and txt.strip():
|
| 299 |
+
logger.info("LLM content: %s", (txt if len(txt) <= 500 else (txt[:500] + "…")))
|
| 300 |
+
except Exception:
|
| 301 |
+
pass
|
| 302 |
+
return resp
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@task()
|
| 306 |
+
def call_tool(tool_call: ToolCall) -> ToolMessage:
|
| 307 |
+
"""Execute a tool call and wrap result in a ToolMessage."""
|
| 308 |
+
global _CURRENT_MSISDN
|
| 309 |
+
tool = _ALL_TOOLS_BY_NAME[tool_call["name"]]
|
| 310 |
+
args = tool_call.get("args") or {}
|
| 311 |
+
# Auto-inject session context and remembered msisdn
|
| 312 |
+
if tool.name in ("start_login_tool", "verify_login_tool"):
|
| 313 |
+
if "session_id" not in args and _CURRENT_THREAD_ID:
|
| 314 |
+
args["session_id"] = _CURRENT_THREAD_ID
|
| 315 |
+
if "msisdn" not in args and _CURRENT_MSISDN:
|
| 316 |
+
args["msisdn"] = _CURRENT_MSISDN
|
| 317 |
+
# If the LLM passes msisdn, remember it for subsequent calls
|
| 318 |
+
try:
|
| 319 |
+
if isinstance(args.get("msisdn"), str) and args.get("msisdn").strip():
|
| 320 |
+
_CURRENT_MSISDN = args.get("msisdn")
|
| 321 |
+
except Exception:
|
| 322 |
+
pass
|
| 323 |
+
if _DEBUG:
|
| 324 |
+
try:
|
| 325 |
+
logger.info("call_tool: name=%s args_keys=%s", tool.name, list(args.keys()))
|
| 326 |
+
except Exception:
|
| 327 |
+
logger.info("call_tool: name=%s", tool.name)
|
| 328 |
+
result = tool.invoke(args)
|
| 329 |
+
# Ensure string content
|
| 330 |
+
content = result if isinstance(result, str) else json.dumps(result)
|
| 331 |
+
try:
|
| 332 |
+
# Log tool result previews and OTP debug_code when present
|
| 333 |
+
if tool.name == "verify_login_tool":
|
| 334 |
+
try:
|
| 335 |
+
data = json.loads(content)
|
| 336 |
+
logger.info("verify_login: verified=%s", data.get("verified"))
|
| 337 |
+
except Exception:
|
| 338 |
+
logger.info("verify_login result: %s", content[:300])
|
| 339 |
+
elif tool.name == "start_login_tool":
|
| 340 |
+
try:
|
| 341 |
+
data = json.loads(content)
|
| 342 |
+
logger.info("start_login_tool: sent=%s", data.get("sent"))
|
| 343 |
+
except Exception:
|
| 344 |
+
logger.info("start_login_tool: %s", content[:300])
|
| 345 |
+
else:
|
| 346 |
+
# Generic preview
|
| 347 |
+
logger.info("tool %s result: %s", tool.name, (content[:300] if isinstance(content, str) else str(content)[:300]))
|
| 348 |
+
except Exception:
|
| 349 |
+
pass
|
| 350 |
+
# Never expose OTP debug_code to the LLM
|
| 351 |
+
try:
|
| 352 |
+
if tool.name == "start_login_tool":
|
| 353 |
+
data = json.loads(content)
|
| 354 |
+
if isinstance(data, dict) and "debug_code" in data:
|
| 355 |
+
data.pop("debug_code", None)
|
| 356 |
+
content = json.dumps(data)
|
| 357 |
+
except Exception:
|
| 358 |
+
pass
|
| 359 |
+
return ToolMessage(content=content, tool_call_id=tool_call["id"], name=tool.name)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@entrypoint()
|
| 363 |
+
def agent(input_dict: dict, previous: Any = None, config: RunnableConfig | None = None, store: BaseStore | None = None):
|
| 364 |
+
"""Multi-threaded telco agent supporting concurrent conversations during long operations.
|
| 365 |
+
|
| 366 |
+
Args:
|
| 367 |
+
input_dict: Must contain:
|
| 368 |
+
- messages: List of new messages
|
| 369 |
+
- thread_type: "main" or "secondary"
|
| 370 |
+
- interim_messages_reset: bool (reset interim conversation)
|
| 371 |
+
previous: Previous state dict with {messages, interim_messages}
|
| 372 |
+
config: Runtime configuration
|
| 373 |
+
store: LangGraph store for coordination
|
| 374 |
+
"""
|
| 375 |
+
# Extract input parameters - handle both dict and list formats
|
| 376 |
+
if isinstance(input_dict, dict):
|
| 377 |
+
messages = input_dict.get("messages", [])
|
| 378 |
+
thread_type = input_dict.get("thread_type", "main")
|
| 379 |
+
interim_messages_reset = input_dict.get("interim_messages_reset", True)
|
| 380 |
+
else:
|
| 381 |
+
# input_dict is actually a list of messages (legacy format)
|
| 382 |
+
messages = input_dict if isinstance(input_dict, list) else []
|
| 383 |
+
thread_type = "main"
|
| 384 |
+
interim_messages_reset = True
|
| 385 |
+
|
| 386 |
+
# Get store (from parameter or global context)
|
| 387 |
+
if store is None:
|
| 388 |
+
store = get_store()
|
| 389 |
+
|
| 390 |
+
# Get namespace for coordination
|
| 391 |
+
cfg = ensure_config() if config is None else config
|
| 392 |
+
conf = cfg.get("configurable", {}) if isinstance(cfg, dict) else {}
|
| 393 |
+
namespace = conf.get("namespace_for_memory")
|
| 394 |
+
if namespace and not isinstance(namespace, tuple):
|
| 395 |
+
try:
|
| 396 |
+
namespace = tuple(namespace)
|
| 397 |
+
except (TypeError, ValueError):
|
| 398 |
+
namespace = (str(namespace),)
|
| 399 |
+
|
| 400 |
+
# Merge with previous state
|
| 401 |
+
interim_messages = []
|
| 402 |
+
if previous:
|
| 403 |
+
if isinstance(previous, dict):
|
| 404 |
+
previous_messages = previous.get("messages", [])
|
| 405 |
+
previous_interim_messages = previous.get("interim_messages", [])
|
| 406 |
+
else:
|
| 407 |
+
# Fallback: previous might be a list of messages (old format)
|
| 408 |
+
previous_messages = list(previous) if isinstance(previous, list) else []
|
| 409 |
+
previous_interim_messages = []
|
| 410 |
+
|
| 411 |
+
messages = add_messages(previous_messages, messages)
|
| 412 |
+
interim_messages = add_messages(messages, previous_interim_messages)
|
| 413 |
+
|
| 414 |
+
# Trim and sanitize
|
| 415 |
+
messages = _trim_messages(messages, max_messages=int(os.getenv("RBC_FEES_MAX_MSGS", "40")))
|
| 416 |
+
messages = _sanitize_conversation(messages)
|
| 417 |
+
|
| 418 |
+
# Get thread ID and session context
|
| 419 |
+
thread_id = _get_thread_id(cfg, messages)
|
| 420 |
+
default_msisdn = conf.get("msisdn") or conf.get("phone_number")
|
| 421 |
+
|
| 422 |
+
# Update module context
|
| 423 |
+
global _CURRENT_THREAD_ID, _CURRENT_MSISDN
|
| 424 |
+
_CURRENT_THREAD_ID = thread_id
|
| 425 |
+
_CURRENT_MSISDN = default_msisdn
|
| 426 |
+
|
| 427 |
+
logger.info("agent start: thread_id=%s thread_type=%s total_in=%s", thread_id, thread_type, len(messages))
|
| 428 |
+
|
| 429 |
+
# Secondary thread: Set processing lock at start
|
| 430 |
+
if thread_type != "main" and namespace:
|
| 431 |
+
store.put(namespace, "secondary_status", {
|
| 432 |
+
"processing": True,
|
| 433 |
+
"started_at": time.time()
|
| 434 |
+
})
|
| 435 |
+
|
| 436 |
+
# Check abort flag before starting
|
| 437 |
+
abort_signal = store.get(namespace, "secondary_abort")
|
| 438 |
+
if abort_signal and abort_signal.value.get("abort"):
|
| 439 |
+
# Clean up and exit silently
|
| 440 |
+
store.put(namespace, "secondary_status", {"processing": False, "aborted": True})
|
| 441 |
+
store.delete(namespace, "secondary_abort")
|
| 442 |
+
prev_state = previous if isinstance(previous, dict) else {"messages": [], "interim_messages": []}
|
| 443 |
+
return entrypoint.final(value=[], save=prev_state)
|
| 444 |
+
|
| 445 |
+
# Choose LLM and system prompt based on thread type
|
| 446 |
+
if thread_type == "main":
|
| 447 |
+
active_llm_with_tools = _LLM_WITH_TOOLS
|
| 448 |
+
system_prompt = SYSTEM_PROMPT
|
| 449 |
+
else:
|
| 450 |
+
active_llm_with_tools = _HELPER_LLM_WITH_TOOLS
|
| 451 |
+
system_prompt = SECONDARY_SYSTEM_PROMPT
|
| 452 |
+
|
| 453 |
+
# Build system messages
|
| 454 |
+
sys_messages = [SystemMessage(content=system_prompt)]
|
| 455 |
+
|
| 456 |
+
# First LLM call
|
| 457 |
+
llm_response = active_llm_with_tools.invoke(sys_messages + messages)
|
| 458 |
+
|
| 459 |
+
# Tool execution loop
|
| 460 |
+
while True:
|
| 461 |
+
tool_calls = getattr(llm_response, "tool_calls", None) or []
|
| 462 |
+
if not tool_calls:
|
| 463 |
+
break
|
| 464 |
+
|
| 465 |
+
# Execute tools in parallel
|
| 466 |
+
futures = [call_tool(tc) for tc in tool_calls]
|
| 467 |
+
tool_results = [f.result() for f in futures]
|
| 468 |
+
|
| 469 |
+
if _DEBUG:
|
| 470 |
+
try:
|
| 471 |
+
logger.info("tool_results: count=%s names=%s", len(tool_results), [tr.name for tr in tool_results])
|
| 472 |
+
except Exception:
|
| 473 |
+
pass
|
| 474 |
+
|
| 475 |
+
messages = add_messages(messages, [llm_response, *tool_results])
|
| 476 |
+
llm_response = active_llm_with_tools.invoke(sys_messages + messages)
|
| 477 |
+
|
| 478 |
+
# Append final assistant turn
|
| 479 |
+
messages = add_messages(messages, [llm_response])
|
| 480 |
+
|
| 481 |
+
# Update interim messages
|
| 482 |
+
if interim_messages_reset:
|
| 483 |
+
interim_messages = add_messages([], [llm_response])
|
| 484 |
+
else:
|
| 485 |
+
interim_messages = add_messages(interim_messages, [llm_response])
|
| 486 |
+
|
| 487 |
+
# Main thread: Reset status after completion and signal completion
|
| 488 |
+
if thread_type == "main" and namespace:
|
| 489 |
+
reset_status(store, namespace)
|
| 490 |
+
# Signal that main operation is complete
|
| 491 |
+
store.put(namespace, "main_operation_complete", {
|
| 492 |
+
"completed": True,
|
| 493 |
+
"timestamp": time.time()
|
| 494 |
+
})
|
| 495 |
+
|
| 496 |
+
# Secondary thread: Handle abort and release lock
|
| 497 |
+
if thread_type != "main" and namespace:
|
| 498 |
+
# Check abort flag before writing results
|
| 499 |
+
abort_signal = store.get(namespace, "secondary_abort")
|
| 500 |
+
if abort_signal and abort_signal.value.get("abort"):
|
| 501 |
+
# Clean up and exit without saving
|
| 502 |
+
store.put(namespace, "secondary_status", {"processing": False, "aborted": True})
|
| 503 |
+
store.delete(namespace, "secondary_abort")
|
| 504 |
+
prev_state = previous if isinstance(previous, dict) else {"messages": [], "interim_messages": []}
|
| 505 |
+
return entrypoint.final(value=[], save=prev_state)
|
| 506 |
+
|
| 507 |
+
# Safe to proceed - write results and release lock
|
| 508 |
+
store.put(namespace, "secondary_interim_messages", {"messages": interim_messages})
|
| 509 |
+
store.put(namespace, "secondary_status", {
|
| 510 |
+
"processing": False,
|
| 511 |
+
"completed_at": time.time()
|
| 512 |
+
})
|
| 513 |
+
|
| 514 |
+
# Main thread: Wait for secondary and synthesize if needed
|
| 515 |
+
if thread_type == "main" and namespace:
|
| 516 |
+
# Wait for secondary thread to finish processing (with timeout)
|
| 517 |
+
MAX_WAIT_SECONDS = 15
|
| 518 |
+
CHECK_INTERVAL = 0.5
|
| 519 |
+
elapsed = 0
|
| 520 |
+
|
| 521 |
+
while elapsed < MAX_WAIT_SECONDS:
|
| 522 |
+
secondary_status = store.get(namespace, "secondary_status")
|
| 523 |
+
if not secondary_status or not secondary_status.value.get("processing", False):
|
| 524 |
+
break
|
| 525 |
+
time.sleep(CHECK_INTERVAL)
|
| 526 |
+
elapsed += CHECK_INTERVAL
|
| 527 |
+
|
| 528 |
+
# If timed out, set abort flag
|
| 529 |
+
if elapsed >= MAX_WAIT_SECONDS:
|
| 530 |
+
store.put(namespace, "secondary_abort", {
|
| 531 |
+
"abort": True,
|
| 532 |
+
"reason": "main_thread_timeout",
|
| 533 |
+
"timestamp": time.time()
|
| 534 |
+
})
|
| 535 |
+
time.sleep(0.2) # Brief moment for secondary to see abort
|
| 536 |
+
|
| 537 |
+
# Read and synthesize interim messages (only if meaningful)
|
| 538 |
+
interim_messages_from_store = store.get(namespace, "secondary_interim_messages")
|
| 539 |
+
if interim_messages_from_store:
|
| 540 |
+
interim_conv = interim_messages_from_store.value.get("messages")
|
| 541 |
+
if interim_conv and len(interim_conv) > 0:
|
| 542 |
+
# Filter out status-only messages for synthesis
|
| 543 |
+
meaningful_messages = []
|
| 544 |
+
for m in interim_conv:
|
| 545 |
+
content = getattr(m, 'content', '').lower()
|
| 546 |
+
# Skip if it's just about status/progress
|
| 547 |
+
if not any(word in content for word in ['processing', 'complete', 'running', 'percent', 'status']):
|
| 548 |
+
meaningful_messages.append(m)
|
| 549 |
+
|
| 550 |
+
# Only synthesize if there were non-status conversations
|
| 551 |
+
if meaningful_messages:
|
| 552 |
+
tool_result_content = messages[-1].content if messages else ""
|
| 553 |
+
interim_conv_str = "\n".join(
|
| 554 |
+
[f"{getattr(m, 'type', 'message')}: {getattr(m, 'content', '')}" for m in meaningful_messages]
|
| 555 |
+
)
|
| 556 |
+
try:
|
| 557 |
+
final_answer = _SYNTHESIS_CHAIN.invoke({
|
| 558 |
+
"tool_result": tool_result_content,
|
| 559 |
+
"interim_conversation": interim_conv_str,
|
| 560 |
+
})
|
| 561 |
+
# Add visual marker for synthesis
|
| 562 |
+
synthesized_content = f"{final_answer.content}"
|
| 563 |
+
messages[-1] = AIMessage(content=synthesized_content)
|
| 564 |
+
logger.info("Synthesized response with %d meaningful interim messages", len(meaningful_messages))
|
| 565 |
+
except Exception as e:
|
| 566 |
+
logger.warning("Synthesis failed: %s", e)
|
| 567 |
+
else:
|
| 568 |
+
logger.info("No meaningful interim messages to synthesize (only status checks)")
|
| 569 |
+
|
| 570 |
+
store.delete(namespace, "secondary_interim_messages")
|
| 571 |
+
|
| 572 |
+
# Clean up coordination state
|
| 573 |
+
reset_status(store, namespace)
|
| 574 |
+
store.delete(namespace, "secondary_status")
|
| 575 |
+
store.delete(namespace, "secondary_abort")
|
| 576 |
+
# Keep completion flag briefly for client to see
|
| 577 |
+
store.put(namespace, "main_operation_complete", {
|
| 578 |
+
"completed": True,
|
| 579 |
+
"timestamp": time.time(),
|
| 580 |
+
"ready_for_new_operation": True
|
| 581 |
+
})
|
| 582 |
+
|
| 583 |
+
# Prepare final state
|
| 584 |
+
current_state = {
|
| 585 |
+
"messages": messages,
|
| 586 |
+
"interim_messages": interim_messages,
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
final_text = getattr(messages[-1], "content", "") if messages else ""
|
| 590 |
+
try:
|
| 591 |
+
if isinstance(final_text, str) and final_text.strip():
|
| 592 |
+
logger.info("final content: %s", (final_text if len(final_text) <= 500 else (final_text[:500] + "…")))
|
| 593 |
+
except Exception:
|
| 594 |
+
pass
|
| 595 |
+
|
| 596 |
+
logger.info("agent done: thread_id=%s thread_type=%s total_messages=%s", thread_id, thread_type, len(messages))
|
| 597 |
+
|
| 598 |
+
return entrypoint.final(value=messages, save=current_state)
|
| 599 |
+
|
| 600 |
+
|
examples/voice_agent_multi_thread/agents/telco-agent-multi/tools.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import time
|
| 4 |
+
from typing import Dict, Any
|
| 5 |
+
|
| 6 |
+
from langchain_core.tools import tool
|
| 7 |
+
from langchain_core.runnables.config import ensure_config
|
| 8 |
+
from langgraph.config import get_store, get_stream_writer
|
| 9 |
+
|
| 10 |
+
# Robust logic import that avoids cross-module leakage during hot reloads
|
| 11 |
+
try:
|
| 12 |
+
from . import logic as telco_logic # type: ignore
|
| 13 |
+
except Exception:
|
| 14 |
+
import importlib.util as _ilu
|
| 15 |
+
_dir = os.path.dirname(__file__)
|
| 16 |
+
_logic_path = os.path.join(_dir, "logic.py")
|
| 17 |
+
_spec = _ilu.spec_from_file_location("telco_agent_logic", _logic_path)
|
| 18 |
+
telco_logic = _ilu.module_from_spec(_spec) # type: ignore
|
| 19 |
+
assert _spec and _spec.loader
|
| 20 |
+
_spec.loader.exec_module(telco_logic) # type: ignore
|
| 21 |
+
|
| 22 |
+
# Import helper functions (following the working example pattern)
|
| 23 |
+
try:
|
| 24 |
+
from ..helper_functions import write_status
|
| 25 |
+
except Exception:
|
| 26 |
+
# Fallback inline definition if import fails
|
| 27 |
+
def write_status(tool_name: str, progress: int, status: str, store, namespace, config):
|
| 28 |
+
if not isinstance(namespace, tuple):
|
| 29 |
+
try:
|
| 30 |
+
namespace = tuple(namespace)
|
| 31 |
+
except (TypeError, ValueError):
|
| 32 |
+
namespace = (str(namespace),)
|
| 33 |
+
store.put(namespace, "working-tool-status-update", {
|
| 34 |
+
"tool_name": tool_name,
|
| 35 |
+
"progress": progress,
|
| 36 |
+
"status": status,
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# --- Identity tools ---
|
| 41 |
+
|
| 42 |
+
@tool
|
| 43 |
+
def start_login_tool(session_id: str, msisdn: str) -> str:
|
| 44 |
+
"""Send a one-time code via SMS to the given mobile number. Returns masked destination and status (JSON)."""
|
| 45 |
+
return json.dumps(telco_logic.start_login(session_id, msisdn))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@tool
|
| 49 |
+
def verify_login_tool(session_id: str, msisdn: str, otp: str) -> str:
|
| 50 |
+
"""Verify the one-time code sent to the user's phone. Returns {verified, session_id, msisdn}."""
|
| 51 |
+
return json.dumps(telco_logic.verify_login(session_id, msisdn, otp))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# --- Customer/package tools ---
|
| 55 |
+
|
| 56 |
+
@tool
|
| 57 |
+
def get_current_package_tool(msisdn: str) -> str:
|
| 58 |
+
"""Get the customer's current package, contract status, and addons (JSON)."""
|
| 59 |
+
return json.dumps(telco_logic.get_current_package(msisdn))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@tool
|
| 63 |
+
def get_data_balance_tool(msisdn: str) -> str:
|
| 64 |
+
"""Get the customer's current month data usage and remaining allowance (JSON)."""
|
| 65 |
+
return json.dumps(telco_logic.get_data_balance(msisdn))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@tool
|
| 69 |
+
def list_available_packages_tool() -> str:
|
| 70 |
+
"""List all available mobile packages with fees and features (JSON array)."""
|
| 71 |
+
return json.dumps(telco_logic.list_available_packages())
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@tool
|
| 75 |
+
def recommend_packages_tool(msisdn: str, preferences_json: str | None = None) -> str:
|
| 76 |
+
"""Recommend up to 3 packages based on the customer's usage and optional preferences JSON."""
|
| 77 |
+
prefs: Dict[str, Any] = {}
|
| 78 |
+
try:
|
| 79 |
+
if isinstance(preferences_json, str) and preferences_json.strip():
|
| 80 |
+
prefs = json.loads(preferences_json)
|
| 81 |
+
except Exception:
|
| 82 |
+
prefs = {}
|
| 83 |
+
return json.dumps(telco_logic.recommend_packages(msisdn, prefs))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@tool
|
| 87 |
+
def get_roaming_info_tool(msisdn: str, country_code: str) -> str:
|
| 88 |
+
"""Get roaming pricing and available passes for a country; indicates if included by current package (JSON)."""
|
| 89 |
+
return json.dumps(telco_logic.get_roaming_info(msisdn, country_code))
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@tool
|
| 93 |
+
def close_contract_tool(msisdn: str, confirm: bool = False) -> str:
|
| 94 |
+
"""Close the customer's contract. Use confirm=true only after explicit user confirmation. Returns summary (JSON)."""
|
| 95 |
+
if not confirm:
|
| 96 |
+
# Just return preview, no long operation
|
| 97 |
+
return json.dumps(telco_logic.close_contract(msisdn, False))
|
| 98 |
+
|
| 99 |
+
# Long-running operation with progress reporting (following working example pattern)
|
| 100 |
+
writer = get_stream_writer()
|
| 101 |
+
writer("Processing your contract closure request. This may take a moment...")
|
| 102 |
+
|
| 103 |
+
tool_name = "close_contract_tool"
|
| 104 |
+
steps = 10
|
| 105 |
+
interval_seconds = 5 # 10 steps × 5 seconds = 50 seconds total
|
| 106 |
+
|
| 107 |
+
config = ensure_config()
|
| 108 |
+
namespace = config["configurable"]["namespace_for_memory"]
|
| 109 |
+
server_store = get_store()
|
| 110 |
+
|
| 111 |
+
for i in range(1, steps + 1):
|
| 112 |
+
time.sleep(interval_seconds)
|
| 113 |
+
pct = (i * 100) // steps
|
| 114 |
+
status = "running"
|
| 115 |
+
write_status(tool_name, pct, status, server_store, namespace, config)
|
| 116 |
+
|
| 117 |
+
# Execute actual closure
|
| 118 |
+
result = telco_logic.close_contract(msisdn, True)
|
| 119 |
+
|
| 120 |
+
write_status(tool_name, 100, "completed", server_store, namespace, config)
|
| 121 |
+
return json.dumps(result)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# --- Extended tools ---
|
| 125 |
+
|
| 126 |
+
@tool
|
| 127 |
+
def list_addons_tool(msisdn: str) -> str:
|
| 128 |
+
"""List customer's active addons (e.g., roaming passes)."""
|
| 129 |
+
return json.dumps(telco_logic.list_addons(msisdn))
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@tool
|
| 133 |
+
def purchase_roaming_pass_tool(msisdn: str, country_code: str, pass_id: str) -> str:
|
| 134 |
+
"""Purchase a roaming pass for a country by pass_id. Returns the added addon (JSON)."""
|
| 135 |
+
result = telco_logic.purchase_roaming_pass(msisdn, country_code, pass_id)
|
| 136 |
+
return json.dumps(result)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@tool
|
| 140 |
+
def change_package_tool(msisdn: str, package_id: str, effective: str = "next_cycle") -> str:
|
| 141 |
+
"""Change customer's package now or next_cycle. Returns status summary (JSON)."""
|
| 142 |
+
result = telco_logic.change_package(msisdn, package_id, effective)
|
| 143 |
+
return json.dumps(result)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@tool
|
| 147 |
+
def get_billing_summary_tool(msisdn: str) -> str:
|
| 148 |
+
"""Get billing summary including monthly fee and last bill amount (JSON)."""
|
| 149 |
+
result = telco_logic.get_billing_summary(msisdn)
|
| 150 |
+
return json.dumps(result)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@tool
|
| 154 |
+
def set_data_alerts_tool(msisdn: str, threshold_percent: int | None = None, threshold_gb: float | None = None) -> str:
|
| 155 |
+
"""Set data usage alerts by percent and/or GB. Returns updated alert settings (JSON)."""
|
| 156 |
+
return json.dumps(telco_logic.set_data_alerts(msisdn, threshold_percent, threshold_gb))
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# --- Helper tool for secondary thread ---
|
| 160 |
+
|
| 161 |
+
@tool
|
| 162 |
+
def check_status() -> dict:
|
| 163 |
+
"""Check the current status and progress of any long-running task."""
|
| 164 |
+
config = ensure_config()
|
| 165 |
+
namespace = config["configurable"]["namespace_for_memory"]
|
| 166 |
+
|
| 167 |
+
if not isinstance(namespace, tuple):
|
| 168 |
+
try:
|
| 169 |
+
namespace = tuple(namespace)
|
| 170 |
+
except (TypeError, ValueError):
|
| 171 |
+
namespace = (str(namespace),)
|
| 172 |
+
|
| 173 |
+
server_store = get_store()
|
| 174 |
+
memory_update = server_store.get(namespace, "working-tool-status-update")
|
| 175 |
+
|
| 176 |
+
if memory_update:
|
| 177 |
+
item_value = memory_update.value
|
| 178 |
+
status = item_value.get("status", "unknown")
|
| 179 |
+
progress = item_value.get("progress", None)
|
| 180 |
+
tool_name = item_value.get("tool_name", "unknown")
|
| 181 |
+
return {
|
| 182 |
+
"status": status,
|
| 183 |
+
"progress": progress,
|
| 184 |
+
"tool_name": tool_name
|
| 185 |
+
}
|
| 186 |
+
else:
|
| 187 |
+
return {
|
| 188 |
+
"status": "idle",
|
| 189 |
+
"progress": None,
|
| 190 |
+
"tool_name": None
|
| 191 |
+
}
|
| 192 |
+
|
examples/voice_agent_multi_thread/agents/telco_client.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Client for multi-threaded telco agent.
|
| 4 |
+
|
| 5 |
+
This client handles routing between main and secondary threads:
|
| 6 |
+
- Main thread: Handles long-running operations (package changes, contract closures, etc.)
|
| 7 |
+
- Secondary thread: Handles interim queries while main thread is busy
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
# Interactive mode
|
| 11 |
+
python telco_client.py --interactive
|
| 12 |
+
|
| 13 |
+
# Single message
|
| 14 |
+
python telco_client.py
|
| 15 |
+
|
| 16 |
+
# Custom server URL
|
| 17 |
+
python telco_client.py --url http://localhost:2024 --interactive
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import asyncio
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
import uuid
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
import contextlib
|
| 27 |
+
|
| 28 |
+
from langgraph_sdk import get_client
|
| 29 |
+
from langgraph_sdk.schema import StreamPart
|
| 30 |
+
import httpx
|
| 31 |
+
from typing import Any, Optional
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# Terminal colors
|
| 35 |
+
RESET = "\033[0m"
|
| 36 |
+
BOLD = "\033[1m"
|
| 37 |
+
DIM = "\033[2m"
|
| 38 |
+
FG_BLUE = "\033[34m"
|
| 39 |
+
FG_GREEN = "\033[32m"
|
| 40 |
+
FG_CYAN = "\033[36m"
|
| 41 |
+
FG_YELLOW = "\033[33m"
|
| 42 |
+
FG_MAGENTA = "\033[35m"
|
| 43 |
+
FG_GRAY = "\033[90m"
|
| 44 |
+
PROMPT_STR = f"{BOLD}> {RESET}"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _show_prompt() -> None:
|
| 48 |
+
sys.stdout.write(PROMPT_STR)
|
| 49 |
+
sys.stdout.flush()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _write_line(s: str) -> None:
|
| 53 |
+
sys.stdout.write("\r\x1b[2K" + s + "\n")
|
| 54 |
+
sys.stdout.flush()
|
| 55 |
+
_show_prompt()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _write_line_no_prompt(s: str) -> None:
|
| 59 |
+
sys.stdout.write("\r\x1b[2K" + s + "\n")
|
| 60 |
+
sys.stdout.flush()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _log(msg: str) -> None:
|
| 64 |
+
_write_line(f"{FG_GRAY}{msg}{RESET}")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _user(msg: str) -> None:
|
| 68 |
+
_write_line_no_prompt(f"{FG_BLUE}User{RESET}: {msg}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _assistant(msg: str) -> None:
|
| 72 |
+
_write_line(f"{FG_GREEN}Assistant{RESET}: {msg}")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _event(label: str, text: str) -> None:
|
| 76 |
+
_write_line(f"{FG_YELLOW}[{label}]{RESET} {DIM}{text}{RESET}")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _extract_text_from_messages(messages: list[Any]) -> Optional[str]:
|
| 80 |
+
"""Extract text from a list of message objects."""
|
| 81 |
+
if not isinstance(messages, list) or not messages:
|
| 82 |
+
return None
|
| 83 |
+
last = messages[-1]
|
| 84 |
+
if isinstance(last, dict):
|
| 85 |
+
content = last.get("content")
|
| 86 |
+
if isinstance(content, str):
|
| 87 |
+
return content
|
| 88 |
+
if isinstance(content, list):
|
| 89 |
+
pieces: list[str] = []
|
| 90 |
+
for seg in content:
|
| 91 |
+
if isinstance(seg, dict):
|
| 92 |
+
t = seg.get("text") or seg.get("content") or ""
|
| 93 |
+
if isinstance(t, str) and t:
|
| 94 |
+
pieces.append(t)
|
| 95 |
+
if pieces:
|
| 96 |
+
return "\n".join(pieces)
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _extract_text(payload: Any, *, graph_key: str | None = None) -> Optional[str]:
|
| 101 |
+
"""Extract assistant text from various payload shapes."""
|
| 102 |
+
# Direct string
|
| 103 |
+
if isinstance(payload, str):
|
| 104 |
+
return payload
|
| 105 |
+
# List of messages or mixed
|
| 106 |
+
if isinstance(payload, list):
|
| 107 |
+
text = _extract_text_from_messages(payload)
|
| 108 |
+
if text:
|
| 109 |
+
return text
|
| 110 |
+
# Fallback: any string entries
|
| 111 |
+
for v in payload:
|
| 112 |
+
t = _extract_text(v, graph_key=graph_key)
|
| 113 |
+
if t:
|
| 114 |
+
return t
|
| 115 |
+
return None
|
| 116 |
+
# Dict payloads
|
| 117 |
+
if isinstance(payload, dict):
|
| 118 |
+
# Graph-level direct string
|
| 119 |
+
if graph_key and isinstance(payload.get(graph_key), str):
|
| 120 |
+
return payload[graph_key]
|
| 121 |
+
# Common shapes
|
| 122 |
+
if isinstance(payload.get("value"), (str, list, dict)):
|
| 123 |
+
t = _extract_text(payload.get("value"), graph_key=graph_key)
|
| 124 |
+
if t:
|
| 125 |
+
return t
|
| 126 |
+
if isinstance(payload.get("messages"), list):
|
| 127 |
+
t = _extract_text_from_messages(payload.get("messages", []))
|
| 128 |
+
if t:
|
| 129 |
+
return t
|
| 130 |
+
if isinstance(payload.get("content"), str):
|
| 131 |
+
return payload.get("content")
|
| 132 |
+
# Search nested values
|
| 133 |
+
for v in payload.values():
|
| 134 |
+
t = _extract_text(v, graph_key=graph_key)
|
| 135 |
+
if t:
|
| 136 |
+
return t
|
| 137 |
+
return None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def stream_run(
|
| 141 |
+
client,
|
| 142 |
+
thread_id: str,
|
| 143 |
+
graph: str,
|
| 144 |
+
message: dict,
|
| 145 |
+
label: str,
|
| 146 |
+
*,
|
| 147 |
+
namespace_for_memory: tuple[str, ...],
|
| 148 |
+
global_last_text: dict[str, str], # Shared across runs for deduplication
|
| 149 |
+
) -> int:
|
| 150 |
+
"""Stream a run and print output."""
|
| 151 |
+
printed_once = False
|
| 152 |
+
command: dict[str, Any] | None = None
|
| 153 |
+
|
| 154 |
+
config = {
|
| 155 |
+
"configurable": {
|
| 156 |
+
"thread_id": thread_id,
|
| 157 |
+
"namespace_for_memory": list(namespace_for_memory),
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
while True:
|
| 162 |
+
last_text: Optional[str] = global_last_text.get("last", None) # Global de-dupe
|
| 163 |
+
stream = client.runs.stream(
|
| 164 |
+
thread_id=thread_id,
|
| 165 |
+
assistant_id=graph,
|
| 166 |
+
input=message if command is None else None,
|
| 167 |
+
command=command,
|
| 168 |
+
stream_mode=["values", "custom"],
|
| 169 |
+
config=config,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
saw_interrupt = False
|
| 173 |
+
async for part in stream:
|
| 174 |
+
assert isinstance(part, StreamPart)
|
| 175 |
+
if part.event == "metadata":
|
| 176 |
+
data = part.data or {}
|
| 177 |
+
run_id = (data.get("run_id") if isinstance(data, dict) else None) or "?"
|
| 178 |
+
_event(label, f"run started (run_id={run_id}, thread_id={thread_id})")
|
| 179 |
+
continue
|
| 180 |
+
if part.event == "custom":
|
| 181 |
+
data = part.data
|
| 182 |
+
text = _extract_text(data, graph_key=graph)
|
| 183 |
+
if text and text != last_text:
|
| 184 |
+
_assistant(text)
|
| 185 |
+
last_text = text
|
| 186 |
+
global_last_text["last"] = text
|
| 187 |
+
continue
|
| 188 |
+
if part.event == "values":
|
| 189 |
+
data = part.data
|
| 190 |
+
text = _extract_text(data, graph_key=graph)
|
| 191 |
+
if text and text != last_text:
|
| 192 |
+
_assistant(text)
|
| 193 |
+
last_text = text
|
| 194 |
+
global_last_text["last"] = text
|
| 195 |
+
continue
|
| 196 |
+
# Uncomment for debug info
|
| 197 |
+
# if part.event:
|
| 198 |
+
# _event(label, f"{part.event} {part.data}")
|
| 199 |
+
if part.event == "end":
|
| 200 |
+
return 0
|
| 201 |
+
|
| 202 |
+
if saw_interrupt:
|
| 203 |
+
command = {"resume": None}
|
| 204 |
+
continue
|
| 205 |
+
return 0
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
async def ainput(prompt: str = "") -> str:
|
| 209 |
+
"""Async input wrapper."""
|
| 210 |
+
loop = asyncio.get_running_loop()
|
| 211 |
+
return await loop.run_in_executor(None, lambda: input(prompt))
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
async def read_latest_status(client, namespace_for_memory: tuple[str, ...]) -> dict:
|
| 215 |
+
"""Read the latest tool status from the store."""
|
| 216 |
+
ns_list = list(namespace_for_memory)
|
| 217 |
+
try:
|
| 218 |
+
items = await client.store.search_items(ns_list)
|
| 219 |
+
except Exception:
|
| 220 |
+
return {}
|
| 221 |
+
|
| 222 |
+
# Normalize return shape: SDK may return a dict with 'items' or a bare list
|
| 223 |
+
items_list: list[Any] | None = None
|
| 224 |
+
if isinstance(items, dict):
|
| 225 |
+
inner = items.get("items")
|
| 226 |
+
if isinstance(inner, list):
|
| 227 |
+
items_list = inner
|
| 228 |
+
elif isinstance(items, list):
|
| 229 |
+
items_list = items
|
| 230 |
+
|
| 231 |
+
if not items_list:
|
| 232 |
+
return {}
|
| 233 |
+
|
| 234 |
+
# Walk from the end to find the most recent item that has a 'status'
|
| 235 |
+
for item in reversed(items_list):
|
| 236 |
+
value = getattr(item, "value", None)
|
| 237 |
+
if value is None and isinstance(item, dict):
|
| 238 |
+
value = item.get("value")
|
| 239 |
+
if isinstance(value, dict) and "status" in value:
|
| 240 |
+
return value
|
| 241 |
+
|
| 242 |
+
# Fallback to last value if present
|
| 243 |
+
last = items_list[-1]
|
| 244 |
+
value = getattr(last, "value", None)
|
| 245 |
+
if value is None and isinstance(last, dict):
|
| 246 |
+
value = last.get("value")
|
| 247 |
+
return value if isinstance(value, dict) else {}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
async def check_completion_flag(client, namespace_for_memory: tuple[str, ...]) -> bool:
|
| 251 |
+
"""Check if main operation has completed recently."""
|
| 252 |
+
ns_list = list(namespace_for_memory)
|
| 253 |
+
try:
|
| 254 |
+
items = await client.store.search_items(ns_list)
|
| 255 |
+
except Exception:
|
| 256 |
+
return False
|
| 257 |
+
|
| 258 |
+
# Normalize return shape
|
| 259 |
+
items_list: list[Any] | None = None
|
| 260 |
+
if isinstance(items, dict):
|
| 261 |
+
inner = items.get("items")
|
| 262 |
+
if isinstance(inner, list):
|
| 263 |
+
items_list = inner
|
| 264 |
+
elif isinstance(items, list):
|
| 265 |
+
items_list = items
|
| 266 |
+
|
| 267 |
+
if not items_list:
|
| 268 |
+
return False
|
| 269 |
+
|
| 270 |
+
# Look for completion flag
|
| 271 |
+
for item in reversed(items_list):
|
| 272 |
+
key = getattr(item, "key", None) or (item.get("key") if isinstance(item, dict) else None)
|
| 273 |
+
if key == "main_operation_complete":
|
| 274 |
+
value = getattr(item, "value", None)
|
| 275 |
+
if value is None and isinstance(item, dict):
|
| 276 |
+
value = item.get("value")
|
| 277 |
+
if isinstance(value, dict) and value.get("ready_for_new_operation"):
|
| 278 |
+
return True
|
| 279 |
+
|
| 280 |
+
return False
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
async def run_client(
|
| 284 |
+
base_url: str,
|
| 285 |
+
graph: str,
|
| 286 |
+
user_id: str,
|
| 287 |
+
interactive: bool,
|
| 288 |
+
thread_file: str | None,
|
| 289 |
+
initial_message: str | None,
|
| 290 |
+
) -> int:
|
| 291 |
+
"""Main client logic."""
|
| 292 |
+
client = get_client(url=base_url)
|
| 293 |
+
|
| 294 |
+
# Primary and secondary thread ids
|
| 295 |
+
thread_path = Path(thread_file) if thread_file else None
|
| 296 |
+
|
| 297 |
+
# Main thread: load from file if present; otherwise create on server and persist
|
| 298 |
+
if thread_path and thread_path.exists():
|
| 299 |
+
try:
|
| 300 |
+
loaded = thread_path.read_text().strip().splitlines()
|
| 301 |
+
thread_id_main = loaded[0] if loaded else None
|
| 302 |
+
except Exception:
|
| 303 |
+
thread_id_main = None
|
| 304 |
+
|
| 305 |
+
if not thread_id_main:
|
| 306 |
+
t = await client.threads.create()
|
| 307 |
+
thread_id_main = getattr(t, "thread_id", None) or (
|
| 308 |
+
t["thread_id"] if isinstance(t, dict) else str(uuid.uuid4())
|
| 309 |
+
)
|
| 310 |
+
try:
|
| 311 |
+
thread_path.write_text(thread_id_main + "\n")
|
| 312 |
+
except Exception:
|
| 313 |
+
pass
|
| 314 |
+
else:
|
| 315 |
+
try:
|
| 316 |
+
await client.threads.create(thread_id=thread_id_main, if_exists="do_nothing")
|
| 317 |
+
except httpx.HTTPStatusError as e:
|
| 318 |
+
if getattr(e, "response", None) is not None and e.response.status_code == 409:
|
| 319 |
+
pass
|
| 320 |
+
else:
|
| 321 |
+
raise
|
| 322 |
+
else:
|
| 323 |
+
t = await client.threads.create()
|
| 324 |
+
thread_id_main = getattr(t, "thread_id", None) or (
|
| 325 |
+
t["thread_id"] if isinstance(t, dict) else str(uuid.uuid4())
|
| 326 |
+
)
|
| 327 |
+
if thread_path:
|
| 328 |
+
try:
|
| 329 |
+
thread_path.write_text(thread_id_main + "\n")
|
| 330 |
+
except Exception:
|
| 331 |
+
pass
|
| 332 |
+
|
| 333 |
+
# Secondary thread: always create on server (ephemeral)
|
| 334 |
+
t2 = await client.threads.create()
|
| 335 |
+
thread_id_updates = getattr(t2, "thread_id", None) or (
|
| 336 |
+
t2["thread_id"] if isinstance(t2, dict) else str(uuid.uuid4())
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# Shared namespace used by server agent's tools
|
| 340 |
+
namespace_for_memory = (user_id, "tools_updates")
|
| 341 |
+
|
| 342 |
+
print(f"{FG_MAGENTA}Telco Agent Multi-Threaded Client{RESET}")
|
| 343 |
+
print(f"Main Thread ID: {FG_CYAN}{thread_id_main}{RESET}")
|
| 344 |
+
print(f"Secondary Thread ID: {FG_CYAN}{thread_id_updates}{RESET}")
|
| 345 |
+
print(f"Namespace: {FG_CYAN}{namespace_for_memory}{RESET}")
|
| 346 |
+
print()
|
| 347 |
+
|
| 348 |
+
# Interactive loop
|
| 349 |
+
if interactive:
|
| 350 |
+
print(f"{FG_CYAN}Interactive Mode: Type your message. Use /exit to quit.{RESET}")
|
| 351 |
+
print(f"{FG_GRAY}Long operations will run in background. You can ask questions while they run.{RESET}")
|
| 352 |
+
print()
|
| 353 |
+
|
| 354 |
+
# Clear any stale flags from previous sessions
|
| 355 |
+
try:
|
| 356 |
+
ns_list = list(namespace_for_memory)
|
| 357 |
+
await client.store.delete_item(ns_list, "main_operation_complete")
|
| 358 |
+
await client.store.delete_item(ns_list, "working-tool-status-update")
|
| 359 |
+
await client.store.delete_item(ns_list, "secondary_status")
|
| 360 |
+
await client.store.delete_item(ns_list, "secondary_abort")
|
| 361 |
+
await client.store.delete_item(ns_list, "secondary_interim_messages")
|
| 362 |
+
except Exception:
|
| 363 |
+
pass # Flags might not exist, that's okay
|
| 364 |
+
|
| 365 |
+
_show_prompt()
|
| 366 |
+
|
| 367 |
+
# Track background task and state
|
| 368 |
+
main_job: asyncio.Task[int] | None = None
|
| 369 |
+
interim_messages_reset = True
|
| 370 |
+
global_last_text: dict[str, str] = {} # Global deduplication
|
| 371 |
+
cooldown_until: float = 0 # Cooldown timestamp
|
| 372 |
+
last_operation_complete_time: float = 0
|
| 373 |
+
|
| 374 |
+
while True:
|
| 375 |
+
try:
|
| 376 |
+
user_text = await ainput("")
|
| 377 |
+
except (KeyboardInterrupt, EOFError):
|
| 378 |
+
user_text = "/exit"
|
| 379 |
+
|
| 380 |
+
user_text = (user_text or "").strip()
|
| 381 |
+
if not user_text:
|
| 382 |
+
continue
|
| 383 |
+
|
| 384 |
+
if user_text.lower() in {"exit", "quit", "/exit"}:
|
| 385 |
+
break
|
| 386 |
+
|
| 387 |
+
_user(user_text)
|
| 388 |
+
|
| 389 |
+
# Check if we're in cooldown period
|
| 390 |
+
current_time = time.time()
|
| 391 |
+
if current_time < cooldown_until:
|
| 392 |
+
wait_time = int(cooldown_until - current_time)
|
| 393 |
+
_event("cooldown", f"Operation just completed, waiting {wait_time}s before starting new operation...")
|
| 394 |
+
await asyncio.sleep(cooldown_until - current_time)
|
| 395 |
+
cooldown_until = 0
|
| 396 |
+
# Clear completion flag after cooldown
|
| 397 |
+
try:
|
| 398 |
+
ns_list = list(namespace_for_memory)
|
| 399 |
+
# Try to delete completion flag (may not exist)
|
| 400 |
+
try:
|
| 401 |
+
await client.store.delete_item(ns_list, "main_operation_complete")
|
| 402 |
+
except Exception:
|
| 403 |
+
pass
|
| 404 |
+
except Exception:
|
| 405 |
+
pass
|
| 406 |
+
|
| 407 |
+
# Determine current status based ONLY on server-side store
|
| 408 |
+
# Don't use main_job.done() because the client task finishes quickly
|
| 409 |
+
# even though the server operation continues
|
| 410 |
+
long_info = await read_latest_status(client, namespace_for_memory)
|
| 411 |
+
long_running = bool(long_info.get("status") == "running")
|
| 412 |
+
just_completed = await check_completion_flag(client, namespace_for_memory)
|
| 413 |
+
|
| 414 |
+
# If operation just completed, set cooldown but don't skip the message
|
| 415 |
+
if just_completed and last_operation_complete_time != current_time:
|
| 416 |
+
_event("status", f"{FG_MAGENTA}Operation complete! Ready for new requests.{RESET}")
|
| 417 |
+
cooldown_until = time.time() + 2.0 # 2 second cooldown
|
| 418 |
+
last_operation_complete_time = current_time
|
| 419 |
+
global_last_text.clear() # Clear dedup cache
|
| 420 |
+
main_job = None
|
| 421 |
+
# Clear completion flag
|
| 422 |
+
try:
|
| 423 |
+
ns_list = list(namespace_for_memory)
|
| 424 |
+
await client.store.delete_item(ns_list, "main_operation_complete")
|
| 425 |
+
except Exception:
|
| 426 |
+
pass
|
| 427 |
+
# Don't continue - let the message be processed after cooldown
|
| 428 |
+
# The cooldown check above will handle waiting if needed
|
| 429 |
+
|
| 430 |
+
# Routing logic: Use ONLY server-side status, not client task status
|
| 431 |
+
if long_running and not just_completed:
|
| 432 |
+
# Secondary thread: handle queries during long operation
|
| 433 |
+
progress = long_info.get("progress", "?")
|
| 434 |
+
tool_name = long_info.get("tool_name", "operation")
|
| 435 |
+
_event("routing", f"Operation in progress ({progress}%), routing to secondary thread")
|
| 436 |
+
payload = {
|
| 437 |
+
"messages": [{"type": "human", "content": user_text}],
|
| 438 |
+
"thread_type": "secondary",
|
| 439 |
+
"interim_messages_reset": False,
|
| 440 |
+
}
|
| 441 |
+
await stream_run(
|
| 442 |
+
client,
|
| 443 |
+
thread_id_updates,
|
| 444 |
+
graph,
|
| 445 |
+
payload,
|
| 446 |
+
label=f"secondary [{progress}%]",
|
| 447 |
+
namespace_for_memory=namespace_for_memory,
|
| 448 |
+
global_last_text=global_last_text,
|
| 449 |
+
)
|
| 450 |
+
interim_messages_reset = False
|
| 451 |
+
else:
|
| 452 |
+
# Main thread: start new operation
|
| 453 |
+
_event("routing", "Starting new operation on main thread (background)")
|
| 454 |
+
interim_messages_reset = True
|
| 455 |
+
global_last_text.clear() # Clear for new operation
|
| 456 |
+
payload = {
|
| 457 |
+
"messages": [{"type": "human", "content": user_text}],
|
| 458 |
+
"thread_type": "main",
|
| 459 |
+
"interim_messages_reset": interim_messages_reset,
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
async def run_main() -> int:
|
| 463 |
+
result = await stream_run(
|
| 464 |
+
client,
|
| 465 |
+
thread_id_main,
|
| 466 |
+
graph,
|
| 467 |
+
payload,
|
| 468 |
+
label="main",
|
| 469 |
+
namespace_for_memory=namespace_for_memory,
|
| 470 |
+
global_last_text=global_last_text,
|
| 471 |
+
)
|
| 472 |
+
# After completion, signal cooldown
|
| 473 |
+
return result
|
| 474 |
+
|
| 475 |
+
main_job = asyncio.create_task(run_main())
|
| 476 |
+
# Do not await; allow user to type while long task runs
|
| 477 |
+
|
| 478 |
+
# On exit, best-effort wait for background
|
| 479 |
+
if main_job is not None:
|
| 480 |
+
print(f"\n{FG_GRAY}Waiting for background task to complete...{RESET}")
|
| 481 |
+
with contextlib.suppress(Exception):
|
| 482 |
+
await asyncio.wait_for(main_job, timeout=10)
|
| 483 |
+
return 0
|
| 484 |
+
else:
|
| 485 |
+
# Non-interactive: single message to main thread
|
| 486 |
+
msg = initial_message or "Hello, I need help with my mobile account"
|
| 487 |
+
print(f"{FG_BLUE}Sending:{RESET} {msg}\n")
|
| 488 |
+
payload = {
|
| 489 |
+
"messages": [{"type": "human", "content": msg}],
|
| 490 |
+
"thread_type": "main",
|
| 491 |
+
"interim_messages_reset": True,
|
| 492 |
+
}
|
| 493 |
+
global_last_text: dict[str, str] = {}
|
| 494 |
+
return await stream_run(
|
| 495 |
+
client,
|
| 496 |
+
thread_id_main,
|
| 497 |
+
graph,
|
| 498 |
+
payload,
|
| 499 |
+
label="single",
|
| 500 |
+
namespace_for_memory=namespace_for_memory,
|
| 501 |
+
global_last_text=global_last_text,
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def main(argv: list[str]) -> int:
|
| 506 |
+
parser = argparse.ArgumentParser(
|
| 507 |
+
description="Client for multi-threaded telco agent",
|
| 508 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 509 |
+
epilog="""
|
| 510 |
+
Examples:
|
| 511 |
+
# Interactive mode (recommended)
|
| 512 |
+
python telco_client.py --interactive
|
| 513 |
+
|
| 514 |
+
# Single message
|
| 515 |
+
python telco_client.py --message "What's my current package?"
|
| 516 |
+
|
| 517 |
+
# Custom server and user
|
| 518 |
+
python telco_client.py --url http://localhost:8000 --user john_doe --interactive
|
| 519 |
+
|
| 520 |
+
# Use different thread file
|
| 521 |
+
python telco_client.py --thread-file .telco_thread --interactive
|
| 522 |
+
"""
|
| 523 |
+
)
|
| 524 |
+
parser.add_argument(
|
| 525 |
+
"--url",
|
| 526 |
+
default="http://127.0.0.1:2024",
|
| 527 |
+
help="LangGraph server base URL (default: http://127.0.0.1:2024)"
|
| 528 |
+
)
|
| 529 |
+
parser.add_argument(
|
| 530 |
+
"--graph",
|
| 531 |
+
default="telco-agent",
|
| 532 |
+
help="Graph name as defined in langgraph.json (default: telco-agent)"
|
| 533 |
+
)
|
| 534 |
+
parser.add_argument(
|
| 535 |
+
"--user",
|
| 536 |
+
default="fciannella",
|
| 537 |
+
help="User ID for namespace (default: fciannella)"
|
| 538 |
+
)
|
| 539 |
+
parser.add_argument(
|
| 540 |
+
"--interactive",
|
| 541 |
+
action="store_true",
|
| 542 |
+
help="Interactive mode (chat continuously)"
|
| 543 |
+
)
|
| 544 |
+
parser.add_argument(
|
| 545 |
+
"--thread-file",
|
| 546 |
+
default=".telco_thread_id",
|
| 547 |
+
help="Path to persist/load main thread ID (default: .telco_thread_id)"
|
| 548 |
+
)
|
| 549 |
+
parser.add_argument(
|
| 550 |
+
"--message",
|
| 551 |
+
"-m",
|
| 552 |
+
help="Single message to send (non-interactive mode)"
|
| 553 |
+
)
|
| 554 |
+
args = parser.parse_args(argv)
|
| 555 |
+
|
| 556 |
+
return asyncio.run(
|
| 557 |
+
run_client(
|
| 558 |
+
base_url=args.url,
|
| 559 |
+
graph=args.graph,
|
| 560 |
+
user_id=args.user,
|
| 561 |
+
interactive=args.interactive,
|
| 562 |
+
thread_file=args.thread_file,
|
| 563 |
+
initial_message=args.message,
|
| 564 |
+
)
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
if __name__ == "__main__":
|
| 569 |
+
raise SystemExit(main(sys.argv[1:]))
|
| 570 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wire Transfer Agent
|
| 2 |
+
|
| 3 |
+
This agent helps customers send domestic or international wire transfers using mock tools and data. It verifies the caller, collects transfer details, validates requirements, provides a quote (FX/fees/ETA), then confirms with OTP before executing.
|
| 4 |
+
|
| 5 |
+
## How to use
|
| 6 |
+
|
| 7 |
+
1. Start with your full name.
|
| 8 |
+
2. Verify identity (mandatory): provide DOB plus either SSN last-4 or your secret answer. If a secret question is returned, answer it.
|
| 9 |
+
3. Provide transfer info one by one when asked: type (DOMESTIC/INTERNATIONAL), source account (last-4), amount and source currency, destination country and currency, who pays fees (OUR/SHA/BEN).
|
| 10 |
+
4. Provide beneficiary details depending on country requirements.
|
| 11 |
+
5. Review the quote (FX rate, fees, net sent/received, ETA), then request OTP, provide the code, and confirm execution.
|
| 12 |
+
|
| 13 |
+
## Mock identities and accounts
|
| 14 |
+
|
| 15 |
+
From `mock_data/accounts.json`:
|
| 16 |
+
|
| 17 |
+
- Francesco Ciannella (`cust_test`)
|
| 18 |
+
- DOB 1990-01-01, SSN last-4 6001, secret answer "blue"
|
| 19 |
+
- Accounts: `WT-CHK-001` (wire-enabled, USD, balance 5200.50), `WT-SAV-001` (not wire-enabled)
|
| 20 |
+
- Alice Stone (`cust_alice`): last-4 1101, secret answer "green"
|
| 21 |
+
|
| 22 |
+
OTP: see `mock_data/otps.json` (default = 123456).
|
| 23 |
+
|
| 24 |
+
Country requirements (`mock_data/country_requirements.json`):
|
| 25 |
+
- US: routing_number, account_number, account_name
|
| 26 |
+
- CA: institution_number, transit_number, account_number, account_name
|
| 27 |
+
- EU: iban, account_name
|
| 28 |
+
|
| 29 |
+
## Example conversation
|
| 30 |
+
|
| 31 |
+
- Agent: Hi! What’s your full name?
|
| 32 |
+
- You: Francesco Ciannella
|
| 33 |
+
- Agent: Please provide DOB and either SSN last-4 or your secret answer.
|
| 34 |
+
- You: 1990-01-01 and 6001
|
| 35 |
+
- Agent: Verified. Is this a DOMESTIC or INTERNATIONAL wire?
|
| 36 |
+
- You: INTERNATIONAL
|
| 37 |
+
- Agent: From which account (last-4)?
|
| 38 |
+
- You: 6001
|
| 39 |
+
- Agent: How much and currency?
|
| 40 |
+
- You: 1000 USD to EUR
|
| 41 |
+
- Agent: Destination country?
|
| 42 |
+
- You: DE
|
| 43 |
+
- Agent: Who pays fees (OUR/SHA/BEN)?
|
| 44 |
+
- You: SHA
|
| 45 |
+
- Agent: Please provide beneficiary fields required for DE (EU): iban and account_name.
|
| 46 |
+
- You: iban DE89 37040044 0532013000, account_name Alice GmbH
|
| 47 |
+
- Agent: Here’s your quote: FX, fees, net sent/received, ETA. Shall I send an OTP to confirm?
|
| 48 |
+
- You: Yes
|
| 49 |
+
- Agent: Please provide the 6-digit OTP.
|
| 50 |
+
- You: 123456
|
| 51 |
+
- Agent: Transfer submitted. Confirmation WI-XXXXXX
|
| 52 |
+
|
| 53 |
+
## Notes
|
| 54 |
+
|
| 55 |
+
- Wire is only allowed from wire-enabled accounts and within daily limits and balances.
|
| 56 |
+
- For SHA/BEN, recipient fees reduce the amount received.
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wire Transfer Agent (ReAct)
|
| 2 |
+
|
| 3 |
+
This package contains a LangGraph ReAct-based agent that helps users
|
| 4 |
+
initiate domestic or international wire transfers using mock tools and fixtures.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .react_agent import agent # noqa: F401
|
| 8 |
+
|
| 9 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/fees_agent.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility shim.
|
| 2 |
+
|
| 3 |
+
Some environments may still reference `./rbc-fees-agent/fees_agent.py:agent`.
|
| 4 |
+
This file re-exports the ReAct agent defined in `react_agent.py`.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
from .react_agent import agent # noqa: F401
|
| 9 |
+
except ImportError:
|
| 10 |
+
import os as _os
|
| 11 |
+
import sys as _sys
|
| 12 |
+
_sys.path.append(_os.path.dirname(__file__))
|
| 13 |
+
from react_agent import agent # type: ignore # noqa: F401
|
| 14 |
+
|
| 15 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/logic.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from langchain_openai import ChatOpenAI
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
_FIXTURE_CACHE: Dict[str, Any] = {}
|
| 13 |
+
_DISPUTES_DB: Dict[str, Dict[str, Any]] = {}
|
| 14 |
+
_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
| 15 |
+
_OTP_DB: Dict[str, Dict[str, Any]] = {}
|
| 16 |
+
_QUOTES: Dict[str, Dict[str, Any]] = {}
|
| 17 |
+
_BENEFICIARIES_DB: Dict[str, List[Dict[str, Any]]] = {}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _fixtures_dir() -> Path:
|
| 21 |
+
return Path(__file__).parent / "mock_data"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _load_fixture(name: str) -> Any:
|
| 25 |
+
if name in _FIXTURE_CACHE:
|
| 26 |
+
return _FIXTURE_CACHE[name]
|
| 27 |
+
p = _fixtures_dir() / name
|
| 28 |
+
with p.open("r", encoding="utf-8") as f:
|
| 29 |
+
data = json.load(f)
|
| 30 |
+
_FIXTURE_CACHE[name] = data
|
| 31 |
+
return data
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _parse_iso_date(text: Optional[str]) -> Optional[datetime]:
|
| 35 |
+
if not text:
|
| 36 |
+
return None
|
| 37 |
+
try:
|
| 38 |
+
return datetime.strptime(text, "%Y-%m-%d")
|
| 39 |
+
except Exception:
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _get_customer_blob(customer_id: str) -> Dict[str, Any]:
|
| 44 |
+
data = _load_fixture("accounts.json")
|
| 45 |
+
return dict(data.get("customers", {}).get(customer_id, {}))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_accounts(customer_id: str) -> List[Dict[str, Any]]:
|
| 49 |
+
cust = _get_customer_blob(customer_id)
|
| 50 |
+
if isinstance(cust, list):
|
| 51 |
+
# backward-compat: old format was a list of accounts
|
| 52 |
+
return list(cust)
|
| 53 |
+
return list(cust.get("accounts", []))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def get_profile(customer_id: str) -> Dict[str, Any]:
|
| 57 |
+
cust = _get_customer_blob(customer_id)
|
| 58 |
+
if isinstance(cust, dict):
|
| 59 |
+
return dict(cust.get("profile", {}))
|
| 60 |
+
return {}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def find_customer_by_name(first_name: str, last_name: str) -> Dict[str, Any]:
|
| 64 |
+
data = _load_fixture("accounts.json")
|
| 65 |
+
customers = data.get("customers", {})
|
| 66 |
+
fn = (first_name or "").strip().lower()
|
| 67 |
+
ln = (last_name or "").strip().lower()
|
| 68 |
+
for cid, blob in customers.items():
|
| 69 |
+
prof = blob.get("profile") if isinstance(blob, dict) else None
|
| 70 |
+
if isinstance(prof, dict):
|
| 71 |
+
pfn = str(prof.get("first_name") or "").strip().lower()
|
| 72 |
+
pln = str(prof.get("last_name") or "").strip().lower()
|
| 73 |
+
if fn == pfn and ln == pln:
|
| 74 |
+
return {"customer_id": cid, "profile": prof}
|
| 75 |
+
return {}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def find_customer_by_full_name(full_name: str) -> Dict[str, Any]:
|
| 79 |
+
data = _load_fixture("accounts.json")
|
| 80 |
+
customers = data.get("customers", {})
|
| 81 |
+
target = (full_name or "").strip().lower()
|
| 82 |
+
for cid, blob in customers.items():
|
| 83 |
+
prof = blob.get("profile") if isinstance(blob, dict) else None
|
| 84 |
+
if isinstance(prof, dict):
|
| 85 |
+
fn = f"{str(prof.get('first_name') or '').strip()} {str(prof.get('last_name') or '').strip()}".strip().lower()
|
| 86 |
+
ff = str(prof.get("full_name") or "").strip().lower()
|
| 87 |
+
if target and (target == fn or target == ff):
|
| 88 |
+
return {"customer_id": cid, "profile": prof}
|
| 89 |
+
return {}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _normalize_dob(text: Optional[str]) -> Optional[str]:
|
| 93 |
+
if not isinstance(text, str) or not text.strip():
|
| 94 |
+
return None
|
| 95 |
+
t = text.strip().lower()
|
| 96 |
+
# YYYY-MM-DD
|
| 97 |
+
try:
|
| 98 |
+
if len(t) >= 10 and t[4] == '-' and t[7] == '-':
|
| 99 |
+
d = datetime.strptime(t[:10], "%Y-%m-%d")
|
| 100 |
+
return d.strftime("%Y-%m-%d")
|
| 101 |
+
except Exception:
|
| 102 |
+
pass
|
| 103 |
+
# YYYY MM DD or YYYY/MM/DD or YYYY.MM.DD (loosely)
|
| 104 |
+
try:
|
| 105 |
+
import re as _re
|
| 106 |
+
parts = _re.findall(r"\d+", t)
|
| 107 |
+
if len(parts) >= 3 and len(parts[0]) == 4:
|
| 108 |
+
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
|
| 109 |
+
if 1900 <= y <= 2100 and 1 <= m <= 12 and 1 <= d <= 31:
|
| 110 |
+
dt = datetime(y, m, d)
|
| 111 |
+
return dt.strftime("%Y-%m-%d")
|
| 112 |
+
except Exception:
|
| 113 |
+
pass
|
| 114 |
+
# Month name DD YYYY
|
| 115 |
+
MONTHS = {
|
| 116 |
+
"jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
|
| 117 |
+
"apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
|
| 118 |
+
"aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9,
|
| 119 |
+
"oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
|
| 120 |
+
}
|
| 121 |
+
try:
|
| 122 |
+
parts = t.replace(',', ' ').split()
|
| 123 |
+
if len(parts) >= 3 and parts[0] in MONTHS:
|
| 124 |
+
m = MONTHS[parts[0]]
|
| 125 |
+
day = int(''.join(ch for ch in parts[1] if ch.isdigit()))
|
| 126 |
+
year = int(parts[2])
|
| 127 |
+
d = datetime(year, m, day)
|
| 128 |
+
return d.strftime("%Y-%m-%d")
|
| 129 |
+
except Exception:
|
| 130 |
+
pass
|
| 131 |
+
# DD/MM/YYYY or MM/DD/YYYY
|
| 132 |
+
try:
|
| 133 |
+
for sep in ('/', '-'):
|
| 134 |
+
if sep in t and t.count(sep) == 2:
|
| 135 |
+
a, b, c = t.split(sep)[:3]
|
| 136 |
+
if len(c) == 4 and a.isdigit() and b.isdigit() and c.isdigit():
|
| 137 |
+
da, db, dy = int(a), int(b), int(c)
|
| 138 |
+
# If first looks like month, assume MM/DD
|
| 139 |
+
if 1 <= da <= 12 and 1 <= db <= 31:
|
| 140 |
+
d = datetime(dy, da, db)
|
| 141 |
+
else:
|
| 142 |
+
# assume DD/MM
|
| 143 |
+
d = datetime(dy, db, da)
|
| 144 |
+
return d.strftime("%Y-%m-%d")
|
| 145 |
+
except Exception:
|
| 146 |
+
pass
|
| 147 |
+
return None
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _find_account_by_id(account_id: str) -> Optional[Dict[str, Any]]:
|
| 151 |
+
data = _load_fixture("accounts.json")
|
| 152 |
+
customers = data.get("customers", {})
|
| 153 |
+
for _, blob in customers.items():
|
| 154 |
+
accts = (blob or {}).get("accounts", [])
|
| 155 |
+
for a in accts or []:
|
| 156 |
+
if str(a.get("account_id")) == account_id:
|
| 157 |
+
return a
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def get_account_balance(account_id: str) -> Dict[str, Any]:
|
| 162 |
+
acc = _find_account_by_id(account_id) or {}
|
| 163 |
+
return {
|
| 164 |
+
"account_id": account_id,
|
| 165 |
+
"currency": acc.get("currency"),
|
| 166 |
+
"balance": float(acc.get("balance", 0.0)),
|
| 167 |
+
"daily_wire_limit": float(acc.get("daily_wire_limit", 0.0)),
|
| 168 |
+
"wire_enabled": bool(acc.get("wire_enabled", False)),
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def get_exchange_rate(from_currency: str, to_currency: str, amount: float) -> Dict[str, Any]:
|
| 173 |
+
if from_currency.upper() == to_currency.upper():
|
| 174 |
+
return {
|
| 175 |
+
"from": from_currency.upper(),
|
| 176 |
+
"to": to_currency.upper(),
|
| 177 |
+
"mid_rate": 1.0,
|
| 178 |
+
"applied_rate": 1.0,
|
| 179 |
+
"margin_bps": 0,
|
| 180 |
+
"converted_amount": round(float(amount), 2),
|
| 181 |
+
}
|
| 182 |
+
data = _load_fixture("exchange_rates.json")
|
| 183 |
+
pairs = data.get("pairs", [])
|
| 184 |
+
mid = None
|
| 185 |
+
bps = 150
|
| 186 |
+
fc = from_currency.upper()
|
| 187 |
+
tc = to_currency.upper()
|
| 188 |
+
for p in pairs:
|
| 189 |
+
if str(p.get("from")).upper() == fc and str(p.get("to")).upper() == tc:
|
| 190 |
+
mid = float(p.get("mid_rate"))
|
| 191 |
+
bps = int(p.get("margin_bps", bps))
|
| 192 |
+
break
|
| 193 |
+
if mid is None:
|
| 194 |
+
# naive inverse lookup
|
| 195 |
+
for p in pairs:
|
| 196 |
+
if str(p.get("from")).upper() == tc and str(p.get("to")).upper() == fc:
|
| 197 |
+
inv = float(p.get("mid_rate"))
|
| 198 |
+
mid = 1.0 / inv if inv else None
|
| 199 |
+
bps = int(p.get("margin_bps", bps))
|
| 200 |
+
break
|
| 201 |
+
if mid is None:
|
| 202 |
+
mid = 1.0
|
| 203 |
+
applied = mid * (1.0 - bps / 10000.0)
|
| 204 |
+
converted = float(amount) * applied
|
| 205 |
+
return {
|
| 206 |
+
"from": fc,
|
| 207 |
+
"to": tc,
|
| 208 |
+
"mid_rate": round(mid, 6),
|
| 209 |
+
"applied_rate": round(applied, 6),
|
| 210 |
+
"margin_bps": bps,
|
| 211 |
+
"converted_amount": round(converted, 2),
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def calculate_wire_fee(kind: str, amount: float, from_currency: str, to_currency: str, payer: str) -> Dict[str, Any]:
|
| 216 |
+
fees = _load_fixture("fee_schedules.json")
|
| 217 |
+
k = (kind or "").strip().upper()
|
| 218 |
+
payer_opt = (payer or "SHA").strip().upper()
|
| 219 |
+
if k not in ("DOMESTIC", "INTERNATIONAL"):
|
| 220 |
+
return {"error": "invalid_type", "message": "type must be DOMESTIC or INTERNATIONAL"}
|
| 221 |
+
if payer_opt not in ("OUR", "SHA", "BEN"):
|
| 222 |
+
return {"error": "invalid_payer", "message": "payer must be OUR, SHA, or BEN"}
|
| 223 |
+
breakdown: Dict[str, float] = {}
|
| 224 |
+
if k == "DOMESTIC":
|
| 225 |
+
breakdown["DOMESTIC_BASE"] = float(fees.get("DOMESTIC", {}).get("base_fee", 15.0))
|
| 226 |
+
else:
|
| 227 |
+
intl = fees.get("INTERNATIONAL", {})
|
| 228 |
+
breakdown["INTERNATIONAL_BASE"] = float(intl.get("base_fee", 25.0))
|
| 229 |
+
breakdown["SWIFT"] = float(intl.get("swift_network_fee", 5.0))
|
| 230 |
+
breakdown["CORRESPONDENT"] = float(intl.get("correspondent_fee", 10.0))
|
| 231 |
+
breakdown["LIFTING"] = float(intl.get("lifting_fee", 5.0))
|
| 232 |
+
|
| 233 |
+
initiator = 0.0
|
| 234 |
+
recipient = 0.0
|
| 235 |
+
for code, fee in breakdown.items():
|
| 236 |
+
if payer_opt == "OUR":
|
| 237 |
+
initiator += fee
|
| 238 |
+
elif payer_opt == "SHA":
|
| 239 |
+
# Sender pays origin bank fees (base, swift); recipient pays intermediary (correspondent/lifting)
|
| 240 |
+
if code in ("DOMESTIC_BASE", "INTERNATIONAL_BASE", "SWIFT"):
|
| 241 |
+
initiator += fee
|
| 242 |
+
else:
|
| 243 |
+
recipient += fee
|
| 244 |
+
elif payer_opt == "BEN":
|
| 245 |
+
recipient += fee
|
| 246 |
+
return {
|
| 247 |
+
"type": k,
|
| 248 |
+
"payer": payer_opt,
|
| 249 |
+
"from_currency": from_currency.upper(),
|
| 250 |
+
"to_currency": to_currency.upper(),
|
| 251 |
+
"amount": float(amount),
|
| 252 |
+
"initiator_fees_total": round(initiator, 2),
|
| 253 |
+
"recipient_fees_total": round(recipient, 2),
|
| 254 |
+
"breakdown": {k: round(v, 2) for k, v in breakdown.items()},
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def screen_sanctions(name: str, country: str) -> Dict[str, Any]:
|
| 259 |
+
data = _load_fixture("sanctions_list.json")
|
| 260 |
+
blocked = data.get("blocked", [])
|
| 261 |
+
nm = (name or "").strip().lower()
|
| 262 |
+
cc = (country or "").strip().upper()
|
| 263 |
+
for e in blocked:
|
| 264 |
+
if str(e.get("name", "")).strip().lower() == nm and str(e.get("country", "")).strip().upper() == cc:
|
| 265 |
+
return {"cleared": False, "reason": "Sanctions match"}
|
| 266 |
+
return {"cleared": True}
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def check_wire_limits(account_id: str, amount: float) -> Dict[str, Any]:
|
| 270 |
+
acc = _find_account_by_id(account_id) or {}
|
| 271 |
+
if not acc:
|
| 272 |
+
return {"ok": False, "reason": "account_not_found"}
|
| 273 |
+
bal = float(acc.get("balance", 0.0))
|
| 274 |
+
lim = float(acc.get("daily_wire_limit", 0.0))
|
| 275 |
+
if not bool(acc.get("wire_enabled", False)):
|
| 276 |
+
return {"ok": False, "reason": "wire_not_enabled"}
|
| 277 |
+
if amount > lim:
|
| 278 |
+
return {"ok": False, "reason": "exceeds_daily_limit", "limit": lim}
|
| 279 |
+
if amount > bal:
|
| 280 |
+
return {"ok": False, "reason": "insufficient_funds", "balance": bal}
|
| 281 |
+
return {"ok": True, "balance": bal, "limit": lim}
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def get_cutoff_and_eta(kind: str, country: str) -> Dict[str, Any]:
|
| 285 |
+
cfg = _load_fixture("cutoff_times.json")
|
| 286 |
+
k = (kind or "").strip().upper()
|
| 287 |
+
key = "DOMESTIC" if k == "DOMESTIC" else "INTERNATIONAL"
|
| 288 |
+
info = cfg.get(key, {})
|
| 289 |
+
return {
|
| 290 |
+
"cutoff_local": info.get("cutoff_local", "17:00"),
|
| 291 |
+
"eta_hours": list(info.get("eta_hours", [24, 72])),
|
| 292 |
+
"country": country
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def get_country_requirements(code: str) -> List[str]:
|
| 297 |
+
data = _load_fixture("country_requirements.json")
|
| 298 |
+
return list(data.get(code.upper(), []))
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def validate_beneficiary(country_code: str, beneficiary: Dict[str, Any]) -> Dict[str, Any]:
|
| 302 |
+
required = get_country_requirements(country_code)
|
| 303 |
+
missing: List[str] = []
|
| 304 |
+
for field in required:
|
| 305 |
+
if not isinstance(beneficiary.get(field), str) or not str(beneficiary.get(field)).strip():
|
| 306 |
+
missing.append(field)
|
| 307 |
+
return {"ok": len(missing) == 0, "missing": missing}
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def save_beneficiary(customer_id: str, beneficiary: Dict[str, Any]) -> Dict[str, Any]:
|
| 311 |
+
arr = _BENEFICIARIES_DB.setdefault(customer_id, [])
|
| 312 |
+
bid = beneficiary.get("beneficiary_id") or f"B-{uuid.uuid4().hex[:6]}"
|
| 313 |
+
entry = dict(beneficiary)
|
| 314 |
+
entry["beneficiary_id"] = bid
|
| 315 |
+
arr.append(entry)
|
| 316 |
+
return {"beneficiary_id": bid}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def generate_otp(customer_id: str) -> Dict[str, Any]:
|
| 320 |
+
# Prefer static OTP from fixture for predictable testing
|
| 321 |
+
static = None
|
| 322 |
+
try:
|
| 323 |
+
data = _load_fixture("otps.json")
|
| 324 |
+
if isinstance(data, dict):
|
| 325 |
+
byc = data.get("by_customer", {}) or {}
|
| 326 |
+
static = byc.get(customer_id) or data.get("default")
|
| 327 |
+
except Exception:
|
| 328 |
+
static = None
|
| 329 |
+
code = str(static or f"{uuid.uuid4().int % 1000000:06d}").zfill(6)
|
| 330 |
+
_OTP_DB[customer_id] = {"otp": code, "created_at": datetime.utcnow().isoformat() + "Z"}
|
| 331 |
+
# In real world, send to phone/email; here we mask
|
| 332 |
+
resp = {"sent": True, "destination": "on-file", "masked": "***-***-****"}
|
| 333 |
+
try:
|
| 334 |
+
if os.getenv("WIRE_DEBUG_OTP", "0").lower() not in ("", "0", "false"): # dev convenience
|
| 335 |
+
resp["debug_code"] = code
|
| 336 |
+
except Exception:
|
| 337 |
+
pass
|
| 338 |
+
return resp
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def verify_otp(customer_id: str, otp: str) -> Dict[str, Any]:
|
| 342 |
+
rec = _OTP_DB.get(customer_id) or {}
|
| 343 |
+
ok = str(rec.get("otp")) == str(otp)
|
| 344 |
+
if ok:
|
| 345 |
+
rec["used_at"] = datetime.utcnow().isoformat() + "Z"
|
| 346 |
+
_OTP_DB[customer_id] = rec
|
| 347 |
+
return {"verified": ok}
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def authenticate_user_wire(session_id: str, customer_id: Optional[str], full_name: Optional[str], dob_yyyy_mm_dd: Optional[str], ssn_last4: Optional[str], secret_answer: Optional[str]) -> Dict[str, Any]:
|
| 351 |
+
session = _SESSIONS.get(session_id) or {"verified": False, "customer_id": customer_id, "name": full_name}
|
| 352 |
+
if isinstance(customer_id, str) and customer_id:
|
| 353 |
+
session["customer_id"] = customer_id
|
| 354 |
+
if isinstance(full_name, str) and full_name:
|
| 355 |
+
session["name"] = full_name
|
| 356 |
+
if isinstance(dob_yyyy_mm_dd, str) and dob_yyyy_mm_dd:
|
| 357 |
+
session["dob"] = dob_yyyy_mm_dd
|
| 358 |
+
if isinstance(ssn_last4, str) and ssn_last4:
|
| 359 |
+
session["ssn_last4"] = ssn_last4
|
| 360 |
+
if isinstance(secret_answer, str) and secret_answer:
|
| 361 |
+
session["secret"] = secret_answer
|
| 362 |
+
|
| 363 |
+
ok = False
|
| 364 |
+
cid = session.get("customer_id")
|
| 365 |
+
if isinstance(cid, str):
|
| 366 |
+
prof = get_profile(cid)
|
| 367 |
+
user_dob_norm = _normalize_dob(session.get("dob"))
|
| 368 |
+
prof_dob_norm = _normalize_dob(prof.get("dob"))
|
| 369 |
+
dob_ok = (user_dob_norm is not None) and (user_dob_norm == prof_dob_norm)
|
| 370 |
+
ssn_ok = str(session.get("ssn_last4") or "") == str(prof.get("ssn_last4") or "")
|
| 371 |
+
def _norm(x: Optional[str]) -> str:
|
| 372 |
+
# Extract only the core answer, removing common phrases
|
| 373 |
+
s = (x or "").strip().lower()
|
| 374 |
+
# Remove common prefixes that users might add
|
| 375 |
+
for prefix in ["my favorite color is ", "my favorite ", "it is ", "it's ", "the answer is "]:
|
| 376 |
+
if s.startswith(prefix):
|
| 377 |
+
s = s[len(prefix):].strip()
|
| 378 |
+
return s
|
| 379 |
+
secret_ok = _norm(session.get("secret")) == _norm(prof.get("secret_answer"))
|
| 380 |
+
if dob_ok and (ssn_ok or secret_ok):
|
| 381 |
+
ok = True
|
| 382 |
+
session["verified"] = ok
|
| 383 |
+
_SESSIONS[session_id] = session
|
| 384 |
+
need: List[str] = []
|
| 385 |
+
if _normalize_dob(session.get("dob")) is None:
|
| 386 |
+
need.append("dob")
|
| 387 |
+
if not session.get("ssn_last4") and not session.get("secret"):
|
| 388 |
+
need.append("ssn_last4_or_secret")
|
| 389 |
+
if not session.get("customer_id"):
|
| 390 |
+
need.append("customer")
|
| 391 |
+
resp: Dict[str, Any] = {"session_id": session_id, "verified": ok, "needs": need, "profile": {"name": session.get("name")}}
|
| 392 |
+
try:
|
| 393 |
+
if isinstance(session.get("customer_id"), str):
|
| 394 |
+
prof = get_profile(session.get("customer_id"))
|
| 395 |
+
if isinstance(prof, dict) and prof.get("secret_question"):
|
| 396 |
+
resp["question"] = prof.get("secret_question")
|
| 397 |
+
except Exception:
|
| 398 |
+
pass
|
| 399 |
+
return resp
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def quote_wire(kind: str, from_account_id: str, beneficiary: Dict[str, Any], amount: float, from_currency: str, to_currency: str, payer: str) -> Dict[str, Any]:
|
| 403 |
+
# FX
|
| 404 |
+
fx = get_exchange_rate(from_currency, to_currency, amount)
|
| 405 |
+
converted_amount = fx["converted_amount"]
|
| 406 |
+
# Fees
|
| 407 |
+
fee = calculate_wire_fee(kind, amount, from_currency, to_currency, payer)
|
| 408 |
+
# Limits and balance
|
| 409 |
+
limits = check_wire_limits(from_account_id, amount)
|
| 410 |
+
if not limits.get("ok"):
|
| 411 |
+
return {"error": "limit_or_balance", "details": limits}
|
| 412 |
+
# Sanctions
|
| 413 |
+
sanc = screen_sanctions(str(beneficiary.get("account_name") or beneficiary.get("name") or ""), str(beneficiary.get("country") or ""))
|
| 414 |
+
if not sanc.get("cleared"):
|
| 415 |
+
return {"error": "sanctions", "details": sanc}
|
| 416 |
+
# ETA
|
| 417 |
+
eta = get_cutoff_and_eta(kind, str(beneficiary.get("country") or ""))
|
| 418 |
+
|
| 419 |
+
payer_opt = (payer or "SHA").upper()
|
| 420 |
+
initiator_fees = float(fee.get("initiator_fees_total", 0.0))
|
| 421 |
+
recipient_fees = float(fee.get("recipient_fees_total", 0.0))
|
| 422 |
+
net_sent = float(amount) + (initiator_fees if payer_opt in ("OUR", "SHA") else 0.0)
|
| 423 |
+
# recipient side fees reduce the amount received when SHA/BEN
|
| 424 |
+
net_received = float(converted_amount)
|
| 425 |
+
if payer_opt in ("SHA", "BEN"):
|
| 426 |
+
net_received = max(0.0, net_received - recipient_fees)
|
| 427 |
+
|
| 428 |
+
qid = f"Q-{uuid.uuid4().hex[:8]}"
|
| 429 |
+
quote = {
|
| 430 |
+
"quote_id": qid,
|
| 431 |
+
"type": kind.upper(),
|
| 432 |
+
"from_account_id": from_account_id,
|
| 433 |
+
"amount": float(amount),
|
| 434 |
+
"from_currency": from_currency.upper(),
|
| 435 |
+
"to_currency": to_currency.upper(),
|
| 436 |
+
"payer": payer_opt,
|
| 437 |
+
"fx": fx,
|
| 438 |
+
"fees": fee,
|
| 439 |
+
"net_sent": round(net_sent, 2),
|
| 440 |
+
"net_received": round(net_received, 2),
|
| 441 |
+
"eta": eta,
|
| 442 |
+
"created_at": datetime.utcnow().isoformat() + "Z",
|
| 443 |
+
"expires_at": (datetime.utcnow().isoformat() + "Z")
|
| 444 |
+
}
|
| 445 |
+
_QUOTES[qid] = quote
|
| 446 |
+
return quote
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def wire_transfer_domestic(quote_id: str, otp: str) -> Dict[str, Any]:
|
| 450 |
+
q = _QUOTES.get(quote_id)
|
| 451 |
+
if not q or q.get("type") != "DOMESTIC":
|
| 452 |
+
return {"error": "invalid_quote"}
|
| 453 |
+
# OTP expected: we need customer_id context; skip and assume OTP verified externally
|
| 454 |
+
conf = f"WD-{uuid.uuid4().hex[:8]}"
|
| 455 |
+
return {"confirmation_id": conf, "status": "submitted"}
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def wire_transfer_international(quote_id: str, otp: str) -> Dict[str, Any]:
|
| 459 |
+
q = _QUOTES.get(quote_id)
|
| 460 |
+
if not q or q.get("type") != "INTERNATIONAL":
|
| 461 |
+
return {"error": "invalid_quote"}
|
| 462 |
+
conf = f"WI-{uuid.uuid4().hex[:8]}"
|
| 463 |
+
return {"confirmation_id": conf, "status": "submitted"}
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def list_transactions(account_id: str, start: Optional[str], end: Optional[str]) -> List[Dict[str, Any]]:
|
| 467 |
+
data = _load_fixture("transactions.json")
|
| 468 |
+
txns = list(data.get(account_id, []))
|
| 469 |
+
if start or end:
|
| 470 |
+
start_dt = _parse_iso_date(start) or datetime.min
|
| 471 |
+
end_dt = _parse_iso_date(end) or datetime.max
|
| 472 |
+
out: List[Dict[str, Any]] = []
|
| 473 |
+
for t in txns:
|
| 474 |
+
td = _parse_iso_date(t.get("date"))
|
| 475 |
+
if td and start_dt <= td <= end_dt:
|
| 476 |
+
out.append(t)
|
| 477 |
+
return out
|
| 478 |
+
return txns
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def get_fee_schedule(product_type: str) -> Dict[str, Any]:
|
| 482 |
+
data = _load_fixture("fee_schedules.json")
|
| 483 |
+
return dict(data.get(product_type.upper(), {}))
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def detect_fees(transactions: List[Dict[str, Any]], schedule: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 487 |
+
results: List[Dict[str, Any]] = []
|
| 488 |
+
for t in transactions:
|
| 489 |
+
if str(t.get("entry_type")).upper() == "FEE":
|
| 490 |
+
fee_code = (t.get("fee_code") or "").upper()
|
| 491 |
+
sched_entry = None
|
| 492 |
+
for s in schedule.get("fees", []) or []:
|
| 493 |
+
if str(s.get("code", "")).upper() == fee_code:
|
| 494 |
+
sched_entry = s
|
| 495 |
+
break
|
| 496 |
+
evt = {
|
| 497 |
+
"id": t.get("id") or str(uuid.uuid4()),
|
| 498 |
+
"posted_date": t.get("date"),
|
| 499 |
+
"amount": float(t.get("amount", 0)),
|
| 500 |
+
"description": t.get("description") or fee_code,
|
| 501 |
+
"fee_code": fee_code,
|
| 502 |
+
"schedule": sched_entry or None,
|
| 503 |
+
}
|
| 504 |
+
results.append(evt)
|
| 505 |
+
try:
|
| 506 |
+
results.sort(key=lambda x: x.get("posted_date") or "")
|
| 507 |
+
except Exception:
|
| 508 |
+
pass
|
| 509 |
+
return results
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def explain_fee(fee_event: Dict[str, Any]) -> str:
|
| 513 |
+
openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 514 |
+
code = (fee_event.get("fee_code") or "").upper()
|
| 515 |
+
name = fee_event.get("schedule", {}).get("name") or code.title()
|
| 516 |
+
posted = fee_event.get("posted_date") or ""
|
| 517 |
+
amount = float(fee_event.get("amount") or 0)
|
| 518 |
+
policy = fee_event.get("schedule", {}).get("policy") or ""
|
| 519 |
+
if not openai_api_key:
|
| 520 |
+
base = f"You were charged {name} on {posted} for CAD {amount:.2f}."
|
| 521 |
+
if code == "NSF":
|
| 522 |
+
return base + " This is applied when a payment is attempted but the account balance was insufficient."
|
| 523 |
+
if code == "MAINTENANCE":
|
| 524 |
+
return base + " This is the monthly account fee as per your account plan."
|
| 525 |
+
if code == "ATM":
|
| 526 |
+
return base + " This fee applies to certain ATM withdrawals."
|
| 527 |
+
return base + " This fee was identified based on your recent transactions."
|
| 528 |
+
|
| 529 |
+
llm = ChatOpenAI(model=os.getenv("EXPLAIN_MODEL", "gpt-4o"), api_key=openai_api_key)
|
| 530 |
+
chain = EXPLAIN_FEE_PROMPT | llm
|
| 531 |
+
out = chain.invoke(
|
| 532 |
+
{
|
| 533 |
+
"fee_code": code,
|
| 534 |
+
"posted_date": posted,
|
| 535 |
+
"amount": f"{amount:.2f}",
|
| 536 |
+
"schedule_name": name,
|
| 537 |
+
"schedule_policy": policy,
|
| 538 |
+
}
|
| 539 |
+
)
|
| 540 |
+
text = getattr(out, "content", None)
|
| 541 |
+
return text if isinstance(text, str) and text.strip() else f"You were charged {name} on {posted} for CAD {amount:.2f}."
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def check_dispute_eligibility(fee_event: Dict[str, Any]) -> Dict[str, Any]:
|
| 545 |
+
code = (fee_event.get("fee_code") or "").upper()
|
| 546 |
+
amount = float(fee_event.get("amount", 0))
|
| 547 |
+
first_time = bool(fee_event.get("first_time_90d", False))
|
| 548 |
+
eligible = False
|
| 549 |
+
reason = ""
|
| 550 |
+
if code in {"NSF", "ATM", "MAINTENANCE", "WITHDRAWAL"} and amount <= 20.0 and first_time:
|
| 551 |
+
eligible = True
|
| 552 |
+
reason = "First occurrence in 90 days and small amount"
|
| 553 |
+
return {"eligible": eligible, "reason": reason}
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def create_dispute_case(fee_event: Dict[str, Any], idempotency_key: str) -> Dict[str, Any]:
|
| 557 |
+
if idempotency_key in _DISPUTES_DB:
|
| 558 |
+
return _DISPUTES_DB[idempotency_key]
|
| 559 |
+
case = {
|
| 560 |
+
"case_id": str(uuid.uuid4()),
|
| 561 |
+
"status": "submitted",
|
| 562 |
+
"fee_id": fee_event.get("id"),
|
| 563 |
+
"created_at": datetime.utcnow().isoformat() + "Z",
|
| 564 |
+
}
|
| 565 |
+
_DISPUTES_DB[idempotency_key] = case
|
| 566 |
+
return case
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def authenticate_user(session_id: str, name: Optional[str], dob_yyyy_mm_dd: Optional[str], last4: Optional[str], secret_answer: Optional[str], customer_id: Optional[str] = None) -> Dict[str, Any]:
|
| 570 |
+
"""Mock identity verification.
|
| 571 |
+
|
| 572 |
+
Rules (mock):
|
| 573 |
+
- If dob == 1990-01-01 and last4 == 6001 or secret_answer == "blue", auth succeeds.
|
| 574 |
+
- Otherwise, remains pending with which fields are still missing.
|
| 575 |
+
Persists per session_id.
|
| 576 |
+
"""
|
| 577 |
+
session = _SESSIONS.get(session_id) or {"verified": False, "name": name, "customer_id": customer_id}
|
| 578 |
+
if isinstance(name, str) and name:
|
| 579 |
+
session["name"] = name
|
| 580 |
+
if isinstance(customer_id, str) and customer_id:
|
| 581 |
+
session["customer_id"] = customer_id
|
| 582 |
+
if isinstance(dob_yyyy_mm_dd, str) and dob_yyyy_mm_dd:
|
| 583 |
+
# Normalize DOB to YYYY-MM-DD
|
| 584 |
+
norm = _normalize_dob(dob_yyyy_mm_dd)
|
| 585 |
+
session["dob"] = norm or dob_yyyy_mm_dd
|
| 586 |
+
if isinstance(last4, str) and last4:
|
| 587 |
+
session["last4"] = last4
|
| 588 |
+
if isinstance(secret_answer, str) and secret_answer:
|
| 589 |
+
session["secret"] = secret_answer
|
| 590 |
+
|
| 591 |
+
ok = False
|
| 592 |
+
# If a specific customer is in context, validate against their profile and accounts
|
| 593 |
+
if isinstance(session.get("customer_id"), str):
|
| 594 |
+
prof = get_profile(session.get("customer_id"))
|
| 595 |
+
accts = get_accounts(session.get("customer_id"))
|
| 596 |
+
dob_ok = _normalize_dob(session.get("dob")) == _normalize_dob(prof.get("dob")) and bool(session.get("dob"))
|
| 597 |
+
last4s = {str(a.get("account_number"))[-4:] for a in accts if a.get("account_number")}
|
| 598 |
+
last4_ok = isinstance(session.get("last4"), str) and session.get("last4") in last4s
|
| 599 |
+
def _norm_secret(x: Optional[str]) -> str:
|
| 600 |
+
# Extract only the core answer, removing common phrases
|
| 601 |
+
s = (x or "").strip().lower()
|
| 602 |
+
# Remove common prefixes that users might add
|
| 603 |
+
for prefix in ["my favorite color is ", "my favorite ", "it is ", "it's ", "the answer is "]:
|
| 604 |
+
if s.startswith(prefix):
|
| 605 |
+
s = s[len(prefix):].strip()
|
| 606 |
+
return s
|
| 607 |
+
secret_ok = _norm_secret(session.get("secret")) == _norm_secret(prof.get("secret_answer"))
|
| 608 |
+
if dob_ok and (last4_ok or secret_ok):
|
| 609 |
+
ok = True
|
| 610 |
+
else:
|
| 611 |
+
# Optional demo fallback (disabled by default)
|
| 612 |
+
allow_fallback = os.getenv("RBC_FEES_ALLOW_GLOBAL_FALLBACK", "0") not in ("", "0", "false", "False")
|
| 613 |
+
if allow_fallback and session.get("dob") == "1990-01-01" and (session.get("last4") == "6001" or (session.get("secret") or "").strip().lower() == "blue"):
|
| 614 |
+
ok = True
|
| 615 |
+
session["verified"] = ok
|
| 616 |
+
_SESSIONS[session_id] = session
|
| 617 |
+
need: list[str] = []
|
| 618 |
+
if not session.get("dob"):
|
| 619 |
+
need.append("dob")
|
| 620 |
+
if not session.get("last4") and not session.get("secret"):
|
| 621 |
+
need.append("last4_or_secret")
|
| 622 |
+
if not session.get("customer_id"):
|
| 623 |
+
need.append("customer")
|
| 624 |
+
resp: Dict[str, Any] = {"session_id": session_id, "verified": ok, "needs": need, "profile": {"name": session.get("name")}}
|
| 625 |
+
try:
|
| 626 |
+
if isinstance(session.get("customer_id"), str):
|
| 627 |
+
prof = get_profile(session.get("customer_id"))
|
| 628 |
+
if isinstance(prof, dict) and prof.get("secret_question"):
|
| 629 |
+
resp["question"] = prof.get("secret_question")
|
| 630 |
+
except Exception:
|
| 631 |
+
pass
|
| 632 |
+
return resp
|
| 633 |
+
|
| 634 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/accounts.json
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"customers": {
|
| 3 |
+
"cust_test": {
|
| 4 |
+
"profile": {
|
| 5 |
+
"first_name": "Francesco",
|
| 6 |
+
"last_name": "Ciannella",
|
| 7 |
+
"full_name": "Francesco Ciannella",
|
| 8 |
+
"dob": "1990-01-01",
|
| 9 |
+
"ssn_last4": "6001",
|
| 10 |
+
"secret_question": "What is your favorite color?",
|
| 11 |
+
"secret_answer": "blue",
|
| 12 |
+
"phone_masked": "***-***-1234"
|
| 13 |
+
},
|
| 14 |
+
"accounts": [
|
| 15 |
+
{
|
| 16 |
+
"account_id": "WT-CHK-001",
|
| 17 |
+
"product_type": "CHK",
|
| 18 |
+
"nickname": "Everyday Chequing",
|
| 19 |
+
"account_number": "6001",
|
| 20 |
+
"currency": "USD",
|
| 21 |
+
"balance": 5200.50,
|
| 22 |
+
"wire_enabled": true,
|
| 23 |
+
"daily_wire_limit": 10000.00
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"account_id": "WT-SAV-001",
|
| 27 |
+
"product_type": "SAV",
|
| 28 |
+
"nickname": "High Interest Savings",
|
| 29 |
+
"account_number": "7182",
|
| 30 |
+
"currency": "USD",
|
| 31 |
+
"balance": 12000.00,
|
| 32 |
+
"wire_enabled": false,
|
| 33 |
+
"daily_wire_limit": 0.00
|
| 34 |
+
}
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
"cust_alice": {
|
| 38 |
+
"profile": {
|
| 39 |
+
"first_name": "Alice",
|
| 40 |
+
"last_name": "Stone",
|
| 41 |
+
"full_name": "Alice Stone",
|
| 42 |
+
"dob": "1985-05-12",
|
| 43 |
+
"ssn_last4": "1101",
|
| 44 |
+
"secret_question": "Favorite color?",
|
| 45 |
+
"secret_answer": "green",
|
| 46 |
+
"phone_masked": "***-***-2211"
|
| 47 |
+
},
|
| 48 |
+
"accounts": [
|
| 49 |
+
{
|
| 50 |
+
"account_id": "WT-CHK-101",
|
| 51 |
+
"product_type": "CHK",
|
| 52 |
+
"nickname": "Everyday Chequing",
|
| 53 |
+
"account_number": "1101",
|
| 54 |
+
"currency": "CAD",
|
| 55 |
+
"balance": 2450.00,
|
| 56 |
+
"wire_enabled": true,
|
| 57 |
+
"daily_wire_limit": 7500.00
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"account_id": "WT-SAV-101",
|
| 61 |
+
"product_type": "SAV",
|
| 62 |
+
"nickname": "High Interest Savings",
|
| 63 |
+
"account_number": "7101",
|
| 64 |
+
"currency": "CAD",
|
| 65 |
+
"balance": 8000.00,
|
| 66 |
+
"wire_enabled": false,
|
| 67 |
+
"daily_wire_limit": 0.00
|
| 68 |
+
}
|
| 69 |
+
]
|
| 70 |
+
},
|
| 71 |
+
"cust_bob": {
|
| 72 |
+
"profile": {
|
| 73 |
+
"first_name": "Bob",
|
| 74 |
+
"last_name": "Rivera",
|
| 75 |
+
"full_name": "Bob Rivera",
|
| 76 |
+
"dob": "1978-11-30",
|
| 77 |
+
"ssn_last4": "1202",
|
| 78 |
+
"secret_question": "Favorite color?",
|
| 79 |
+
"secret_answer": "red",
|
| 80 |
+
"phone_masked": "***-***-3322"
|
| 81 |
+
},
|
| 82 |
+
"accounts": [
|
| 83 |
+
{
|
| 84 |
+
"account_id": "WT-CHK-202",
|
| 85 |
+
"product_type": "CHK",
|
| 86 |
+
"nickname": "Primary Chequing",
|
| 87 |
+
"account_number": "1202",
|
| 88 |
+
"currency": "USD",
|
| 89 |
+
"balance": 3900.00,
|
| 90 |
+
"wire_enabled": true,
|
| 91 |
+
"daily_wire_limit": 5000.00
|
| 92 |
+
}
|
| 93 |
+
]
|
| 94 |
+
},
|
| 95 |
+
"cust_carla": {
|
| 96 |
+
"profile": {
|
| 97 |
+
"first_name": "Carla",
|
| 98 |
+
"last_name": "Nguyen",
|
| 99 |
+
"full_name": "Carla Nguyen",
|
| 100 |
+
"dob": "1992-03-14",
|
| 101 |
+
"ssn_last4": "7303",
|
| 102 |
+
"secret_question": "Favorite color?",
|
| 103 |
+
"secret_answer": "blue",
|
| 104 |
+
"phone_masked": "***-***-4433"
|
| 105 |
+
},
|
| 106 |
+
"accounts": [
|
| 107 |
+
{
|
| 108 |
+
"account_id": "WT-SAV-303",
|
| 109 |
+
"product_type": "SAV",
|
| 110 |
+
"nickname": "Savings",
|
| 111 |
+
"account_number": "7303",
|
| 112 |
+
"currency": "EUR",
|
| 113 |
+
"balance": 1500.00,
|
| 114 |
+
"wire_enabled": true,
|
| 115 |
+
"daily_wire_limit": 3000.00
|
| 116 |
+
}
|
| 117 |
+
]
|
| 118 |
+
},
|
| 119 |
+
"cust_dave": {
|
| 120 |
+
"profile": {
|
| 121 |
+
"first_name": "David",
|
| 122 |
+
"last_name": "Patel",
|
| 123 |
+
"full_name": "David Patel",
|
| 124 |
+
"dob": "1989-07-21",
|
| 125 |
+
"ssn_last4": "1404",
|
| 126 |
+
"secret_question": "Favorite animal?",
|
| 127 |
+
"secret_answer": "tiger",
|
| 128 |
+
"phone_masked": "***-***-5544"
|
| 129 |
+
},
|
| 130 |
+
"accounts": [
|
| 131 |
+
{
|
| 132 |
+
"account_id": "WT-CHK-404",
|
| 133 |
+
"product_type": "CHK",
|
| 134 |
+
"nickname": "Everyday Chequing",
|
| 135 |
+
"account_number": "1404",
|
| 136 |
+
"currency": "USD",
|
| 137 |
+
"balance": 15000.00,
|
| 138 |
+
"wire_enabled": true,
|
| 139 |
+
"daily_wire_limit": 20000.00
|
| 140 |
+
}
|
| 141 |
+
]
|
| 142 |
+
},
|
| 143 |
+
"cust_eve": {
|
| 144 |
+
"profile": {
|
| 145 |
+
"first_name": "Evelyn",
|
| 146 |
+
"last_name": "Moore",
|
| 147 |
+
"full_name": "Evelyn Moore",
|
| 148 |
+
"dob": "1995-09-09",
|
| 149 |
+
"ssn_last4": "7505",
|
| 150 |
+
"secret_question": "Favorite season?",
|
| 151 |
+
"secret_answer": "summer",
|
| 152 |
+
"phone_masked": "***-***-6655"
|
| 153 |
+
},
|
| 154 |
+
"accounts": [
|
| 155 |
+
{
|
| 156 |
+
"account_id": "WT-SAV-505",
|
| 157 |
+
"product_type": "SAV",
|
| 158 |
+
"nickname": "High Interest Savings",
|
| 159 |
+
"account_number": "7505",
|
| 160 |
+
"currency": "CAD",
|
| 161 |
+
"balance": 6400.00,
|
| 162 |
+
"wire_enabled": true,
|
| 163 |
+
"daily_wire_limit": 4000.00
|
| 164 |
+
}
|
| 165 |
+
]
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/beneficiaries.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"saved": [
|
| 3 |
+
{
|
| 4 |
+
"customer_id": "cust_test",
|
| 5 |
+
"beneficiary_id": "B-001",
|
| 6 |
+
"name": "John Smith",
|
| 7 |
+
"country": "US",
|
| 8 |
+
"currency": "USD",
|
| 9 |
+
"bank_fields": {"routing_number": "021000021", "account_number": "123456789", "account_name": "John Smith"},
|
| 10 |
+
"last_used_at": "2025-08-25T10:00:00Z"
|
| 11 |
+
}
|
| 12 |
+
]
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/country_requirements.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"US": ["routing_number", "account_number", "account_name"],
|
| 3 |
+
"CA": ["institution_number", "transit_number", "account_number", "account_name"],
|
| 4 |
+
"GB": ["sort_code", "account_number", "account_name"],
|
| 5 |
+
"EU": ["iban", "account_name"],
|
| 6 |
+
"MX": ["clabe", "account_name"],
|
| 7 |
+
"IN": ["ifsc", "account_number", "account_name"],
|
| 8 |
+
"RU": ["account_number", "account_name"]
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/cutoff_times.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"DOMESTIC": {"cutoff_local": "17:00", "eta_hours": [2, 24]},
|
| 3 |
+
"INTERNATIONAL": {"cutoff_local": "15:00", "eta_hours": [24, 72]}
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/exchange_rates.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"pairs": [
|
| 3 |
+
{"from": "USD", "to": "EUR", "mid_rate": 0.92, "margin_bps": 150, "updated_at": "2025-08-25T12:00:00Z"},
|
| 4 |
+
{"from": "USD", "to": "CAD", "mid_rate": 1.32, "margin_bps": 120, "updated_at": "2025-08-25T12:00:00Z"},
|
| 5 |
+
{"from": "CAD", "to": "USD", "mid_rate": 0.76, "margin_bps": 120, "updated_at": "2025-08-25T12:00:00Z"},
|
| 6 |
+
{"from": "CAD", "to": "GBP", "mid_rate": 0.58, "margin_bps": 160, "updated_at": "2025-08-25T12:00:00Z"},
|
| 7 |
+
{"from": "USD", "to": "RUB", "mid_rate": 90.0, "margin_bps": 200, "updated_at": "2025-08-25T12:00:00Z"},
|
| 8 |
+
{"from": "RUB", "to": "USD", "mid_rate": 0.011111, "margin_bps": 200, "updated_at": "2025-08-25T12:00:00Z"}
|
| 9 |
+
]
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/fee_schedules.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"DOMESTIC": {
|
| 3 |
+
"base_fee": 15.00,
|
| 4 |
+
"expedited_fee": 10.00,
|
| 5 |
+
"networks": ["ACH", "FEDWIRE"],
|
| 6 |
+
"payer_rules": {"OUR": true, "SHA": true, "BEN": true}
|
| 7 |
+
},
|
| 8 |
+
"INTERNATIONAL": {
|
| 9 |
+
"base_fee": 25.00,
|
| 10 |
+
"correspondent_fee": 10.00,
|
| 11 |
+
"lifting_fee": 5.00,
|
| 12 |
+
"swift_network_fee": 5.00,
|
| 13 |
+
"payer_rules": {"OUR": true, "SHA": true, "BEN": true}
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/limits.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"global": {"daily_total_limit": 20000.0},
|
| 3 |
+
"per_account": {
|
| 4 |
+
"WT-CHK-001": {"daily_limit": 10000.0},
|
| 5 |
+
"WT-SAV-001": {"daily_limit": 0.0},
|
| 6 |
+
"WT-CHK-101": {"daily_limit": 7500.0},
|
| 7 |
+
"WT-SAV-101": {"daily_limit": 0.0},
|
| 8 |
+
"WT-CHK-202": {"daily_limit": 5000.0},
|
| 9 |
+
"WT-SAV-303": {"daily_limit": 3000.0},
|
| 10 |
+
"WT-CHK-404": {"daily_limit": 20000.0},
|
| 11 |
+
"WT-SAV-505": {"daily_limit": 4000.0}
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/otps.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"default": "123456",
|
| 3 |
+
"by_customer": {
|
| 4 |
+
"cust_test": "123456",
|
| 5 |
+
"cust_alice": "123456",
|
| 6 |
+
"cust_bob": "123456",
|
| 7 |
+
"cust_carla": "123456",
|
| 8 |
+
"cust_dave": "123456",
|
| 9 |
+
"cust_eve": "123456"
|
| 10 |
+
}
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/packages.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"FX_MARGINS_BPS": 150,
|
| 3 |
+
"WIRE_PLANS": [
|
| 4 |
+
{"id": "WIRE_BASIC", "name": "Wire Basic", "monthly_fee": 0.0, "notes": "Pay per transfer; standard fees apply."},
|
| 5 |
+
{"id": "WIRE_PLUS", "name": "Wire Plus", "monthly_fee": 15.0, "waives": ["DOMESTIC_BASE"], "reduces": {"INTERNATIONAL_BASE": 0.5}, "notes": "Domestic base fee waived; 50% off international base fee."},
|
| 6 |
+
{"id": "WIRE_PREMIER", "name": "Wire Premier", "monthly_fee": 30.0, "waives": ["DOMESTIC_BASE", "INTERNATIONAL_BASE", "SWIFT"], "reduces": {"CORRESPONDENT": 0.5}, "notes": "Waives base fees; cuts correspondent 50%; great for frequent senders."}
|
| 7 |
+
],
|
| 8 |
+
"DEFAULTS": {"domestic_base": 15.0, "international_base": 25.0, "swift": 5.0, "correspondent": 10.0, "lifting": 5.0}
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/sanctions_list.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"blocked": [
|
| 3 |
+
{"name": "ACME Import Export", "country": "RU"},
|
| 4 |
+
{"name": "Global Holdings", "country": "IR"}
|
| 5 |
+
]
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/transactions.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"WT-CHK-001": [
|
| 3 |
+
{"id": "w1", "date": "2025-08-25", "amount": -1200.00, "description": "Wire to John Smith (domestic)", "entry_type": "WIRE", "direction": "OUT"},
|
| 4 |
+
{"id": "w2", "date": "2025-08-25", "amount": -15.00, "description": "Wire fee domestic", "entry_type": "FEE", "fee_code": "WIRE_OUT"},
|
| 5 |
+
{"id": "p1", "date": "2025-08-15", "amount": 3000.00, "description": "Payroll", "entry_type": "CREDIT"}
|
| 6 |
+
],
|
| 7 |
+
"WT-SAV-001": [],
|
| 8 |
+
"WT-CHK-101": [
|
| 9 |
+
{"id": "w3", "date": "2025-08-12", "amount": -2500.00, "description": "Wire to UK vendor (international)", "entry_type": "WIRE", "direction": "OUT"},
|
| 10 |
+
{"id": "w4", "date": "2025-08-12", "amount": -25.00, "description": "Wire fee international", "entry_type": "FEE", "fee_code": "WIRE_OUT"}
|
| 11 |
+
],
|
| 12 |
+
"WT-SAV-101": [],
|
| 13 |
+
"WT-CHK-202": [],
|
| 14 |
+
"WT-SAV-303": [],
|
| 15 |
+
"WT-CHK-404": [
|
| 16 |
+
{"id": "w5", "date": "2025-08-05", "amount": -5000.00, "description": "Wire to CA supplier (international)", "entry_type": "WIRE", "direction": "OUT"},
|
| 17 |
+
{"id": "w6", "date": "2025-08-05", "amount": -35.00, "description": "Wire fee international", "entry_type": "FEE", "fee_code": "WIRE_OUT"}
|
| 18 |
+
],
|
| 19 |
+
"WT-SAV-505": []
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/prompts.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 2 |
+
|
| 3 |
+
# Turn a structured fee event into a friendly, empathetic explanation
|
| 4 |
+
EXPLAIN_FEE_PROMPT = ChatPromptTemplate.from_messages([
|
| 5 |
+
(
|
| 6 |
+
"system",
|
| 7 |
+
"""
|
| 8 |
+
You are a warm, cheerful banking assistant speaking on the phone. Use a friendly, empathetic tone.
|
| 9 |
+
Guidelines:
|
| 10 |
+
- Start with brief empathy (e.g., "I know surprise fees can be frustrating.").
|
| 11 |
+
- Clearly explain what the fee is and why it was applied.
|
| 12 |
+
- Keep it concise (2–3 sentences), plain language, no jargon.
|
| 13 |
+
- Offer help-oriented phrasing ("we can look into options"), no blame.
|
| 14 |
+
""",
|
| 15 |
+
),
|
| 16 |
+
(
|
| 17 |
+
"human",
|
| 18 |
+
"""
|
| 19 |
+
Fee event:
|
| 20 |
+
- code: {fee_code}
|
| 21 |
+
- posted_date: {posted_date}
|
| 22 |
+
- amount: {amount}
|
| 23 |
+
- schedule_name: {schedule_name}
|
| 24 |
+
- schedule_policy: {schedule_policy}
|
| 25 |
+
|
| 26 |
+
Write a concise explanation (2–3 sentences) suitable for a mobile UI.
|
| 27 |
+
""",
|
| 28 |
+
),
|
| 29 |
+
])
|
| 30 |
+
|
| 31 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/react_agent.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict, List
|
| 7 |
+
|
| 8 |
+
from langgraph.func import entrypoint, task
|
| 9 |
+
from langgraph.graph import add_messages
|
| 10 |
+
from langchain_openai import ChatOpenAI
|
| 11 |
+
from langchain_core.messages import (
|
| 12 |
+
SystemMessage,
|
| 13 |
+
HumanMessage,
|
| 14 |
+
AIMessage,
|
| 15 |
+
BaseMessage,
|
| 16 |
+
ToolCall,
|
| 17 |
+
ToolMessage,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---- Tools (wire-transfer) ----
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
from . import tools as wire_tools # type: ignore
|
| 25 |
+
except Exception:
|
| 26 |
+
import importlib.util as _ilu
|
| 27 |
+
_dir = os.path.dirname(__file__)
|
| 28 |
+
_tools_path = os.path.join(_dir, "tools.py")
|
| 29 |
+
_spec = _ilu.spec_from_file_location("wire_transfer_agent_tools", _tools_path)
|
| 30 |
+
wire_tools = _ilu.module_from_spec(_spec) # type: ignore
|
| 31 |
+
assert _spec and _spec.loader
|
| 32 |
+
_spec.loader.exec_module(wire_tools) # type: ignore
|
| 33 |
+
|
| 34 |
+
# Aliases for tool functions
|
| 35 |
+
list_accounts = wire_tools.list_accounts
|
| 36 |
+
get_customer_profile = wire_tools.get_customer_profile
|
| 37 |
+
find_customer = wire_tools.find_customer
|
| 38 |
+
find_account_by_last4 = wire_tools.find_account_by_last4
|
| 39 |
+
verify_identity = wire_tools.verify_identity
|
| 40 |
+
get_account_balance_tool = wire_tools.get_account_balance_tool
|
| 41 |
+
get_exchange_rate_tool = wire_tools.get_exchange_rate_tool
|
| 42 |
+
calculate_wire_fee_tool = wire_tools.calculate_wire_fee_tool
|
| 43 |
+
check_wire_limits_tool = wire_tools.check_wire_limits_tool
|
| 44 |
+
get_cutoff_and_eta_tool = wire_tools.get_cutoff_and_eta_tool
|
| 45 |
+
get_country_requirements_tool = wire_tools.get_country_requirements_tool
|
| 46 |
+
validate_beneficiary_tool = wire_tools.validate_beneficiary_tool
|
| 47 |
+
save_beneficiary_tool = wire_tools.save_beneficiary_tool
|
| 48 |
+
quote_wire_tool = wire_tools.quote_wire_tool
|
| 49 |
+
generate_otp_tool = wire_tools.generate_otp_tool
|
| 50 |
+
verify_otp_tool = wire_tools.verify_otp_tool
|
| 51 |
+
wire_transfer_domestic = wire_tools.wire_transfer_domestic
|
| 52 |
+
wire_transfer_international = wire_tools.wire_transfer_international
|
| 53 |
+
find_customer_by_name = wire_tools.find_customer_by_name
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
"""ReAct agent entrypoint and system prompt."""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
SYSTEM_PROMPT = (
|
| 60 |
+
"You are a warm, cheerful banking assistant helping a customer send a wire transfer (domestic or international). "
|
| 61 |
+
"Start with a brief greeting and very short small talk. Then ask for the caller's full name. "
|
| 62 |
+
"CUSTOMER LOOKUP: After receiving the customer's name, thank them and call find_customer with their first and last name to get their customer_id. If find_customer returns an empty result ({}), politely ask the customer to confirm their full name spelling or offer to look them up by other details. Do NOT proceed to asking for date of birth if you don't have a valid customer_id. "
|
| 63 |
+
"IDENTITY IS MANDATORY: Once you have a customer_id from find_customer, you MUST call verify_identity. Thank the customer for their name, then ask for date of birth (customer can use any format; you normalize) and EITHER SSN last-4 OR the secret answer. If verify_identity returns a secret question, read it verbatim and collect the answer. "
|
| 64 |
+
"NEVER claim the customer is verified unless the verify_identity tool returned verified=true. If not verified, ask ONLY for the next missing field and call verify_identity again. Do NOT proceed to wire details until verified=true. "
|
| 65 |
+
"IMPORTANT: Once verified=true is returned from verify_identity, DO NOT ask for identity verification again. The customer is verified for the entire session. Proceed directly to OTP verification when ready to execute the transfer. "
|
| 66 |
+
"AFTER VERIFIED: Ask ONE question at a time, in this order, waiting for the user's answer each time: (1) wire type (DOMESTIC or INTERNATIONAL); (2) source account (last-4 or picker); (3) amount (with source currency); (4) destination country/state; (5) destination currency preference; (6) who pays fees (OUR/SHA/BEN). Keep each turn to a single, concise prompt. Do NOT re-ask for fields already provided; instead, briefly summarize known details and ask only for the next missing field. "
|
| 67 |
+
"If destination currency differs from source, call get_exchange_rate_tool and state the applied rate and converted amount. "
|
| 68 |
+
"Collect beneficiary details next. Use get_country_requirements_tool and validate_beneficiary_tool; if fields are missing, ask for ONLY the next missing field (one per turn). "
|
| 69 |
+
"Then check balance/limits via get_account_balance_tool and check_wire_limits_tool. Provide a pre-transfer quote using quote_wire_tool showing: FX rate, total fees, who pays what, net sent, net received, and ETA from get_cutoff_and_eta_tool. "
|
| 70 |
+
"Before executing, generate an OTP (generate_otp_tool), collect it, verify via verify_otp_tool, then execute the appropriate transfer: wire_transfer_domestic or wire_transfer_international. Offer to save the beneficiary afterward. "
|
| 71 |
+
"STYLE: Keep messages short (1–2 sentences), empathetic, and strictly ask one question per turn. "
|
| 72 |
+
"TTS SAFETY: Output must be plain text suitable for text-to-speech. Do not use markdown, bullets, asterisks, emojis, or special typography. Use only ASCII punctuation and straight quotes."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
_MODEL_NAME = os.getenv("REACT_MODEL", os.getenv("CLARIFY_MODEL", "gpt-4o"))
|
| 77 |
+
_LLM = ChatOpenAI(model=_MODEL_NAME, temperature=0.3)
|
| 78 |
+
_TOOLS = [
|
| 79 |
+
list_accounts,
|
| 80 |
+
get_customer_profile,
|
| 81 |
+
find_customer,
|
| 82 |
+
find_account_by_last4,
|
| 83 |
+
verify_identity,
|
| 84 |
+
get_account_balance_tool,
|
| 85 |
+
get_exchange_rate_tool,
|
| 86 |
+
calculate_wire_fee_tool,
|
| 87 |
+
check_wire_limits_tool,
|
| 88 |
+
get_cutoff_and_eta_tool,
|
| 89 |
+
get_country_requirements_tool,
|
| 90 |
+
validate_beneficiary_tool,
|
| 91 |
+
save_beneficiary_tool,
|
| 92 |
+
quote_wire_tool,
|
| 93 |
+
generate_otp_tool,
|
| 94 |
+
verify_otp_tool,
|
| 95 |
+
wire_transfer_domestic,
|
| 96 |
+
wire_transfer_international,
|
| 97 |
+
]
|
| 98 |
+
_LLM_WITH_TOOLS = _LLM.bind_tools(_TOOLS)
|
| 99 |
+
_TOOLS_BY_NAME = {t.name: t for t in _TOOLS}
|
| 100 |
+
|
| 101 |
+
# Simple per-run context storage (thread-safe enough for local dev worker)
|
| 102 |
+
_CURRENT_THREAD_ID: str | None = None
|
| 103 |
+
_CURRENT_CUSTOMER_ID: str | None = None
|
| 104 |
+
|
| 105 |
+
# ---- Logger ----
|
| 106 |
+
logger = logging.getLogger("WireTransferAgent")
|
| 107 |
+
if not logger.handlers:
|
| 108 |
+
_stream = logging.StreamHandler()
|
| 109 |
+
_stream.setLevel(logging.INFO)
|
| 110 |
+
_fmt = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 111 |
+
_stream.setFormatter(_fmt)
|
| 112 |
+
logger.addHandler(_stream)
|
| 113 |
+
try:
|
| 114 |
+
_file = logging.FileHandler(str(Path(__file__).resolve().parents[2] / "app.log"))
|
| 115 |
+
_file.setLevel(logging.INFO)
|
| 116 |
+
_file.setFormatter(_fmt)
|
| 117 |
+
logger.addHandler(_file)
|
| 118 |
+
except Exception:
|
| 119 |
+
pass
|
| 120 |
+
logger.setLevel(logging.INFO)
|
| 121 |
+
_DEBUG = os.getenv("RBC_FEES_DEBUG", "0") not in ("", "0", "false", "False")
|
| 122 |
+
|
| 123 |
+
def _get_thread_id(config: Dict[str, Any] | None, messages: List[BaseMessage]) -> str:
|
| 124 |
+
cfg = config or {}
|
| 125 |
+
# Try dict-like and attribute-like access
|
| 126 |
+
def _safe_get(container: Any, key: str, default: Any = None) -> Any:
|
| 127 |
+
try:
|
| 128 |
+
if isinstance(container, dict):
|
| 129 |
+
return container.get(key, default)
|
| 130 |
+
if hasattr(container, "get"):
|
| 131 |
+
return container.get(key, default)
|
| 132 |
+
if hasattr(container, key):
|
| 133 |
+
return getattr(container, key, default)
|
| 134 |
+
except Exception:
|
| 135 |
+
return default
|
| 136 |
+
return default
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
conf = _safe_get(cfg, "configurable", {}) or {}
|
| 140 |
+
for key in ("thread_id", "session_id", "thread"):
|
| 141 |
+
val = _safe_get(conf, key)
|
| 142 |
+
if isinstance(val, str) and val:
|
| 143 |
+
return val
|
| 144 |
+
except Exception:
|
| 145 |
+
pass
|
| 146 |
+
|
| 147 |
+
# Fallback: look for session_id on the latest human message additional_kwargs
|
| 148 |
+
try:
|
| 149 |
+
for m in reversed(messages or []):
|
| 150 |
+
addl = getattr(m, "additional_kwargs", None)
|
| 151 |
+
if isinstance(addl, dict) and isinstance(addl.get("session_id"), str) and addl.get("session_id"):
|
| 152 |
+
return addl.get("session_id")
|
| 153 |
+
if isinstance(m, dict):
|
| 154 |
+
ak = m.get("additional_kwargs") or {}
|
| 155 |
+
if isinstance(ak, dict) and isinstance(ak.get("session_id"), str) and ak.get("session_id"):
|
| 156 |
+
return ak.get("session_id")
|
| 157 |
+
except Exception:
|
| 158 |
+
pass
|
| 159 |
+
return "unknown"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _trim_messages(messages: List[BaseMessage], max_messages: int = 40) -> List[BaseMessage]:
|
| 163 |
+
if len(messages) <= max_messages:
|
| 164 |
+
return messages
|
| 165 |
+
return messages[-max_messages:]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _sanitize_conversation(messages: List[BaseMessage]) -> List[BaseMessage]:
|
| 169 |
+
"""Ensure tool messages only follow an assistant message with tool_calls.
|
| 170 |
+
|
| 171 |
+
Drops orphan tool messages that could cause OpenAI 400 errors.
|
| 172 |
+
"""
|
| 173 |
+
sanitized: List[BaseMessage] = []
|
| 174 |
+
pending_tool_ids: set[str] | None = None
|
| 175 |
+
for m in messages:
|
| 176 |
+
try:
|
| 177 |
+
if isinstance(m, AIMessage):
|
| 178 |
+
sanitized.append(m)
|
| 179 |
+
tool_calls = getattr(m, "tool_calls", None) or []
|
| 180 |
+
ids: set[str] = set()
|
| 181 |
+
for tc in tool_calls:
|
| 182 |
+
# ToolCall can be mapping-like or object-like
|
| 183 |
+
if isinstance(tc, dict):
|
| 184 |
+
_id = tc.get("id") or tc.get("tool_call_id")
|
| 185 |
+
else:
|
| 186 |
+
_id = getattr(tc, "id", None) or getattr(tc, "tool_call_id", None)
|
| 187 |
+
if isinstance(_id, str):
|
| 188 |
+
ids.add(_id)
|
| 189 |
+
pending_tool_ids = ids if ids else None
|
| 190 |
+
continue
|
| 191 |
+
if isinstance(m, ToolMessage):
|
| 192 |
+
if pending_tool_ids and isinstance(getattr(m, "tool_call_id", None), str) and m.tool_call_id in pending_tool_ids:
|
| 193 |
+
sanitized.append(m)
|
| 194 |
+
# keep accepting subsequent tool messages for the same assistant turn
|
| 195 |
+
continue
|
| 196 |
+
# Orphan tool message: drop
|
| 197 |
+
continue
|
| 198 |
+
# Any other message resets expectation
|
| 199 |
+
sanitized.append(m)
|
| 200 |
+
pending_tool_ids = None
|
| 201 |
+
except Exception:
|
| 202 |
+
# On any unexpected shape, include as-is but reset to avoid pairing issues
|
| 203 |
+
sanitized.append(m)
|
| 204 |
+
pending_tool_ids = None
|
| 205 |
+
# Ensure the conversation doesn't start with a ToolMessage
|
| 206 |
+
while sanitized and isinstance(sanitized[0], ToolMessage):
|
| 207 |
+
sanitized.pop(0)
|
| 208 |
+
return sanitized
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _today_string() -> str:
|
| 212 |
+
override = os.getenv("RBC_FEES_TODAY_OVERRIDE")
|
| 213 |
+
if isinstance(override, str) and override.strip():
|
| 214 |
+
try:
|
| 215 |
+
datetime.strptime(override.strip(), "%Y-%m-%d")
|
| 216 |
+
return override.strip()
|
| 217 |
+
except Exception:
|
| 218 |
+
pass
|
| 219 |
+
return datetime.utcnow().strftime("%Y-%m-%d")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _system_messages() -> List[BaseMessage]:
|
| 223 |
+
today = _today_string()
|
| 224 |
+
return [SystemMessage(content=SYSTEM_PROMPT)]
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@task()
|
| 228 |
+
def call_llm(messages: List[BaseMessage]) -> AIMessage:
|
| 229 |
+
"""LLM decides whether to call a tool or not."""
|
| 230 |
+
if _DEBUG:
|
| 231 |
+
try:
|
| 232 |
+
preview = [f"{getattr(m,'type', getattr(m,'role',''))}:{str(getattr(m,'content', m))[:80]}" for m in messages[-6:]]
|
| 233 |
+
logger.info("call_llm: messages_count=%s preview=%s", len(messages), preview)
|
| 234 |
+
except Exception:
|
| 235 |
+
logger.info("call_llm: messages_count=%s", len(messages))
|
| 236 |
+
resp = _LLM_WITH_TOOLS.invoke(_system_messages() + messages)
|
| 237 |
+
try:
|
| 238 |
+
# Log assistant content or tool calls for visibility
|
| 239 |
+
tool_calls = getattr(resp, "tool_calls", None) or []
|
| 240 |
+
if tool_calls:
|
| 241 |
+
names = []
|
| 242 |
+
for tc in tool_calls:
|
| 243 |
+
n = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
|
| 244 |
+
if isinstance(n, str):
|
| 245 |
+
names.append(n)
|
| 246 |
+
logger.info("LLM tool_calls: %s", names)
|
| 247 |
+
else:
|
| 248 |
+
txt = getattr(resp, "content", "") or ""
|
| 249 |
+
if isinstance(txt, str) and txt.strip():
|
| 250 |
+
logger.info("LLM content: %s", (txt if len(txt) <= 500 else (txt[:500] + "…")))
|
| 251 |
+
except Exception:
|
| 252 |
+
pass
|
| 253 |
+
return resp
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
@task()
|
| 257 |
+
def call_tool(tool_call: ToolCall) -> ToolMessage:
|
| 258 |
+
"""Execute a tool call and wrap result in a ToolMessage."""
|
| 259 |
+
tool = _TOOLS_BY_NAME[tool_call["name"]]
|
| 260 |
+
args = tool_call.get("args") or {}
|
| 261 |
+
# Auto-inject session/customer context if missing for identity and other tools
|
| 262 |
+
if tool.name == "verify_identity":
|
| 263 |
+
if "session_id" not in args and _CURRENT_THREAD_ID:
|
| 264 |
+
args["session_id"] = _CURRENT_THREAD_ID
|
| 265 |
+
if "customer_id" not in args and _CURRENT_CUSTOMER_ID:
|
| 266 |
+
args["customer_id"] = _CURRENT_CUSTOMER_ID
|
| 267 |
+
if tool.name == "list_accounts":
|
| 268 |
+
if "customer_id" not in args and _CURRENT_CUSTOMER_ID:
|
| 269 |
+
args["customer_id"] = _CURRENT_CUSTOMER_ID
|
| 270 |
+
# Gate non-identity tools until verified=true
|
| 271 |
+
try:
|
| 272 |
+
if tool.name not in ("verify_identity", "find_customer"):
|
| 273 |
+
# Look back through recent messages for the last verify_identity result
|
| 274 |
+
# The runtime passes messages separately; we cannot access here, so rely on LLM prompt discipline.
|
| 275 |
+
# As an extra guard, if the tool is attempting a wire action before identity, return a friendly error.
|
| 276 |
+
pass
|
| 277 |
+
except Exception:
|
| 278 |
+
pass
|
| 279 |
+
if _DEBUG:
|
| 280 |
+
try:
|
| 281 |
+
logger.info("call_tool: name=%s args_keys=%s", tool.name, list(args.keys()))
|
| 282 |
+
except Exception:
|
| 283 |
+
logger.info("call_tool: name=%s", tool.name)
|
| 284 |
+
result = tool.invoke(args)
|
| 285 |
+
# Ensure string content
|
| 286 |
+
content = result if isinstance(result, str) else json.dumps(result)
|
| 287 |
+
try:
|
| 288 |
+
# Log tool result previews and OTP debug_code when present
|
| 289 |
+
if tool.name == "verify_identity":
|
| 290 |
+
try:
|
| 291 |
+
data = json.loads(content)
|
| 292 |
+
logger.info("verify_identity: verified=%s needs=%s", data.get("verified"), data.get("needs"))
|
| 293 |
+
except Exception:
|
| 294 |
+
logger.info("verify_identity result: %s", content[:300])
|
| 295 |
+
elif tool.name == "generate_otp_tool":
|
| 296 |
+
try:
|
| 297 |
+
data = json.loads(content)
|
| 298 |
+
if isinstance(data, dict) and data.get("debug_code"):
|
| 299 |
+
logger.info("OTP debug_code: %s", data.get("debug_code"))
|
| 300 |
+
else:
|
| 301 |
+
logger.info("generate_otp_tool: %s", content[:300])
|
| 302 |
+
except Exception:
|
| 303 |
+
logger.info("generate_otp_tool: %s", content[:300])
|
| 304 |
+
else:
|
| 305 |
+
# Generic preview
|
| 306 |
+
logger.info("tool %s result: %s", tool.name, (content[:300] if isinstance(content, str) else str(content)[:300]))
|
| 307 |
+
except Exception:
|
| 308 |
+
pass
|
| 309 |
+
# Never expose OTP debug_code to the LLM
|
| 310 |
+
try:
|
| 311 |
+
if tool.name == "generate_otp_tool":
|
| 312 |
+
data = json.loads(content)
|
| 313 |
+
if isinstance(data, dict) and "debug_code" in data:
|
| 314 |
+
data.pop("debug_code", None)
|
| 315 |
+
content = json.dumps(data)
|
| 316 |
+
except Exception:
|
| 317 |
+
pass
|
| 318 |
+
return ToolMessage(content=content, tool_call_id=tool_call["id"], name=tool.name)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
@entrypoint()
|
| 322 |
+
def agent(messages: List[BaseMessage], previous: List[BaseMessage] | None, config: Dict[str, Any] | None = None):
|
| 323 |
+
# Start from full conversation history (previous + new)
|
| 324 |
+
prev_list = list(previous or [])
|
| 325 |
+
new_list = list(messages or [])
|
| 326 |
+
convo: List[BaseMessage] = prev_list + new_list
|
| 327 |
+
# Trim to avoid context bloat
|
| 328 |
+
convo = _trim_messages(convo, max_messages=int(os.getenv("RBC_FEES_MAX_MSGS", "40")))
|
| 329 |
+
# Sanitize to avoid orphan tool messages after trimming
|
| 330 |
+
convo = _sanitize_conversation(convo)
|
| 331 |
+
thread_id = _get_thread_id(config, new_list)
|
| 332 |
+
logger.info("agent start: thread_id=%s total_in=%s (prev=%s, new=%s)", thread_id, len(convo), len(prev_list), len(new_list))
|
| 333 |
+
# Establish default customer from config (or fallback to cust_test)
|
| 334 |
+
conf = (config or {}).get("configurable", {}) if isinstance(config, dict) else {}
|
| 335 |
+
default_customer = conf.get("customer_id") or conf.get("user_email") or "cust_test"
|
| 336 |
+
|
| 337 |
+
# Heuristic: infer customer_id from latest human name if provided (e.g., "I am Alice Stone")
|
| 338 |
+
inferred_customer: str | None = None
|
| 339 |
+
try:
|
| 340 |
+
recent_humans = [m for m in reversed(new_list) if (getattr(m, "type", None) == "human" or getattr(m, "role", None) == "user" or (isinstance(m, dict) and m.get("type") == "human"))]
|
| 341 |
+
text = None
|
| 342 |
+
for m in recent_humans[:3]:
|
| 343 |
+
text = (getattr(m, "content", None) if not isinstance(m, dict) else m.get("content")) or ""
|
| 344 |
+
if isinstance(text, str) and text.strip():
|
| 345 |
+
break
|
| 346 |
+
if isinstance(text, str):
|
| 347 |
+
tokens = [t for t in text.replace(',', ' ').split() if t.isalpha()]
|
| 348 |
+
if len(tokens) >= 2 and find_customer_by_name is not None:
|
| 349 |
+
# Try adjacent pairs as first/last
|
| 350 |
+
for i in range(len(tokens) - 1):
|
| 351 |
+
fn = tokens[i]
|
| 352 |
+
ln = tokens[i + 1]
|
| 353 |
+
found = find_customer_by_name(fn, ln) # type: ignore
|
| 354 |
+
if isinstance(found, dict) and found.get("customer_id"):
|
| 355 |
+
inferred_customer = found.get("customer_id")
|
| 356 |
+
break
|
| 357 |
+
except Exception:
|
| 358 |
+
pass
|
| 359 |
+
|
| 360 |
+
# Update module context
|
| 361 |
+
global _CURRENT_THREAD_ID, _CURRENT_CUSTOMER_ID
|
| 362 |
+
_CURRENT_THREAD_ID = thread_id
|
| 363 |
+
_CURRENT_CUSTOMER_ID = inferred_customer or default_customer
|
| 364 |
+
|
| 365 |
+
llm_response = call_llm(convo).result()
|
| 366 |
+
|
| 367 |
+
while True:
|
| 368 |
+
tool_calls = getattr(llm_response, "tool_calls", None) or []
|
| 369 |
+
if not tool_calls:
|
| 370 |
+
break
|
| 371 |
+
|
| 372 |
+
# Execute tools (in parallel) and append results
|
| 373 |
+
futures = [call_tool(tc) for tc in tool_calls]
|
| 374 |
+
tool_results = [f.result() for f in futures]
|
| 375 |
+
if _DEBUG:
|
| 376 |
+
try:
|
| 377 |
+
logger.info("tool_results: count=%s names=%s", len(tool_results), [tr.name for tr in tool_results])
|
| 378 |
+
except Exception:
|
| 379 |
+
pass
|
| 380 |
+
convo = add_messages(convo, [llm_response, *tool_results])
|
| 381 |
+
llm_response = call_llm(convo).result()
|
| 382 |
+
|
| 383 |
+
# Append final assistant turn
|
| 384 |
+
convo = add_messages(convo, [llm_response])
|
| 385 |
+
final_text = getattr(llm_response, "content", "") or ""
|
| 386 |
+
try:
|
| 387 |
+
if isinstance(final_text, str) and final_text.strip():
|
| 388 |
+
logger.info("final content: %s", (final_text if len(final_text) <= 500 else (final_text[:500] + "…")))
|
| 389 |
+
except Exception:
|
| 390 |
+
pass
|
| 391 |
+
ai = AIMessage(content=final_text if isinstance(final_text, str) else str(final_text))
|
| 392 |
+
logger.info("agent done: thread_id=%s total_messages=%s final_len=%s", thread_id, len(convo), len(ai.content))
|
| 393 |
+
# Save only the merged conversation (avoid duplicating previous)
|
| 394 |
+
return entrypoint.final(value=ai, save=convo)
|
| 395 |
+
|
| 396 |
+
|
examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/tools.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any, Dict
|
| 5 |
+
|
| 6 |
+
from langchain_core.tools import tool
|
| 7 |
+
|
| 8 |
+
# Robust logic import to avoid crossing into other agent modules during hot reloads
|
| 9 |
+
try:
|
| 10 |
+
from . import logic as wt_logic # type: ignore
|
| 11 |
+
except Exception:
|
| 12 |
+
import importlib.util as _ilu
|
| 13 |
+
_dir = os.path.dirname(__file__)
|
| 14 |
+
_logic_path = os.path.join(_dir, "logic.py")
|
| 15 |
+
_spec = _ilu.spec_from_file_location("wire_transfer_agent_logic", _logic_path)
|
| 16 |
+
wt_logic = _ilu.module_from_spec(_spec) # type: ignore
|
| 17 |
+
assert _spec and _spec.loader
|
| 18 |
+
_spec.loader.exec_module(wt_logic) # type: ignore
|
| 19 |
+
|
| 20 |
+
get_accounts = wt_logic.get_accounts
|
| 21 |
+
get_profile = wt_logic.get_profile
|
| 22 |
+
find_customer_by_name = wt_logic.find_customer_by_name
|
| 23 |
+
find_customer_by_full_name = getattr(wt_logic, "find_customer_by_full_name", wt_logic.find_customer_by_name)
|
| 24 |
+
get_account_balance = wt_logic.get_account_balance
|
| 25 |
+
get_exchange_rate = wt_logic.get_exchange_rate
|
| 26 |
+
calculate_wire_fee = wt_logic.calculate_wire_fee
|
| 27 |
+
check_wire_limits = wt_logic.check_wire_limits
|
| 28 |
+
get_cutoff_and_eta = wt_logic.get_cutoff_and_eta
|
| 29 |
+
get_country_requirements = wt_logic.get_country_requirements
|
| 30 |
+
validate_beneficiary = wt_logic.validate_beneficiary
|
| 31 |
+
save_beneficiary = wt_logic.save_beneficiary
|
| 32 |
+
generate_otp = wt_logic.generate_otp
|
| 33 |
+
verify_otp = wt_logic.verify_otp
|
| 34 |
+
authenticate_user_wire = wt_logic.authenticate_user_wire
|
| 35 |
+
quote_wire = wt_logic.quote_wire
|
| 36 |
+
wire_transfer_domestic_logic = wt_logic.wire_transfer_domestic
|
| 37 |
+
wire_transfer_international_logic = wt_logic.wire_transfer_international
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@tool
|
| 41 |
+
def list_accounts(customer_id: str) -> str:
|
| 42 |
+
"""List customer's accounts with masked numbers, balances, currency, and wire eligibility. Returns JSON string."""
|
| 43 |
+
return json.dumps(get_accounts(customer_id))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@tool
|
| 47 |
+
def get_customer_profile(customer_id: str) -> str:
|
| 48 |
+
"""Fetch basic customer profile (full_name, dob, ssn_last4, secret question). Returns JSON string."""
|
| 49 |
+
return json.dumps(get_profile(customer_id))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@tool
|
| 53 |
+
def find_customer(first_name: str | None = None, last_name: str | None = None, full_name: str | None = None) -> str:
|
| 54 |
+
"""Find a customer_id by name. Prefer full_name; otherwise use first and last name. Returns JSON with customer_id or {}."""
|
| 55 |
+
if isinstance(full_name, str) and full_name.strip():
|
| 56 |
+
return json.dumps(find_customer_by_full_name(full_name))
|
| 57 |
+
return json.dumps(find_customer_by_name(first_name or "", last_name or ""))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@tool
|
| 61 |
+
def find_account_by_last4(customer_id: str, last4: str) -> str:
|
| 62 |
+
"""Find a customer's account by last 4 digits. Returns JSON with account or {} if not found."""
|
| 63 |
+
accts = get_accounts(customer_id)
|
| 64 |
+
for a in accts:
|
| 65 |
+
num = str(a.get("account_number") or "")
|
| 66 |
+
if num.endswith(str(last4)):
|
| 67 |
+
return json.dumps(a)
|
| 68 |
+
return json.dumps({})
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@tool
|
| 72 |
+
def verify_identity(session_id: str, customer_id: str | None = None, full_name: str | None = None, dob_yyyy_mm_dd: str | None = None, ssn_last4: str | None = None, secret_answer: str | None = None) -> str:
|
| 73 |
+
"""Verify user identity before wires. Provide any of: full_name, dob (YYYY-MM-DD), ssn_last4, secret_answer. Returns JSON with verified flag, needed fields, and optional secret question."""
|
| 74 |
+
res = authenticate_user_wire(session_id, customer_id, full_name, dob_yyyy_mm_dd, ssn_last4, secret_answer)
|
| 75 |
+
return json.dumps(res)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@tool
|
| 79 |
+
def get_account_balance_tool(account_id: str) -> str:
|
| 80 |
+
"""Get balance, currency, and wire limits for an account. Returns JSON."""
|
| 81 |
+
return json.dumps(get_account_balance(account_id))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@tool
|
| 85 |
+
def get_exchange_rate_tool(from_currency: str, to_currency: str, amount: float) -> str:
|
| 86 |
+
"""Get exchange rate and converted amount for a given amount. Returns JSON."""
|
| 87 |
+
return json.dumps(get_exchange_rate(from_currency, to_currency, amount))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@tool
|
| 91 |
+
def calculate_wire_fee_tool(kind: str, amount: float, from_currency: str, to_currency: str, payer: str) -> str:
|
| 92 |
+
"""Calculate wire fee breakdown and who pays (OUR/SHA/BEN). Returns JSON."""
|
| 93 |
+
return json.dumps(calculate_wire_fee(kind, amount, from_currency, to_currency, payer))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@tool
|
| 97 |
+
def check_wire_limits_tool(account_id: str, amount: float) -> str:
|
| 98 |
+
"""Check sufficient funds and daily wire limit on an account. Returns JSON."""
|
| 99 |
+
return json.dumps(check_wire_limits(account_id, amount))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@tool
|
| 103 |
+
def get_cutoff_and_eta_tool(kind: str, country: str) -> str:
|
| 104 |
+
"""Get cutoff time and estimated arrival window by type and country. Returns JSON."""
|
| 105 |
+
return json.dumps(get_cutoff_and_eta(kind, country))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@tool
|
| 109 |
+
def get_country_requirements_tool(country_code: str) -> str:
|
| 110 |
+
"""Get required beneficiary fields for a country. Returns JSON array."""
|
| 111 |
+
return json.dumps(get_country_requirements(country_code))
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@tool
|
| 115 |
+
def validate_beneficiary_tool(country_code: str, beneficiary_json: str) -> str:
|
| 116 |
+
"""Validate beneficiary fields for a given country. Input is JSON dict string; returns {ok, missing}."""
|
| 117 |
+
try:
|
| 118 |
+
beneficiary = json.loads(beneficiary_json)
|
| 119 |
+
except Exception:
|
| 120 |
+
beneficiary = {}
|
| 121 |
+
return json.dumps(validate_beneficiary(country_code, beneficiary))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@tool
|
| 125 |
+
def save_beneficiary_tool(customer_id: str, beneficiary_json: str) -> str:
|
| 126 |
+
"""Save a beneficiary for future use. Input is JSON dict string; returns {beneficiary_id}."""
|
| 127 |
+
try:
|
| 128 |
+
beneficiary = json.loads(beneficiary_json)
|
| 129 |
+
except Exception:
|
| 130 |
+
beneficiary = {}
|
| 131 |
+
return json.dumps(save_beneficiary(customer_id, beneficiary))
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@tool
|
| 135 |
+
def quote_wire_tool(kind: str, from_account_id: str, beneficiary_json: str, amount: float, from_currency: str, to_currency: str, payer: str) -> str:
|
| 136 |
+
"""Create a wire quote including FX, fees, limits, sanctions, eta; returns JSON with quote_id and totals."""
|
| 137 |
+
try:
|
| 138 |
+
beneficiary = json.loads(beneficiary_json)
|
| 139 |
+
except Exception:
|
| 140 |
+
beneficiary = {}
|
| 141 |
+
return json.dumps(quote_wire(kind, from_account_id, beneficiary, amount, from_currency, to_currency, payer))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@tool
|
| 145 |
+
def generate_otp_tool(customer_id: str) -> str:
|
| 146 |
+
"""Generate a one-time passcode for wire authorization. Returns masked destination info."""
|
| 147 |
+
return json.dumps(generate_otp(customer_id))
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@tool
|
| 151 |
+
def verify_otp_tool(customer_id: str, otp: str) -> str:
|
| 152 |
+
"""Verify the one-time passcode for wire authorization. Returns {verified}."""
|
| 153 |
+
return json.dumps(verify_otp(customer_id, otp))
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@tool
|
| 157 |
+
def wire_transfer_domestic(quote_id: str, otp: str) -> str:
|
| 158 |
+
"""Execute a domestic wire with a valid quote_id and OTP. Returns confirmation."""
|
| 159 |
+
return json.dumps(wire_transfer_domestic_logic(quote_id, otp))
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@tool
|
| 163 |
+
def wire_transfer_international(quote_id: str, otp: str) -> str:
|
| 164 |
+
"""Execute an international wire with a valid quote_id and OTP. Returns confirmation."""
|
| 165 |
+
return json.dumps(wire_transfer_international_logic(quote_id, otp))
|
| 166 |
+
|
| 167 |
+
|
examples/voice_agent_multi_thread/docker-compose.yml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: voice-agents-webrtc-langgraph
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
python-app:
|
| 5 |
+
build:
|
| 6 |
+
context: ../../
|
| 7 |
+
dockerfile: examples/voice_agent_webrtc_langgraph/Dockerfile
|
| 8 |
+
ports:
|
| 9 |
+
- "9000:9000" # UI static server
|
| 10 |
+
- "7860:7860" # Pipeline API/WebSocket
|
| 11 |
+
- "2024:2024" # LangGraph dev (optional external access)
|
| 12 |
+
volumes:
|
| 13 |
+
- ./audio_dumps:/app/examples/voice_agent_webrtc_langgraph/audio_dumps
|
| 14 |
+
# - /home/fciannella/src/ace-controller/examples/voice_agent_webrtc_langgraph:/app/examples/voice_agent_webrtc_langgraph/audio_prompt.wav
|
| 15 |
+
# - /home/fciannella/src/ace-controller-langgraph-agents:/langgraph-agents
|
| 16 |
+
env_file:
|
| 17 |
+
- .env
|
| 18 |
+
environment:
|
| 19 |
+
- NVIDIA_API_KEY=${NVIDIA_API_KEY}
|
| 20 |
+
- USE_LANGGRAPH=${USE_LANGGRAPH}
|
| 21 |
+
- LANGGRAPH_BASE_URL=${LANGGRAPH_BASE_URL}
|
| 22 |
+
- LANGGRAPH_ASSISTANT=${LANGGRAPH_ASSISTANT}
|
| 23 |
+
- USER_EMAIL=${USER_EMAIL}
|
| 24 |
+
- LANGGRAPH_STREAM_MODE=${LANGGRAPH_STREAM_MODE}
|
| 25 |
+
- LANGGRAPH_DEBUG_STREAM=${LANGGRAPH_DEBUG_STREAM}
|
| 26 |
+
- LANGGRAPH_PORT=${LANGGRAPH_PORT}
|
| 27 |
+
|
| 28 |
+
- RIVA_ASR_LANGUAGE=en-US
|
| 29 |
+
- RIVA_TTS_LANGUAGE=en-US
|
| 30 |
+
- RIVA_TTS_VOICE_ID=Magpie-ZeroShot.Female-1
|
| 31 |
+
- ZERO_SHOT_AUDIO_PROMPT=/app/examples/voice_agent_webrtc_langgraph/audio_prompt.wav # set this only if using a zero-shot TTS model with a custom audio prompt
|
| 32 |
+
- ENABLE_SPECULATIVE_SPEECH=true # set to false to disable speculative speech processing
|
| 33 |
+
|
| 34 |
+
restart: unless-stopped
|
| 35 |
+
healthcheck:
|
| 36 |
+
test: ["CMD-SHELL", "curl -f http://localhost:7860/get_prompt || exit 1"]
|
| 37 |
+
interval: 30s
|
| 38 |
+
timeout: 10s
|
| 39 |
+
retries: 3
|
| 40 |
+
start_period: 60s
|
| 41 |
+
logging:
|
| 42 |
+
driver: "json-file"
|
| 43 |
+
options:
|
| 44 |
+
max-size: "50m"
|
| 45 |
+
max-file: "5"
|
| 46 |
+
|
| 47 |
+
volumes:
|
| 48 |
+
nim_cache:
|
| 49 |
+
riva_data:
|
examples/voice_agent_multi_thread/env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
NVIDIA_API_KEY=nvapi-
|
| 2 |
+
ZEROSHOT_TTS_NVIDIA_API_KEY=nvapi-
|
| 3 |
+
RIVA_API_KEY=nvapi-
|
| 4 |
+
USE_LANGGRAPH=true
|
| 5 |
+
LANGGRAPH_BASE_URL=http://127.0.0.1:2024
|
| 6 |
+
LANGGRAPH_ASSISTANT=ace-base-agent
|
| 7 |
+
USER_EMAIL=test@example.com
|
| 8 |
+
LANGGRAPH_STREAM_MODE=values
|
| 9 |
+
LANGGRAPH_DEBUG_STREAM=true
|
examples/voice_agent_multi_thread/index.html
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>WebRTC Voice Agent</title>
|
| 7 |
+
<style>
|
| 8 |
+
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
|
| 9 |
+
#status { font-size: 20px; margin: 20px; }
|
| 10 |
+
button { padding: 10px 20px; font-size: 16px; }
|
| 11 |
+
</style>
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<h1>WebRTC Voice Agent</h1>
|
| 15 |
+
<p id="status">Disconnected</p>
|
| 16 |
+
<button id="connect-btn">Connect</button>
|
| 17 |
+
<audio id="audio-el" autoplay></audio>
|
| 18 |
+
|
| 19 |
+
<script>
|
| 20 |
+
const statusEl = document.getElementById("status")
|
| 21 |
+
const buttonEl = document.getElementById("connect-btn")
|
| 22 |
+
const audioEl = document.getElementById("audio-el")
|
| 23 |
+
|
| 24 |
+
let connected = false
|
| 25 |
+
let peerConnection = null
|
| 26 |
+
|
| 27 |
+
const waitForIceGatheringComplete = async (pc, timeoutMs = 2000) => {
|
| 28 |
+
if (pc.iceGatheringState === 'complete') return;
|
| 29 |
+
console.log("Waiting for ICE gathering to complete. Current state:", pc.iceGatheringState);
|
| 30 |
+
return new Promise((resolve) => {
|
| 31 |
+
let timeoutId;
|
| 32 |
+
const checkState = () => {
|
| 33 |
+
console.log("icegatheringstatechange:", pc.iceGatheringState);
|
| 34 |
+
if (pc.iceGatheringState === 'complete') {
|
| 35 |
+
cleanup();
|
| 36 |
+
resolve();
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
const onTimeout = () => {
|
| 40 |
+
console.warn(`ICE gathering timed out after ${timeoutMs} ms.`);
|
| 41 |
+
cleanup();
|
| 42 |
+
resolve();
|
| 43 |
+
};
|
| 44 |
+
const cleanup = () => {
|
| 45 |
+
pc.removeEventListener('icegatheringstatechange', checkState);
|
| 46 |
+
clearTimeout(timeoutId);
|
| 47 |
+
};
|
| 48 |
+
pc.addEventListener('icegatheringstatechange', checkState);
|
| 49 |
+
timeoutId = setTimeout(onTimeout, timeoutMs);
|
| 50 |
+
// Checking the state again to avoid any eventual race condition
|
| 51 |
+
checkState();
|
| 52 |
+
});
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
const createSmallWebRTCConnection = async (audioTrack) => {
|
| 57 |
+
const config = {
|
| 58 |
+
iceServers: [],
|
| 59 |
+
};
|
| 60 |
+
const pc = new RTCPeerConnection(config)
|
| 61 |
+
addPeerConnectionEventListeners(pc)
|
| 62 |
+
pc.ontrack = e => audioEl.srcObject = e.streams[0]
|
| 63 |
+
// SmallWebRTCTransport expects to receive both transceivers
|
| 64 |
+
pc.addTransceiver(audioTrack, { direction: 'sendrecv' })
|
| 65 |
+
pc.addTransceiver('video', { direction: 'sendrecv' })
|
| 66 |
+
await pc.setLocalDescription(await pc.createOffer())
|
| 67 |
+
await waitForIceGatheringComplete(pc)
|
| 68 |
+
const offer = pc.localDescription
|
| 69 |
+
try {
|
| 70 |
+
const response = await fetch('/api/offer', {
|
| 71 |
+
body: JSON.stringify({ sdp: offer.sdp, type: offer.type}),
|
| 72 |
+
headers: { 'Content-Type': 'application/json' },
|
| 73 |
+
method: 'POST',
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
if (!response.ok) {
|
| 77 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const answer = await response.json()
|
| 81 |
+
await pc.setRemoteDescription(answer)
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error('Error during WebRTC connection setup:', error);
|
| 84 |
+
_onDisconnected();
|
| 85 |
+
throw error;
|
| 86 |
+
}
|
| 87 |
+
return pc
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const connect = async () => {
|
| 91 |
+
_onConnecting()
|
| 92 |
+
const audioStream = await navigator.mediaDevices.getUserMedia({audio: true})
|
| 93 |
+
peerConnection= await createSmallWebRTCConnection(audioStream.getAudioTracks()[0])
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const addPeerConnectionEventListeners = (pc) => {
|
| 97 |
+
pc.oniceconnectionstatechange = () => {
|
| 98 |
+
console.log("oniceconnectionstatechange", pc?.iceConnectionState)
|
| 99 |
+
}
|
| 100 |
+
pc.onconnectionstatechange = () => {
|
| 101 |
+
console.log("onconnectionstatechange", pc?.connectionState)
|
| 102 |
+
let connectionState = pc?.connectionState
|
| 103 |
+
if (connectionState === 'connected') {
|
| 104 |
+
_onConnected()
|
| 105 |
+
} else if (connectionState === 'disconnected') {
|
| 106 |
+
_onDisconnected()
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
pc.onicecandidate = (event) => {
|
| 110 |
+
if (event.candidate) {
|
| 111 |
+
console.log("New ICE candidate:", event.candidate);
|
| 112 |
+
} else {
|
| 113 |
+
console.log("All ICE candidates have been sent.");
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const _onConnecting = () => {
|
| 119 |
+
statusEl.textContent = "Connecting"
|
| 120 |
+
buttonEl.textContent = "Disconnect"
|
| 121 |
+
connected = true
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const _onConnected = () => {
|
| 125 |
+
statusEl.textContent = "Connected"
|
| 126 |
+
buttonEl.textContent = "Disconnect"
|
| 127 |
+
connected = true
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
const _onDisconnected = () => {
|
| 131 |
+
statusEl.textContent = "Disconnected"
|
| 132 |
+
buttonEl.textContent = "Connect"
|
| 133 |
+
connected = false
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const disconnect = () => {
|
| 137 |
+
if (!peerConnection) {
|
| 138 |
+
return
|
| 139 |
+
}
|
| 140 |
+
peerConnection.close()
|
| 141 |
+
peerConnection = null
|
| 142 |
+
_onDisconnected()
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
buttonEl.addEventListener("click", async () => {
|
| 146 |
+
if (!connected) {
|
| 147 |
+
await connect()
|
| 148 |
+
} else {
|
| 149 |
+
disconnect()
|
| 150 |
+
}
|
| 151 |
+
});
|
| 152 |
+
</script>
|
| 153 |
+
</body>
|
| 154 |
+
</html>
|
examples/voice_agent_multi_thread/ipa.json
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"NVIDIA": "ˈɛnˌvɪdiə",
|
| 3 |
+
"Riva": "ˈriːvə",
|
| 4 |
+
"Parakeet": "ˈpærəˌkiːt",
|
| 5 |
+
"Canary": "kəˈnɛri",
|
| 6 |
+
"Magpie": "ˈmæɡˌpaɪ",
|
| 7 |
+
"Llama": "ˈlɑːmə",
|
| 8 |
+
"Nemotron": "ˈniːmoʊˌtrɒn",
|
| 9 |
+
"TTS": "ˌtiːˌtiːˈɛs",
|
| 10 |
+
"ASR": "ˌeɪˌɛsˈɑːr",
|
| 11 |
+
"GPU": "ˌdʒiːˌpiːˈjuː",
|
| 12 |
+
"RTX": "ɑːrˌtiːˈɛks",
|
| 13 |
+
"A100": "eɪ wʌn ˈhʌndrəd",
|
| 14 |
+
"H100": "eɪtʃ wʌn ˈhʌndrəd",
|
| 15 |
+
"Blackwell": "ˈblækwɛl",
|
| 16 |
+
"Grace Hopper": "ˈɡreɪs ˈhɒpər",
|
| 17 |
+
"DLI": "ˌdiːˌɛlˈaɪ",
|
| 18 |
+
"DGX": "ˌdiːˌdʒiːˈɛks",
|
| 19 |
+
"Omniverse": "ˈɑːmnɪˌvɜːrs",
|
| 20 |
+
"Jetson": "ˈdʒɛtsən",
|
| 21 |
+
"TensorRT": "ˈtɛnsər ɑːrˈtiː",
|
| 22 |
+
"CUDA": "ˈkuːdə",
|
| 23 |
+
"RAG": "ræɡ",
|
| 24 |
+
"NIM": "nɪm",
|
| 25 |
+
"ACE": "eɪs",
|
| 26 |
+
"NeMo": "ˈniːmoʊ",
|
| 27 |
+
"Nemo Guardrails": "ˈniːmoʊ ˈɡɑːrdˌreɪlz",
|
| 28 |
+
"Helm": "hɛlm",
|
| 29 |
+
"Kubernetes": "ˌkuːbərˈnetiːz",
|
| 30 |
+
"Docker": "ˈdɒkər",
|
| 31 |
+
"API": "ˌeɪˌpiːˈaɪ",
|
| 32 |
+
"SDK": "ˌɛsˌdiːˈkeɪ",
|
| 33 |
+
"NGC": "ˌɛnˌdʒiːˈsiː",
|
| 34 |
+
"CUDA Core": "ˈkuːdə kɔːr",
|
| 35 |
+
"Inference": "ˈɪnfərəns",
|
| 36 |
+
"Riva NIM": "ˈriːvə nɪm",
|
| 37 |
+
"RIVA API": "ˈriːvə ˌeɪˌpiːˈaɪ",
|
| 38 |
+
"Riva Canary": "ˈriːvə kəˈnɛri",
|
| 39 |
+
"FastPitch": "ˈfæstˌpɪtʃ",
|
| 40 |
+
"CTC": "ˌsiːˌtiːˈsiː",
|
| 41 |
+
"RNNT": "ˌɑːrˌɛnˌɛnˈtiː",
|
| 42 |
+
"TDT": "ˌtiːˌdiːˈtiː",
|
| 43 |
+
"Adi": "ˈɑːdi",
|
| 44 |
+
"Julie": "ˈdʒuːli",
|
| 45 |
+
"Ryan": "ˈraɪən",
|
| 46 |
+
"Ankit": "ˈæŋkɪt",
|
| 47 |
+
"Priya": "ˈpriːjɑː",
|
| 48 |
+
"Sanjay": "ˈsɑːndʒeɪ",
|
| 49 |
+
"Wei": "weɪ",
|
| 50 |
+
"Jing": "dʒɪŋ",
|
| 51 |
+
"Elena": "ɛˈleɪnə",
|
| 52 |
+
"Ivan": "ˈiːvɑːn",
|
| 53 |
+
"Monica": "ˈmɒnɪkə",
|
| 54 |
+
"bouquet": "buˈkeɪ",
|
| 55 |
+
"delivery": "dɪˈlɪvəri",
|
| 56 |
+
"vase": "veɪs",
|
| 57 |
+
"greenery": "ˈgriːnəri",
|
| 58 |
+
"foliage": "ˈfoʊliɪdʒ",
|
| 59 |
+
"eucalyptus": "ˌjuːkəˈlɪptəs",
|
| 60 |
+
"orchid": "ˈɔːrkɪd",
|
| 61 |
+
"succulent": "ˈsʌkjələnt",
|
| 62 |
+
"fern": "fɜːrn",
|
| 63 |
+
"hydrangea": "haɪˈdreɪndʒə",
|
| 64 |
+
"rose": "roʊz",
|
| 65 |
+
"sunflower": "ˈsʌnˌflaʊər",
|
| 66 |
+
"lily": "ˈlɪli",
|
| 67 |
+
"tulip": "ˈtuːlɪp",
|
| 68 |
+
"peony": "ˈpiːəni",
|
| 69 |
+
"carnation": "kɑːrˈneɪʃən",
|
| 70 |
+
"daisy": "ˈdeɪzi",
|
| 71 |
+
"chrysanthemum": "krɪˈzænθəməm",
|
| 72 |
+
"snapdragon": "ˈsnæpˌdræɡən",
|
| 73 |
+
"alstroemeria": "ælstroʊˈmiːriə",
|
| 74 |
+
"freesia": "ˈfriːʒə",
|
| 75 |
+
"marigold": "ˈmærɪˌɡoʊld",
|
| 76 |
+
"anthurium": "ænˈθjʊriəm",
|
| 77 |
+
"camellia": "kəˈmiːliə",
|
| 78 |
+
"greens": "griːnz",
|
| 79 |
+
"hypericum": "haɪˈpɛrɪkəm",
|
| 80 |
+
"NVIDIA headquarters": "ɛnˌvɪdiə ˈhɛd.kwɔːrtərz",
|
| 81 |
+
"Santa Clara": "ˌsæntə ˈklærə",
|
| 82 |
+
"California": "ˌkælɪˈfɔːrnɪə",
|
| 83 |
+
"Silicon Valley": "ˈsɪlɪkən ˈvæli",
|
| 84 |
+
"Building E": "ˈbɪldɪŋ iː",
|
| 85 |
+
"Endeavor": "ˈɛndɪˌvər",
|
| 86 |
+
"NVIDIA green": "ɛnˌvɪdiə ɡriːn",
|
| 87 |
+
"lime green": "laɪm ɡriːn",
|
| 88 |
+
"emerald": "ˈɛmərəld",
|
| 89 |
+
"moss green": "mɒs ɡriːn",
|
| 90 |
+
"forest green": "ˈfɒrɪst ɡriːn",
|
| 91 |
+
"chartreuse": "ʃɑːrˈtruːs",
|
| 92 |
+
"gift card": "ɡɪft kɑːrd",
|
| 93 |
+
"credit card": "ˈkrɛdɪt kɑːrd",
|
| 94 |
+
"Visa": "ˈviːzə",
|
| 95 |
+
"Mastercard": "ˈmæstərˌkɑːrd",
|
| 96 |
+
"Amex": "ˈæmɛks",
|
| 97 |
+
"PayPal": "ˈpeɪˌpæl",
|
| 98 |
+
"Apple Pay": "ˈæpəl peɪ",
|
| 99 |
+
"confirmation email": "͵kɒnfəˈmeɪʃən ˈiːˌmeɪl",
|
| 100 |
+
"AI": "ˌeɪˈaɪ",
|
| 101 |
+
"cloud": "klaʊd",
|
| 102 |
+
"audio": "ˈɔːdiˌoʊ",
|
| 103 |
+
"speech": "spiːtʃ",
|
| 104 |
+
"model": "ˈmɒdl̩",
|
| 105 |
+
"LLM": "ˌɛlˌɛlˈɛm",
|
| 106 |
+
"API key": "ˌeɪˌpiːˈaɪ kiː",
|
| 107 |
+
"voice agent": "vɔɪs ˈeɪdʒənt",
|
| 108 |
+
"Flora": "ˈflɔːrə",
|
| 109 |
+
"assistant": "əˈsɪstənt",
|
| 110 |
+
"flower bot": "ˈflaʊər bɑːt",
|
| 111 |
+
"voice demo": "vɔɪs ˈdɛmoʊ",
|
| 112 |
+
"Have a green day": "ˈhæv ə ɡriːn deɪ",
|
| 113 |
+
"blooms": "bluːmz",
|
| 114 |
+
"NVIDIA swag": "ɛnˌvɪdiə swæɡ",
|
| 115 |
+
"limited edition": "ˈlɪmɪtɪd ɪˈdɪʃən",
|
| 116 |
+
"coupon": "ˈkuːˌpɒn",
|
| 117 |
+
"gift wrap": "ɡɪft ræp",
|
| 118 |
+
"email": "ˈiːˌmeɪl",
|
| 119 |
+
"phone number": "foʊn ˈnʌmbər",
|
| 120 |
+
"pickup": "ˈpɪkˌʌp"
|
| 121 |
+
}
|
examples/voice_agent_multi_thread/langgraph_llm_service.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LangGraph-backed LLM service for Pipecat pipelines.
|
| 2 |
+
|
| 3 |
+
This service adapts a running LangGraph agent (accessed via langgraph-sdk)
|
| 4 |
+
to Pipecat's frame-based processing model. It consumes `OpenAILLMContextFrame`
|
| 5 |
+
or `LLMMessagesFrame` inputs, extracts the latest user message (using the
|
| 6 |
+
LangGraph server's thread to persist history), and streams assistant tokens
|
| 7 |
+
back as `LLMTextFrame` until completion.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
from typing import Any, Optional
|
| 14 |
+
import os
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
|
| 17 |
+
from langgraph_sdk import get_client
|
| 18 |
+
from langchain_core.messages import HumanMessage
|
| 19 |
+
from loguru import logger
|
| 20 |
+
from pipecat.frames.frames import (
|
| 21 |
+
Frame,
|
| 22 |
+
LLMFullResponseEndFrame,
|
| 23 |
+
LLMFullResponseStartFrame,
|
| 24 |
+
LLMMessagesFrame,
|
| 25 |
+
LLMTextFrame,
|
| 26 |
+
StartInterruptionFrame,
|
| 27 |
+
VisionImageRawFrame,
|
| 28 |
+
)
|
| 29 |
+
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext, OpenAILLMContextFrame
|
| 30 |
+
from pipecat.processors.frame_processor import FrameDirection
|
| 31 |
+
from pipecat.services.openai.llm import OpenAILLMService
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
load_dotenv()
|
| 35 |
+
|
| 36 |
+
# TTS sanitize helper: normalize curly quotes/dashes and non-breaking spaces to ASCII
|
| 37 |
+
def _tts_sanitize(text: str) -> str:
|
| 38 |
+
try:
|
| 39 |
+
if not isinstance(text, str):
|
| 40 |
+
text = str(text)
|
| 41 |
+
replacements = {
|
| 42 |
+
"\u2018": "'", # left single quote
|
| 43 |
+
"\u2019": "'", # right single quote / apostrophe
|
| 44 |
+
"\u201C": '"', # left double quote
|
| 45 |
+
"\u201D": '"', # right double quote
|
| 46 |
+
"\u00AB": '"', # left angle quote
|
| 47 |
+
"\u00BB": '"', # right angle quote
|
| 48 |
+
"\u2013": "-", # en dash
|
| 49 |
+
"\u2014": "-", # em dash
|
| 50 |
+
"\u2026": "...",# ellipsis
|
| 51 |
+
"\u00A0": " ", # non-breaking space
|
| 52 |
+
"\u202F": " ", # narrow no-break space
|
| 53 |
+
}
|
| 54 |
+
for k, v in replacements.items():
|
| 55 |
+
text = text.replace(k, v)
|
| 56 |
+
return text
|
| 57 |
+
except Exception:
|
| 58 |
+
return text
|
| 59 |
+
|
| 60 |
+
class LangGraphLLMService(OpenAILLMService):
|
| 61 |
+
"""Pipecat LLM service that delegates responses to a LangGraph agent.
|
| 62 |
+
|
| 63 |
+
Attributes:
|
| 64 |
+
base_url: LangGraph API base URL, e.g. "http://127.0.0.1:2024".
|
| 65 |
+
assistant: Assistant name or id registered with the LangGraph server.
|
| 66 |
+
user_email: Value for `configurable.user_email` (routing / personalization).
|
| 67 |
+
stream_mode: SDK stream mode ("updates", "values", "messages", "events").
|
| 68 |
+
debug_stream: When True, logs raw stream events for troubleshooting.
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
def __init__(
|
| 72 |
+
self,
|
| 73 |
+
*,
|
| 74 |
+
base_url: str = "http://127.0.0.1:2024",
|
| 75 |
+
assistant: str = "ace-base-agent",
|
| 76 |
+
user_email: str = "test@example.com",
|
| 77 |
+
stream_mode: str = "values",
|
| 78 |
+
debug_stream: bool = False,
|
| 79 |
+
thread_id: Optional[str] = None,
|
| 80 |
+
auth_token: Optional[str] = None,
|
| 81 |
+
enable_multi_threading: bool = True, # Enable multi-threaded routing
|
| 82 |
+
**kwargs: Any,
|
| 83 |
+
) -> None:
|
| 84 |
+
# Initialize base class; OpenAI settings unused but required by parent
|
| 85 |
+
super().__init__(api_key="", **kwargs)
|
| 86 |
+
self.base_url = base_url
|
| 87 |
+
self.assistant = assistant
|
| 88 |
+
self.user_email = user_email
|
| 89 |
+
self.stream_mode = stream_mode
|
| 90 |
+
self.debug_stream = debug_stream
|
| 91 |
+
self.enable_multi_threading = enable_multi_threading
|
| 92 |
+
|
| 93 |
+
# Optional auth header
|
| 94 |
+
token = (
|
| 95 |
+
auth_token
|
| 96 |
+
or os.getenv("LANGGRAPH_AUTH_TOKEN")
|
| 97 |
+
or os.getenv("AUTH0_ACCESS_TOKEN")
|
| 98 |
+
or os.getenv("AUTH_BEARER_TOKEN")
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
headers = {"Authorization": f"Bearer {token}"} if isinstance(token, str) and token else None
|
| 102 |
+
self._client = get_client(url=self.base_url, headers=headers) if headers else get_client(url=self.base_url)
|
| 103 |
+
|
| 104 |
+
# Multi-threading: maintain separate threads for main and secondary
|
| 105 |
+
self._thread_id_main: Optional[str] = thread_id
|
| 106 |
+
self._thread_id_secondary: Optional[str] = None
|
| 107 |
+
self._thread_id: Optional[str] = thread_id # Backward compatibility
|
| 108 |
+
|
| 109 |
+
# Namespace for store coordination - sanitize email (periods not allowed)
|
| 110 |
+
sanitized_email = self.user_email.replace(".", "_").replace("@", "_at_")
|
| 111 |
+
self._namespace_for_memory: tuple[str, str] = (sanitized_email, "tools_updates")
|
| 112 |
+
|
| 113 |
+
# Track interim message reset state
|
| 114 |
+
self._interim_messages_reset: bool = True
|
| 115 |
+
self._last_was_long_operation: bool = False
|
| 116 |
+
|
| 117 |
+
self._current_task: Optional[asyncio.Task] = None
|
| 118 |
+
self._outer_open: bool = False
|
| 119 |
+
self._emitted_texts: set[str] = set()
|
| 120 |
+
|
| 121 |
+
async def _ensure_thread(self, thread_type: str = "main") -> Optional[str]:
|
| 122 |
+
"""Ensure thread exists for the given type (main or secondary)."""
|
| 123 |
+
if thread_type == "main":
|
| 124 |
+
if self._thread_id_main:
|
| 125 |
+
return self._thread_id_main
|
| 126 |
+
else:
|
| 127 |
+
if self._thread_id_secondary:
|
| 128 |
+
return self._thread_id_secondary
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
thread = await self._client.threads.create()
|
| 132 |
+
except Exception as exc: # noqa: BLE001
|
| 133 |
+
logger.warning(f"LangGraph: failed to create {thread_type} thread; proceeding threadless. Error: {exc}")
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
thread_id = getattr(thread, "thread_id", None)
|
| 137 |
+
if thread_id is None and isinstance(thread, dict):
|
| 138 |
+
thread_id = thread.get("thread_id") or thread.get("id")
|
| 139 |
+
if thread_id is None:
|
| 140 |
+
thread_id = getattr(thread, "id", None)
|
| 141 |
+
|
| 142 |
+
if isinstance(thread_id, str) and thread_id:
|
| 143 |
+
if thread_type == "main":
|
| 144 |
+
self._thread_id_main = thread_id
|
| 145 |
+
self._thread_id = thread_id # Backward compatibility
|
| 146 |
+
else:
|
| 147 |
+
self._thread_id_secondary = thread_id
|
| 148 |
+
logger.info(f"Created {thread_type} thread: {thread_id}")
|
| 149 |
+
return thread_id
|
| 150 |
+
else:
|
| 151 |
+
logger.warning(f"LangGraph: could not determine {thread_type} thread id; proceeding threadless.")
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
async def _check_long_operation_running(self) -> bool:
|
| 155 |
+
"""Check if a long operation is currently running via the store."""
|
| 156 |
+
if not self.enable_multi_threading:
|
| 157 |
+
logger.info("Multi-threading disabled, returning False")
|
| 158 |
+
return False
|
| 159 |
+
|
| 160 |
+
try:
|
| 161 |
+
ns_list = list(self._namespace_for_memory)
|
| 162 |
+
logger.info(f"Checking store with namespace: {ns_list}")
|
| 163 |
+
|
| 164 |
+
# Get the specific status key that tools write to
|
| 165 |
+
item = await self._client.store.get_item(ns_list, "working-tool-status-update")
|
| 166 |
+
|
| 167 |
+
if item is None:
|
| 168 |
+
logger.info("No item found in store, returning False")
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
# Extract value from the item
|
| 172 |
+
value = getattr(item, "value", None)
|
| 173 |
+
if value is None and isinstance(item, dict):
|
| 174 |
+
value = item.get("value")
|
| 175 |
+
|
| 176 |
+
# Check if status is "running"
|
| 177 |
+
if isinstance(value, dict):
|
| 178 |
+
status = value.get("status")
|
| 179 |
+
logger.info(f"🔍 Long operation check: status={status}, tool={value.get('tool_name')}, progress={value.get('progress')}")
|
| 180 |
+
return status == "running"
|
| 181 |
+
|
| 182 |
+
logger.info(f"Value not a dict: {type(value)}")
|
| 183 |
+
return False
|
| 184 |
+
except Exception as exc: # noqa: BLE001
|
| 185 |
+
logger.error(f"❌ Failed to check operation status: {exc}", exc_info=True)
|
| 186 |
+
return False
|
| 187 |
+
|
| 188 |
+
@staticmethod
|
| 189 |
+
def _extract_latest_user_text(context: OpenAILLMContext) -> str:
|
| 190 |
+
"""Return the latest user (or fallback system) message content.
|
| 191 |
+
|
| 192 |
+
The LangGraph server maintains history via threads, so we only need to
|
| 193 |
+
send the current turn text. Prefer the latest user message; if absent,
|
| 194 |
+
fall back to the latest system message so system-only kickoffs can work.
|
| 195 |
+
"""
|
| 196 |
+
messages = context.get_messages() or []
|
| 197 |
+
for msg in reversed(messages):
|
| 198 |
+
try:
|
| 199 |
+
if msg.get("role") == "user":
|
| 200 |
+
content = msg.get("content", "")
|
| 201 |
+
return content if isinstance(content, str) else str(content)
|
| 202 |
+
except Exception: # Defensive against unexpected shapes
|
| 203 |
+
continue
|
| 204 |
+
# Fallback: use the most recent system message if no user message exists
|
| 205 |
+
for msg in reversed(messages):
|
| 206 |
+
try:
|
| 207 |
+
if msg.get("role") == "system":
|
| 208 |
+
content = msg.get("content", "")
|
| 209 |
+
return content if isinstance(content, str) else str(content)
|
| 210 |
+
except Exception:
|
| 211 |
+
continue
|
| 212 |
+
return ""
|
| 213 |
+
|
| 214 |
+
async def _stream_langgraph(self, text: str) -> None:
|
| 215 |
+
# Determine thread type based on whether a long operation is running
|
| 216 |
+
thread_type = "main"
|
| 217 |
+
if self.enable_multi_threading:
|
| 218 |
+
long_operation_running = await self._check_long_operation_running()
|
| 219 |
+
if long_operation_running:
|
| 220 |
+
thread_type = "secondary"
|
| 221 |
+
self._interim_messages_reset = False
|
| 222 |
+
logger.info("Long operation detected, routing to secondary thread")
|
| 223 |
+
else:
|
| 224 |
+
# Starting new main operation
|
| 225 |
+
if self._last_was_long_operation:
|
| 226 |
+
self._interim_messages_reset = True
|
| 227 |
+
self._last_was_long_operation = False
|
| 228 |
+
else:
|
| 229 |
+
self._interim_messages_reset = True
|
| 230 |
+
logger.info("No long operation, routing to main thread")
|
| 231 |
+
|
| 232 |
+
# Ensure appropriate thread
|
| 233 |
+
thread_id = await self._ensure_thread(thread_type)
|
| 234 |
+
|
| 235 |
+
# Build config with namespace for store coordination
|
| 236 |
+
config = {
|
| 237 |
+
"configurable": {
|
| 238 |
+
"user_email": self.user_email,
|
| 239 |
+
"thread_id": thread_id,
|
| 240 |
+
"namespace_for_memory": list(self._namespace_for_memory),
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
# Build input dict for multi-threaded agent
|
| 245 |
+
if self.enable_multi_threading:
|
| 246 |
+
input_payload = {
|
| 247 |
+
"messages": [{"type": "human", "content": text}],
|
| 248 |
+
"thread_type": thread_type,
|
| 249 |
+
"interim_messages_reset": self._interim_messages_reset,
|
| 250 |
+
}
|
| 251 |
+
else:
|
| 252 |
+
# Backward compatible: simple message input
|
| 253 |
+
input_payload = [HumanMessage(content=text)]
|
| 254 |
+
|
| 255 |
+
try:
|
| 256 |
+
async for chunk in self._client.runs.stream(
|
| 257 |
+
thread_id,
|
| 258 |
+
self.assistant,
|
| 259 |
+
input=input_payload,
|
| 260 |
+
stream_mode=self.stream_mode,
|
| 261 |
+
config=config,
|
| 262 |
+
):
|
| 263 |
+
data = getattr(chunk, "data", None)
|
| 264 |
+
event = getattr(chunk, "event", "") or ""
|
| 265 |
+
|
| 266 |
+
if self.debug_stream:
|
| 267 |
+
try:
|
| 268 |
+
# Short, structured debugging output
|
| 269 |
+
dtype = type(data).__name__
|
| 270 |
+
preview = ""
|
| 271 |
+
if hasattr(data, "content") and isinstance(getattr(data, "content"), str):
|
| 272 |
+
c = getattr(data, "content")
|
| 273 |
+
preview = c[:120]
|
| 274 |
+
elif isinstance(data, dict):
|
| 275 |
+
preview = ",".join(list(data.keys())[:6])
|
| 276 |
+
logger.debug(f"[LangGraph stream] event={event} data={dtype}:{preview}")
|
| 277 |
+
except Exception: # noqa: BLE001
|
| 278 |
+
logger.debug(f"[LangGraph stream] event={event}")
|
| 279 |
+
|
| 280 |
+
# Token streaming events (LangChain chat model streaming)
|
| 281 |
+
if "on_chat_model_stream" in event or event.endswith(".on_chat_model_stream"):
|
| 282 |
+
part_text = ""
|
| 283 |
+
d = data
|
| 284 |
+
if isinstance(d, dict):
|
| 285 |
+
if "chunk" in d:
|
| 286 |
+
ch = d["chunk"]
|
| 287 |
+
part_text = getattr(ch, "content", None) or ""
|
| 288 |
+
if not isinstance(part_text, str):
|
| 289 |
+
part_text = str(part_text)
|
| 290 |
+
elif "delta" in d:
|
| 291 |
+
delta = d["delta"]
|
| 292 |
+
part_text = getattr(delta, "content", None) or ""
|
| 293 |
+
if not isinstance(part_text, str):
|
| 294 |
+
part_text = str(part_text)
|
| 295 |
+
elif "content" in d and isinstance(d["content"], str):
|
| 296 |
+
part_text = d["content"]
|
| 297 |
+
else:
|
| 298 |
+
part_text = getattr(d, "content", "")
|
| 299 |
+
|
| 300 |
+
if part_text:
|
| 301 |
+
if not self._outer_open:
|
| 302 |
+
await self.push_frame(LLMFullResponseStartFrame())
|
| 303 |
+
self._outer_open = True
|
| 304 |
+
self._emitted_texts.clear()
|
| 305 |
+
if part_text not in self._emitted_texts:
|
| 306 |
+
self._emitted_texts.add(part_text)
|
| 307 |
+
await self.push_frame(LLMTextFrame(_tts_sanitize(part_text)))
|
| 308 |
+
|
| 309 |
+
# Final value-style events (values mode)
|
| 310 |
+
if event == "values":
|
| 311 |
+
# Some dev servers send final AI message content here
|
| 312 |
+
final_text = ""
|
| 313 |
+
|
| 314 |
+
# Handle list of messages (most common case)
|
| 315 |
+
if isinstance(data, list) and data:
|
| 316 |
+
# Find the last AI message in the list
|
| 317 |
+
for msg in reversed(data):
|
| 318 |
+
if isinstance(msg, dict):
|
| 319 |
+
if msg.get("type") == "ai" and isinstance(msg.get("content"), str):
|
| 320 |
+
final_text = msg["content"]
|
| 321 |
+
break
|
| 322 |
+
elif hasattr(msg, "type") and getattr(msg, "type") == "ai":
|
| 323 |
+
content = getattr(msg, "content", None)
|
| 324 |
+
if isinstance(content, str):
|
| 325 |
+
final_text = content
|
| 326 |
+
break
|
| 327 |
+
# Handle single message object
|
| 328 |
+
elif hasattr(data, "content") and isinstance(getattr(data, "content"), str):
|
| 329 |
+
final_text = getattr(data, "content")
|
| 330 |
+
# Handle single message dict
|
| 331 |
+
elif isinstance(data, dict):
|
| 332 |
+
c = data.get("content")
|
| 333 |
+
if isinstance(c, str):
|
| 334 |
+
final_text = c
|
| 335 |
+
|
| 336 |
+
if final_text:
|
| 337 |
+
# Close backchannel utterance if open
|
| 338 |
+
if self._outer_open:
|
| 339 |
+
await self.push_frame(LLMFullResponseEndFrame())
|
| 340 |
+
self._outer_open = False
|
| 341 |
+
self._emitted_texts.clear()
|
| 342 |
+
# Emit final explanation as its own message
|
| 343 |
+
await self.push_frame(LLMFullResponseStartFrame())
|
| 344 |
+
await self.push_frame(LLMTextFrame(_tts_sanitize(final_text)))
|
| 345 |
+
await self.push_frame(LLMFullResponseEndFrame())
|
| 346 |
+
|
| 347 |
+
# Messages mode: look for an array of messages
|
| 348 |
+
if event == "messages" or event.endswith(":messages"):
|
| 349 |
+
try:
|
| 350 |
+
msgs = None
|
| 351 |
+
if isinstance(data, dict):
|
| 352 |
+
msgs = data.get("messages") or data.get("result") or data.get("value")
|
| 353 |
+
elif hasattr(data, "messages"):
|
| 354 |
+
msgs = getattr(data, "messages")
|
| 355 |
+
if isinstance(msgs, list) and msgs:
|
| 356 |
+
last = msgs[-1]
|
| 357 |
+
content = getattr(last, "content", None)
|
| 358 |
+
if content is None and isinstance(last, dict):
|
| 359 |
+
content = last.get("content")
|
| 360 |
+
if isinstance(content, str) and content:
|
| 361 |
+
if not self._outer_open:
|
| 362 |
+
await self.push_frame(LLMFullResponseStartFrame())
|
| 363 |
+
self._outer_open = True
|
| 364 |
+
self._emitted_texts.clear()
|
| 365 |
+
if content not in self._emitted_texts:
|
| 366 |
+
self._emitted_texts.add(content)
|
| 367 |
+
await self.push_frame(LLMTextFrame(_tts_sanitize(content)))
|
| 368 |
+
except Exception as exc: # noqa: BLE001
|
| 369 |
+
logger.debug(f"LangGraph messages parsing error: {exc}")
|
| 370 |
+
# If payload is a plain string, emit it
|
| 371 |
+
if isinstance(data, str):
|
| 372 |
+
txt = data.strip()
|
| 373 |
+
if txt:
|
| 374 |
+
if not self._outer_open:
|
| 375 |
+
await self.push_frame(LLMFullResponseStartFrame())
|
| 376 |
+
self._outer_open = True
|
| 377 |
+
self._emitted_texts.clear()
|
| 378 |
+
if txt not in self._emitted_texts:
|
| 379 |
+
self._emitted_texts.add(txt)
|
| 380 |
+
await self.push_frame(LLMTextFrame(_tts_sanitize(txt)))
|
| 381 |
+
except Exception as exc: # noqa: BLE001
|
| 382 |
+
logger.error(f"LangGraph stream error: {exc}")
|
| 383 |
+
|
| 384 |
+
async def _process_context_and_frames(self, context: OpenAILLMContext) -> None:
|
| 385 |
+
"""Adapter entrypoint: push start/end frames and stream tokens."""
|
| 386 |
+
try:
|
| 387 |
+
# Defer opening until backchannels arrive; final will be emitted separately
|
| 388 |
+
user_text = self._extract_latest_user_text(context)
|
| 389 |
+
if not user_text:
|
| 390 |
+
logger.debug("LangGraph: no user text in context; skipping run.")
|
| 391 |
+
return
|
| 392 |
+
self._outer_open = False
|
| 393 |
+
self._emitted_texts.clear()
|
| 394 |
+
await self._stream_langgraph(user_text)
|
| 395 |
+
finally:
|
| 396 |
+
if self._outer_open:
|
| 397 |
+
await self.push_frame(LLMFullResponseEndFrame())
|
| 398 |
+
self._outer_open = False
|
| 399 |
+
|
| 400 |
+
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
| 401 |
+
"""Process pipeline frames, handling interruptions and context inputs."""
|
| 402 |
+
context: Optional[OpenAILLMContext] = None
|
| 403 |
+
|
| 404 |
+
if isinstance(frame, OpenAILLMContextFrame):
|
| 405 |
+
context = frame.context
|
| 406 |
+
elif isinstance(frame, LLMMessagesFrame):
|
| 407 |
+
context = OpenAILLMContext.from_messages(frame.messages)
|
| 408 |
+
elif isinstance(frame, VisionImageRawFrame):
|
| 409 |
+
# Not implemented for LangGraph adapter; ignore images
|
| 410 |
+
context = None
|
| 411 |
+
elif isinstance(frame, StartInterruptionFrame):
|
| 412 |
+
# Relay interruption downstream and cancel any active run
|
| 413 |
+
await self._start_interruption()
|
| 414 |
+
await self.stop_all_metrics()
|
| 415 |
+
await self.push_frame(frame, direction)
|
| 416 |
+
if self._current_task is not None and not self._current_task.done():
|
| 417 |
+
await self.cancel_task(self._current_task)
|
| 418 |
+
self._current_task = None
|
| 419 |
+
return
|
| 420 |
+
else:
|
| 421 |
+
await super().process_frame(frame, direction)
|
| 422 |
+
|
| 423 |
+
if context is not None:
|
| 424 |
+
if self._current_task is not None and not self._current_task.done():
|
| 425 |
+
await self.cancel_task(self._current_task)
|
| 426 |
+
self._current_task = None
|
| 427 |
+
logger.debug("LangGraph LLM: canceled previous task")
|
| 428 |
+
|
| 429 |
+
self._current_task = self.create_task(self._process_context_and_frames(context))
|
| 430 |
+
self._current_task.add_done_callback(lambda _: setattr(self, "_current_task", None))
|
| 431 |
+
|
| 432 |
+
|
examples/voice_agent_multi_thread/pipeline.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: BSD 2-Clause License
|
| 3 |
+
|
| 4 |
+
"""Voice Agent WebRTC Pipeline.
|
| 5 |
+
|
| 6 |
+
This module implements a voice agent pipeline using WebRTC for real-time
|
| 7 |
+
speech-to-speech communication with dynamic prompt support.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
import uuid
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import uvicorn
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
| 21 |
+
from fastapi.responses import JSONResponse
|
| 22 |
+
from fastapi.staticfiles import StaticFiles
|
| 23 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 24 |
+
from loguru import logger
|
| 25 |
+
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
| 26 |
+
from pipecat.frames.frames import InputAudioRawFrame, LLMMessagesFrame, TTSAudioRawFrame
|
| 27 |
+
from pipecat.pipeline.pipeline import Pipeline
|
| 28 |
+
from pipecat.pipeline.runner import PipelineRunner
|
| 29 |
+
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
| 30 |
+
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
| 31 |
+
from pipecat.transports.base_transport import TransportParams
|
| 32 |
+
from pipecat.transports.network.small_webrtc import SmallWebRTCTransport
|
| 33 |
+
from pipecat.transports.network.webrtc_connection import (
|
| 34 |
+
IceServer,
|
| 35 |
+
SmallWebRTCConnection,
|
| 36 |
+
)
|
| 37 |
+
from websocket_transcript_output import WebsocketTranscriptOutput
|
| 38 |
+
|
| 39 |
+
from nvidia_pipecat.processors.audio_util import AudioRecorder
|
| 40 |
+
from nvidia_pipecat.processors.nvidia_context_aggregator import (
|
| 41 |
+
NvidiaTTSResponseCacher,
|
| 42 |
+
create_nvidia_context_aggregator,
|
| 43 |
+
)
|
| 44 |
+
from nvidia_pipecat.processors.transcript_synchronization import (
|
| 45 |
+
BotTranscriptSynchronization,
|
| 46 |
+
UserTranscriptSynchronization,
|
| 47 |
+
)
|
| 48 |
+
from nvidia_pipecat.services.riva_speech import RivaASRService, RivaTTSService
|
| 49 |
+
from langgraph_llm_service import LangGraphLLMService
|
| 50 |
+
|
| 51 |
+
load_dotenv(override=True)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
app = FastAPI()
|
| 55 |
+
|
| 56 |
+
app.add_middleware(
|
| 57 |
+
CORSMiddleware,
|
| 58 |
+
allow_origins=["*"],
|
| 59 |
+
allow_credentials=True,
|
| 60 |
+
allow_methods=["*"],
|
| 61 |
+
allow_headers=["*"],
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Store connections by pc_id
|
| 65 |
+
pcs_map: dict[str, SmallWebRTCConnection] = {}
|
| 66 |
+
contexts_map: dict[str, OpenAILLMContext] = {}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# Helper: Build ICE servers for client (browser) using Twilio token if configured
|
| 70 |
+
def _build_client_ice_servers() -> list[dict]:
|
| 71 |
+
# Prefer Twilio dynamic credentials
|
| 72 |
+
sid = os.getenv("TWILIO_ACCOUNT_SID")
|
| 73 |
+
tok = os.getenv("TWILIO_AUTH_TOKEN")
|
| 74 |
+
if sid and tok:
|
| 75 |
+
try:
|
| 76 |
+
# Import lazily to avoid hard dependency when not configured
|
| 77 |
+
from twilio.rest import Client # type: ignore
|
| 78 |
+
|
| 79 |
+
client = Client(sid, tok)
|
| 80 |
+
token = client.tokens.create()
|
| 81 |
+
servers: list[dict] = []
|
| 82 |
+
# Twilio may return either 'ice_servers' with 'url' or 'urls'
|
| 83 |
+
for s in getattr(token, "ice_servers", []) or []:
|
| 84 |
+
url_val = s.get("urls") if isinstance(s, dict) else getattr(s, "urls", None)
|
| 85 |
+
if not url_val:
|
| 86 |
+
url_val = s.get("url") if isinstance(s, dict) else getattr(s, "url", None)
|
| 87 |
+
entry: dict = {"urls": url_val}
|
| 88 |
+
u = s.get("username") if isinstance(s, dict) else getattr(s, "username", None)
|
| 89 |
+
c = s.get("credential") if isinstance(s, dict) else getattr(s, "credential", None)
|
| 90 |
+
if u:
|
| 91 |
+
entry["username"] = u
|
| 92 |
+
if c:
|
| 93 |
+
entry["credential"] = c
|
| 94 |
+
if entry.get("urls"):
|
| 95 |
+
servers.append(entry)
|
| 96 |
+
# Always include a public STUN fallback
|
| 97 |
+
servers.append({"urls": "stun:stun.l.google.com:19302"})
|
| 98 |
+
return servers
|
| 99 |
+
except Exception as e: # noqa: BLE001
|
| 100 |
+
logger.warning(f"Twilio TURN fetch failed, falling back to env/static: {e}")
|
| 101 |
+
# Static env fallback
|
| 102 |
+
servers: list[dict] = []
|
| 103 |
+
turn_url = os.getenv("TURN_SERVER_URL") or os.getenv("TURN_URL")
|
| 104 |
+
turn_user = os.getenv("TURN_USERNAME") or os.getenv("TURN_USER")
|
| 105 |
+
turn_pass = os.getenv("TURN_PASSWORD") or os.getenv("TURN_PASS")
|
| 106 |
+
if turn_url:
|
| 107 |
+
server: dict = {"urls": turn_url}
|
| 108 |
+
if turn_user:
|
| 109 |
+
server["username"] = turn_user
|
| 110 |
+
if turn_pass:
|
| 111 |
+
server["credential"] = turn_pass
|
| 112 |
+
servers.append(server)
|
| 113 |
+
servers.append({"urls": "stun:stun.l.google.com:19302"})
|
| 114 |
+
return servers
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# Helper: Convert client ICE dicts to server IceServer objects
|
| 118 |
+
def _build_server_ice_servers() -> list[IceServer]:
|
| 119 |
+
out: list[IceServer] = []
|
| 120 |
+
for s in _build_client_ice_servers():
|
| 121 |
+
urls = s.get("urls")
|
| 122 |
+
username = s.get("username", "")
|
| 123 |
+
credential = s.get("credential", "")
|
| 124 |
+
# urls may be a list or a string. Normalize to list for safety.
|
| 125 |
+
if isinstance(urls, list):
|
| 126 |
+
for u in urls:
|
| 127 |
+
out.append(IceServer(urls=u, username=username, credential=credential))
|
| 128 |
+
elif isinstance(urls, str) and urls:
|
| 129 |
+
out.append(IceServer(urls=urls, username=username, credential=credential))
|
| 130 |
+
return out
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# Backward-compatible static servers (unused when Twilio configured)
|
| 134 |
+
ice_servers = (
|
| 135 |
+
[
|
| 136 |
+
IceServer(
|
| 137 |
+
urls=os.getenv("TURN_SERVER_URL", ""),
|
| 138 |
+
username=os.getenv("TURN_USERNAME", ""),
|
| 139 |
+
credential=os.getenv("TURN_PASSWORD", ""),
|
| 140 |
+
)
|
| 141 |
+
]
|
| 142 |
+
if os.getenv("TURN_SERVER_URL")
|
| 143 |
+
else []
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@app.get("/assistants")
|
| 148 |
+
async def list_assistants(request: Request):
|
| 149 |
+
"""Return a list of assistants from LangGraph, with robust fallbacks.
|
| 150 |
+
|
| 151 |
+
Output: List of {assistant_id, graph_id?, name?, description?, display_name}.
|
| 152 |
+
"""
|
| 153 |
+
import requests
|
| 154 |
+
|
| 155 |
+
base_url = os.getenv("LANGGRAPH_BASE_URL", "http://127.0.0.1:2024").rstrip("/")
|
| 156 |
+
|
| 157 |
+
inbound_auth = request.headers.get("authorization")
|
| 158 |
+
token = os.getenv("LANGGRAPH_AUTH_TOKEN") or os.getenv("AUTH0_ACCESS_TOKEN") or os.getenv("AUTH_BEARER_TOKEN")
|
| 159 |
+
headers = {"Authorization": inbound_auth} if inbound_auth else ({"Authorization": f"Bearer {token}"} if token else None)
|
| 160 |
+
|
| 161 |
+
def normalize_entries(raw_items: list) -> list[dict]:
|
| 162 |
+
results: list[dict] = []
|
| 163 |
+
for entry in raw_items:
|
| 164 |
+
assistant_id = None
|
| 165 |
+
if isinstance(entry, dict):
|
| 166 |
+
assistant_id = entry.get("assistant_id") or entry.get("id") or entry.get("name")
|
| 167 |
+
elif isinstance(entry, str):
|
| 168 |
+
assistant_id = entry
|
| 169 |
+
if not assistant_id:
|
| 170 |
+
continue
|
| 171 |
+
results.append({"assistant_id": assistant_id, **(entry if isinstance(entry, dict) else {})})
|
| 172 |
+
return results
|
| 173 |
+
|
| 174 |
+
# Try GET /assistants first (newer servers)
|
| 175 |
+
items: list[dict] = []
|
| 176 |
+
try:
|
| 177 |
+
get_resp = requests.get(f"{base_url}/assistants", params={"limit": 100}, timeout=8, headers=headers)
|
| 178 |
+
if get_resp.ok:
|
| 179 |
+
data = get_resp.json() or []
|
| 180 |
+
if isinstance(data, dict):
|
| 181 |
+
data = data.get("items") or data.get("results") or data.get("assistants") or []
|
| 182 |
+
items = normalize_entries(data)
|
| 183 |
+
except Exception as exc: # noqa: BLE001
|
| 184 |
+
logger.warning(f"GET /assistants failed: {exc}")
|
| 185 |
+
|
| 186 |
+
# Fallback: POST /assistants/search (older servers)
|
| 187 |
+
if not items:
|
| 188 |
+
try:
|
| 189 |
+
search_resp = requests.post(
|
| 190 |
+
f"{base_url}/assistants/search",
|
| 191 |
+
json={
|
| 192 |
+
"metadata": {},
|
| 193 |
+
"limit": 100,
|
| 194 |
+
"offset": 0,
|
| 195 |
+
"sort_by": "assistant_id",
|
| 196 |
+
"sort_order": "asc",
|
| 197 |
+
"select": ["assistant_id"],
|
| 198 |
+
},
|
| 199 |
+
timeout=10,
|
| 200 |
+
headers=headers,
|
| 201 |
+
)
|
| 202 |
+
if search_resp.ok:
|
| 203 |
+
data = search_resp.json() or []
|
| 204 |
+
if isinstance(data, dict):
|
| 205 |
+
data = data.get("items") or data.get("results") or []
|
| 206 |
+
items = normalize_entries(data)
|
| 207 |
+
except Exception as exc: # noqa: BLE001
|
| 208 |
+
logger.warning(f"POST /assistants/search failed: {exc}")
|
| 209 |
+
|
| 210 |
+
# Best-effort: enrich with details when possible
|
| 211 |
+
enriched: list[dict] = []
|
| 212 |
+
for item in items:
|
| 213 |
+
detail = dict(item)
|
| 214 |
+
assistant_id = detail.get("assistant_id")
|
| 215 |
+
if assistant_id:
|
| 216 |
+
try:
|
| 217 |
+
detail_resp = requests.get(f"{base_url}/assistants/{assistant_id}", timeout=5, headers=headers)
|
| 218 |
+
if detail_resp.ok:
|
| 219 |
+
d = detail_resp.json() or {}
|
| 220 |
+
detail.update(
|
| 221 |
+
{
|
| 222 |
+
"graph_id": d.get("graph_id"),
|
| 223 |
+
"name": d.get("name"),
|
| 224 |
+
"description": d.get("description"),
|
| 225 |
+
"metadata": d.get("metadata") or {},
|
| 226 |
+
}
|
| 227 |
+
)
|
| 228 |
+
except Exception:
|
| 229 |
+
pass
|
| 230 |
+
md = (detail.get("metadata") or {}) if isinstance(detail.get("metadata"), dict) else {}
|
| 231 |
+
display_name = (
|
| 232 |
+
detail.get("name")
|
| 233 |
+
or md.get("display_name")
|
| 234 |
+
or md.get("friendly_name")
|
| 235 |
+
or detail.get("graph_id")
|
| 236 |
+
or detail.get("assistant_id")
|
| 237 |
+
)
|
| 238 |
+
detail["display_name"] = display_name
|
| 239 |
+
enriched.append(detail)
|
| 240 |
+
|
| 241 |
+
# Final fallback: read local graphs from agents/langgraph.json
|
| 242 |
+
if not enriched:
|
| 243 |
+
try:
|
| 244 |
+
config_path = Path(__file__).parent / "agents" / "langgraph.json"
|
| 245 |
+
with open(config_path, encoding="utf-8") as f:
|
| 246 |
+
cfg = json.load(f) or {}
|
| 247 |
+
graphs = (cfg.get("graphs") or {}) if isinstance(cfg, dict) else {}
|
| 248 |
+
for graph_id in graphs.keys():
|
| 249 |
+
enriched.append({
|
| 250 |
+
"assistant_id": graph_id,
|
| 251 |
+
"graph_id": graph_id,
|
| 252 |
+
"display_name": graph_id,
|
| 253 |
+
})
|
| 254 |
+
except Exception as exc: # noqa: BLE001
|
| 255 |
+
logger.error(f"Failed to read local agents/langgraph.json: {exc}")
|
| 256 |
+
|
| 257 |
+
return enriched
|
| 258 |
+
|
| 259 |
+
async def run_bot(webrtc_connection, ws: WebSocket, assistant_override: str | None = None):
|
| 260 |
+
"""Run the voice agent bot with WebRTC connection and WebSocket.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
webrtc_connection: The WebRTC connection for audio streaming
|
| 264 |
+
ws: WebSocket connection for communication
|
| 265 |
+
"""
|
| 266 |
+
stream_id = uuid.uuid4()
|
| 267 |
+
transport_params = TransportParams(
|
| 268 |
+
audio_in_enabled=True,
|
| 269 |
+
audio_in_sample_rate=16000,
|
| 270 |
+
audio_out_sample_rate=16000,
|
| 271 |
+
audio_out_enabled=True,
|
| 272 |
+
vad_analyzer=SileroVADAnalyzer(),
|
| 273 |
+
audio_out_10ms_chunks=5,
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
transport = SmallWebRTCTransport(
|
| 277 |
+
webrtc_connection=webrtc_connection,
|
| 278 |
+
params=transport_params,
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
selected_assistant = assistant_override or os.getenv("LANGGRAPH_ASSISTANT", "ace-base-agent")
|
| 282 |
+
logger.info(f"Using LangGraph assistant: {selected_assistant}")
|
| 283 |
+
|
| 284 |
+
# Enable multi-threading for telco agent
|
| 285 |
+
enable_multi_threading = selected_assistant in ["telco-agent", "wire-transfer-agent"]
|
| 286 |
+
|
| 287 |
+
llm = LangGraphLLMService(
|
| 288 |
+
base_url=os.getenv("LANGGRAPH_BASE_URL", "http://127.0.0.1:2024"),
|
| 289 |
+
assistant=selected_assistant,
|
| 290 |
+
user_email=os.getenv("USER_EMAIL", "test@example.com"),
|
| 291 |
+
stream_mode=os.getenv("LANGGRAPH_STREAM_MODE", "values"),
|
| 292 |
+
debug_stream=os.getenv("LANGGRAPH_DEBUG_STREAM", "false").lower() == "true",
|
| 293 |
+
enable_multi_threading=enable_multi_threading,
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# stt = RivaASRService(
|
| 299 |
+
# server=os.getenv("RIVA_ASR_URL", "localhost:50051"),
|
| 300 |
+
# api_key=os.getenv("NVIDIA_API_KEY"),
|
| 301 |
+
# language=os.getenv("RIVA_ASR_LANGUAGE", "en-US"),
|
| 302 |
+
# sample_rate=16000,
|
| 303 |
+
# model=os.getenv("RIVA_ASR_MODEL", "parakeet-1.1b-en-US-asr-streaming-silero-vad-asr-bls-ensemble"),
|
| 304 |
+
# )
|
| 305 |
+
|
| 306 |
+
stt = RivaASRService(
|
| 307 |
+
# server=os.getenv("RIVA_ASR_URL", "localhost:50051"), # default url is grpc.nvcf.nvidia.com:443
|
| 308 |
+
api_key=os.getenv("RIVA_API_KEY"),
|
| 309 |
+
function_id=os.getenv("NVIDIA_ASR_FUNCTION_ID", "52b117d2-6c15-4cfa-a905-a67013bee409"),
|
| 310 |
+
language=os.getenv("RIVA_ASR_LANGUAGE", "en-US"),
|
| 311 |
+
sample_rate=16000,
|
| 312 |
+
model=os.getenv("RIVA_ASR_MODEL", "parakeet-1.1b-en-US-asr-streaming-silero-vad-asr-bls-ensemble"),
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
# stt = RivaASRService(
|
| 316 |
+
# server=os.getenv("RIVA_ASR_URL", "localhost:50051"),
|
| 317 |
+
# api_key=os.getenv("NVIDIA_API_KEY"),
|
| 318 |
+
# language=os.getenv("RIVA_ASR_LANGUAGE", "en-US"),
|
| 319 |
+
# sample_rate=16000,
|
| 320 |
+
# model=os.getenv("RIVA_ASR_MODEL", "parakeet-1.1b-en-US-asr-streaming-silero-vad-asr-bls-ensemble"),
|
| 321 |
+
# )
|
| 322 |
+
|
| 323 |
+
# Load IPA dictionary with error handling
|
| 324 |
+
ipa_file = Path(__file__).parent / "ipa.json"
|
| 325 |
+
try:
|
| 326 |
+
with open(ipa_file, encoding="utf-8") as f:
|
| 327 |
+
ipa_dict = json.load(f)
|
| 328 |
+
except FileNotFoundError as e:
|
| 329 |
+
logger.error(f"IPA dictionary file not found at {ipa_file}")
|
| 330 |
+
raise FileNotFoundError(f"IPA dictionary file not found at {ipa_file}") from e
|
| 331 |
+
except json.JSONDecodeError as e:
|
| 332 |
+
logger.error(f"Invalid JSON in IPA dictionary file: {e}")
|
| 333 |
+
raise ValueError(f"Invalid JSON in IPA dictionary file: {e}") from e
|
| 334 |
+
except Exception as e:
|
| 335 |
+
logger.error(f"Error loading IPA dictionary: {e}")
|
| 336 |
+
raise
|
| 337 |
+
|
| 338 |
+
tts = RivaTTSService(
|
| 339 |
+
# server=os.getenv("RIVA_TTS_URL", "localhost:50051"), # default url is grpc.nvcf.nvidia.com:443
|
| 340 |
+
api_key=os.getenv("RIVA_API_KEY"),
|
| 341 |
+
function_id=os.getenv("NVIDIA_TTS_FUNCTION_ID", "4e813649-d5e4-4020-b2be-2b918396d19d"),
|
| 342 |
+
voice_id=os.getenv("RIVA_TTS_VOICE_ID", "Magpie-ZeroShot.Female-1"),
|
| 343 |
+
model=os.getenv("RIVA_TTS_MODEL", "magpie_tts_ensemble-Magpie-ZeroShot"),
|
| 344 |
+
language=os.getenv("RIVA_TTS_LANGUAGE", "en-US"),
|
| 345 |
+
zero_shot_audio_prompt_file=(
|
| 346 |
+
Path(os.getenv("ZERO_SHOT_AUDIO_PROMPT")) if os.getenv("ZERO_SHOT_AUDIO_PROMPT") else None
|
| 347 |
+
),
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
# tts = RivaTTSService(
|
| 351 |
+
# server=os.getenv("RIVA_TTS_URL", "localhost:50051"),
|
| 352 |
+
# api_key=os.getenv("NVIDIA_API_KEY"),
|
| 353 |
+
# voice_id=os.getenv("RIVA_TTS_VOICE_ID", "Magpie-ZeroShot.Female-1"),
|
| 354 |
+
# model=os.getenv("RIVA_TTS_MODEL", "magpie_tts_ensemble-Magpie-ZeroShot"),
|
| 355 |
+
# language=os.getenv("RIVA_TTS_LANGUAGE", "en-US"),
|
| 356 |
+
# zero_shot_audio_prompt_file=(
|
| 357 |
+
# Path(os.getenv("ZERO_SHOT_AUDIO_PROMPT", str(Path(__file__).parent / "model-em_sample-02.wav")))
|
| 358 |
+
# if os.getenv("ZERO_SHOT_AUDIO_PROMPT")
|
| 359 |
+
# else None
|
| 360 |
+
# ),
|
| 361 |
+
# ipa_dict=ipa_dict,
|
| 362 |
+
# )
|
| 363 |
+
|
| 364 |
+
# Create audio_dumps directory if it doesn't exist
|
| 365 |
+
audio_dumps_dir = Path(__file__).parent / "audio_dumps"
|
| 366 |
+
audio_dumps_dir.mkdir(exist_ok=True)
|
| 367 |
+
|
| 368 |
+
asr_recorder = AudioRecorder(
|
| 369 |
+
output_file=str(audio_dumps_dir / f"asr_recording_{stream_id}.wav"),
|
| 370 |
+
params=transport_params,
|
| 371 |
+
frame_type=InputAudioRawFrame,
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
tts_recorder = AudioRecorder(
|
| 375 |
+
output_file=str(audio_dumps_dir / f"tts_recording_{stream_id}.wav"),
|
| 376 |
+
params=transport_params,
|
| 377 |
+
frame_type=TTSAudioRawFrame,
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
# Used to synchronize the user and bot transcripts in the UI
|
| 381 |
+
stt_transcript_synchronization = UserTranscriptSynchronization()
|
| 382 |
+
tts_transcript_synchronization = BotTranscriptSynchronization()
|
| 383 |
+
|
| 384 |
+
# Start with empty context; LangGraph agent manages prompts and policy
|
| 385 |
+
context = OpenAILLMContext([])
|
| 386 |
+
|
| 387 |
+
# Store context globally so WebSocket can access it
|
| 388 |
+
pc_id = webrtc_connection.pc_id
|
| 389 |
+
contexts_map[pc_id] = context
|
| 390 |
+
|
| 391 |
+
# Configure speculative speech processing based on environment variable
|
| 392 |
+
enable_speculative_speech = os.getenv("ENABLE_SPECULATIVE_SPEECH", "true").lower() == "true"
|
| 393 |
+
|
| 394 |
+
if enable_speculative_speech:
|
| 395 |
+
context_aggregator = create_nvidia_context_aggregator(context, send_interims=True)
|
| 396 |
+
tts_response_cacher = NvidiaTTSResponseCacher()
|
| 397 |
+
else:
|
| 398 |
+
context_aggregator = llm.create_context_aggregator(context)
|
| 399 |
+
tts_response_cacher = None
|
| 400 |
+
|
| 401 |
+
transcript_processor_output = WebsocketTranscriptOutput(ws)
|
| 402 |
+
|
| 403 |
+
pipeline = Pipeline(
|
| 404 |
+
[
|
| 405 |
+
transport.input(), # Websocket input from client
|
| 406 |
+
asr_recorder,
|
| 407 |
+
stt, # Speech-To-Text
|
| 408 |
+
stt_transcript_synchronization,
|
| 409 |
+
context_aggregator.user(),
|
| 410 |
+
llm, # LLM
|
| 411 |
+
tts, # Text-To-Speech
|
| 412 |
+
tts_recorder,
|
| 413 |
+
*([tts_response_cacher] if tts_response_cacher else []), # Include cacher only if enabled
|
| 414 |
+
tts_transcript_synchronization,
|
| 415 |
+
transcript_processor_output,
|
| 416 |
+
transport.output(), # Websocket output to client
|
| 417 |
+
context_aggregator.assistant(),
|
| 418 |
+
]
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
task = PipelineTask(
|
| 422 |
+
pipeline,
|
| 423 |
+
params=PipelineParams(
|
| 424 |
+
allow_interruptions=True,
|
| 425 |
+
enable_metrics=True,
|
| 426 |
+
enable_usage_metrics=True,
|
| 427 |
+
send_initial_empty_metrics=True,
|
| 428 |
+
start_metadata={"stream_id": stream_id},
|
| 429 |
+
),
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
# No auto-kickoff; LangGraph determines when/how to greet
|
| 433 |
+
|
| 434 |
+
runner = PipelineRunner(handle_sigint=False)
|
| 435 |
+
|
| 436 |
+
await runner.run(task)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
@app.websocket("/ws")
|
| 440 |
+
async def websocket_endpoint(websocket: WebSocket):
|
| 441 |
+
"""WebSocket endpoint for handling voice agent connections.
|
| 442 |
+
|
| 443 |
+
Args:
|
| 444 |
+
websocket: The WebSocket connection to handle
|
| 445 |
+
"""
|
| 446 |
+
await websocket.accept()
|
| 447 |
+
try:
|
| 448 |
+
request = await websocket.receive_json()
|
| 449 |
+
pc_id = request.get("pc_id")
|
| 450 |
+
assistant_from_client = request.get("assistant")
|
| 451 |
+
|
| 452 |
+
if pc_id and pc_id in pcs_map:
|
| 453 |
+
pipecat_connection = pcs_map[pc_id]
|
| 454 |
+
logger.info(f"Reusing existing connection for pc_id: {pc_id}")
|
| 455 |
+
await pipecat_connection.renegotiate(sdp=request["sdp"], type=request["type"])
|
| 456 |
+
else:
|
| 457 |
+
# Build dynamic servers (Twilio or env) for new connections
|
| 458 |
+
dynamic_servers = _build_server_ice_servers()
|
| 459 |
+
pipecat_connection = SmallWebRTCConnection(dynamic_servers if dynamic_servers else ice_servers)
|
| 460 |
+
await pipecat_connection.initialize(sdp=request["sdp"], type=request["type"])
|
| 461 |
+
|
| 462 |
+
@pipecat_connection.event_handler("closed")
|
| 463 |
+
async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
|
| 464 |
+
logger.info(f"Discarding peer connection for pc_id: {webrtc_connection.pc_id}")
|
| 465 |
+
pcs_map.pop(webrtc_connection.pc_id, None) # Remove connection reference
|
| 466 |
+
contexts_map.pop(webrtc_connection.pc_id, None) # Remove context reference
|
| 467 |
+
|
| 468 |
+
asyncio.create_task(run_bot(pipecat_connection, websocket, assistant_from_client))
|
| 469 |
+
|
| 470 |
+
answer = pipecat_connection.get_answer()
|
| 471 |
+
pcs_map[answer["pc_id"]] = pipecat_connection
|
| 472 |
+
|
| 473 |
+
await websocket.send_json(answer)
|
| 474 |
+
|
| 475 |
+
# Keep the connection open and print text messages
|
| 476 |
+
while True:
|
| 477 |
+
try:
|
| 478 |
+
message = await websocket.receive_text()
|
| 479 |
+
# Parse JSON message from UI
|
| 480 |
+
try:
|
| 481 |
+
data = json.loads(message)
|
| 482 |
+
message = data.get("message", "").strip()
|
| 483 |
+
if data.get("type") == "context_reset" and message:
|
| 484 |
+
print(f"Received context reset from UI: {message}")
|
| 485 |
+
logger.info(f"Context reset from UI: {message}")
|
| 486 |
+
|
| 487 |
+
# Forward context reset as a user message to LangGraph on next turn
|
| 488 |
+
pc_id = pipecat_connection.pc_id
|
| 489 |
+
if pc_id in contexts_map:
|
| 490 |
+
context = contexts_map[pc_id]
|
| 491 |
+
context.add_message({"role": "user", "content": message})
|
| 492 |
+
else:
|
| 493 |
+
print(f"No context found for pc_id: {pc_id}")
|
| 494 |
+
|
| 495 |
+
except json.JSONDecodeError:
|
| 496 |
+
print(f"Non-JSON message: {message}")
|
| 497 |
+
except Exception as e:
|
| 498 |
+
logger.error(f"Error processing message: {e}")
|
| 499 |
+
break
|
| 500 |
+
|
| 501 |
+
except WebSocketDisconnect:
|
| 502 |
+
logger.info("Client disconnected from websocket")
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
@app.get("/get_prompt")
|
| 506 |
+
async def get_prompt():
|
| 507 |
+
"""Report that the LangGraph agent owns the prompt/policy."""
|
| 508 |
+
return {
|
| 509 |
+
"prompt": "",
|
| 510 |
+
"name": "LangGraph-managed",
|
| 511 |
+
"description": "Prompt and persona are managed by the LangGraph agent.",
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
# RTC config endpoint must be registered before mounting static at "/"
|
| 515 |
+
@app.get("/rtc-config")
|
| 516 |
+
async def rtc_config():
|
| 517 |
+
"""Expose browser RTC ICE configuration based on environment variables or Twilio.
|
| 518 |
+
|
| 519 |
+
Uses Twilio dynamic TURN credentials when TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN are set.
|
| 520 |
+
Falls back to TURN_* env vars. Always includes a public STUN fallback.
|
| 521 |
+
"""
|
| 522 |
+
try:
|
| 523 |
+
servers = _build_client_ice_servers()
|
| 524 |
+
return {"iceServers": servers}
|
| 525 |
+
except Exception as e: # noqa: BLE001
|
| 526 |
+
logger.warning(f"rtc-config dynamic build failed: {e}")
|
| 527 |
+
# Final safe fallback
|
| 528 |
+
return {"iceServers": [{"urls": "stun:stun.l.google.com:19302"}]}
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
# Serve static UI (if bundled) after API/WebSocket routes so they still take precedence
|
| 532 |
+
UI_DIST_DIR = Path(__file__).parent / "ui" / "dist"
|
| 533 |
+
if UI_DIST_DIR.exists():
|
| 534 |
+
app.mount("/", StaticFiles(directory=str(UI_DIST_DIR), html=True), name="static")
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
if __name__ == "__main__":
|
| 538 |
+
parser = argparse.ArgumentParser(description="WebRTC demo")
|
| 539 |
+
parser.add_argument("--host", default="0.0.0.0", help="Host for HTTP server (default: localhost)")
|
| 540 |
+
parser.add_argument("--port", type=int, default=7860, help="Port for HTTP server (default: 7860)")
|
| 541 |
+
parser.add_argument("--verbose", "-v", action="count")
|
| 542 |
+
args = parser.parse_args()
|
| 543 |
+
|
| 544 |
+
logger.remove(0)
|
| 545 |
+
if args.verbose:
|
| 546 |
+
logger.add(sys.stderr, level="TRACE")
|
| 547 |
+
else:
|
| 548 |
+
logger.add(sys.stderr, level="DEBUG")
|
| 549 |
+
|
| 550 |
+
uvicorn.run(app, host=args.host, port=args.port)
|