fciannella commited on
Commit
2f49513
·
1 Parent(s): 06523e9

Added the healthcare example

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +16 -8
  2. README.md +75 -4
  3. examples/voice_agent_multi_thread/DOCKER_DEPLOYMENT.md +322 -0
  4. examples/voice_agent_multi_thread/Dockerfile +78 -0
  5. examples/voice_agent_multi_thread/PIPECAT_MULTI_THREADING.md +295 -0
  6. examples/voice_agent_multi_thread/README.md +112 -0
  7. examples/voice_agent_multi_thread/agents/.telco_thread_id +1 -0
  8. examples/voice_agent_multi_thread/agents/Dockerfile.langgraph.api +39 -0
  9. examples/voice_agent_multi_thread/agents/env.example +10 -0
  10. examples/voice_agent_multi_thread/agents/helper_functions.py +62 -0
  11. examples/voice_agent_multi_thread/agents/langgraph.json +15 -0
  12. examples/voice_agent_multi_thread/agents/requirements.txt +15 -0
  13. examples/voice_agent_multi_thread/agents/telco-agent-multi/IMPLEMENTATION_SUMMARY.md +280 -0
  14. examples/voice_agent_multi_thread/agents/telco-agent-multi/MULTI_THREAD_README.md +227 -0
  15. examples/voice_agent_multi_thread/agents/telco-agent-multi/README.md +57 -0
  16. examples/voice_agent_multi_thread/agents/telco-agent-multi/__init__.py +10 -0
  17. examples/voice_agent_multi_thread/agents/telco-agent-multi/example_multi_thread.py +233 -0
  18. examples/voice_agent_multi_thread/agents/telco-agent-multi/logic.py +1003 -0
  19. examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/customers.json +66 -0
  20. examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/otps.json +17 -0
  21. examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/packages.json +72 -0
  22. examples/voice_agent_multi_thread/agents/telco-agent-multi/mock_data/roaming_rates.json +30 -0
  23. examples/voice_agent_multi_thread/agents/telco-agent-multi/prompts.py +30 -0
  24. examples/voice_agent_multi_thread/agents/telco-agent-multi/react_agent.py +600 -0
  25. examples/voice_agent_multi_thread/agents/telco-agent-multi/tools.py +192 -0
  26. examples/voice_agent_multi_thread/agents/telco_client.py +570 -0
  27. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/README.md +56 -0
  28. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/__init__.py +9 -0
  29. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/fees_agent.py +15 -0
  30. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/logic.py +634 -0
  31. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/accounts.json +170 -0
  32. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/beneficiaries.json +15 -0
  33. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/country_requirements.json +11 -0
  34. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/cutoff_times.json +6 -0
  35. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/exchange_rates.json +12 -0
  36. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/fee_schedules.json +17 -0
  37. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/limits.json +15 -0
  38. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/otps.json +13 -0
  39. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/packages.json +11 -0
  40. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/sanctions_list.json +8 -0
  41. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/mock_data/transactions.json +22 -0
  42. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/prompts.py +31 -0
  43. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/react_agent.py +396 -0
  44. examples/voice_agent_multi_thread/agents/wire-transfer-agent-multi/tools.py +167 -0
  45. examples/voice_agent_multi_thread/docker-compose.yml +49 -0
  46. examples/voice_agent_multi_thread/env.example +9 -0
  47. examples/voice_agent_multi_thread/index.html +154 -0
  48. examples/voice_agent_multi_thread/ipa.json +121 -0
  49. examples/voice_agent_multi_thread/langgraph_llm_service.py +432 -0
  50. 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/voice_agent_webrtc_langgraph/ui/package*.json ./
7
  RUN npm ci --no-audit --no-fund && npm cache clean --force
8
  # Build UI
9
- COPY examples/voice_agent_webrtc_langgraph/ui/ .
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/voice_agent_webrtc_langgraph/ ./examples/voice_agent_webrtc_langgraph/
50
 
51
  # Copy built UI into example directory so FastAPI can serve it
52
- COPY --from=ui-builder --chown=user /ui/dist /app/examples/voice_agent_webrtc_langgraph/ui/dist
53
 
54
  # Example app directory
55
- WORKDIR /app/examples/voice_agent_webrtc_langgraph
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/voice_agent_webrtc_langgraph/.venv ]; then chown -R 1000:1000 /app/examples/voice_agent_webrtc_langgraph/.venv; fi
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 ["/app/examples/voice_agent_webrtc_langgraph/start.sh"]
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
- - Serves the built UI at `http://<host>:9000/` (via Docker).
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>:9000/`.
56
 
57
- Chrome on http origins: enable Insecure origins treated as secure at `chrome://flags/` and add `http://<machine-ip>:9000`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 cant access the mic on http, use the Chrome flag above or host the UI via HTTPS.
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)