dial-autogen 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: dial-autogen
3
+ Version: 0.10.0
4
+ Summary: Official Dial AutoGen tools — phone numbers, SMS, OTP, and voice calls for Microsoft AutoGen agents
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: autogen-agentchat>=0.4
7
+ Requires-Dist: autogen-core>=0.4
8
+ Requires-Dist: dial-sdk
9
+ Description-Content-Type: text/markdown
10
+
11
+ # dial-autogen
12
+
13
+ Official Dial tools for [Microsoft AutoGen](https://microsoft.github.io/autogen/) — give an
14
+ AutoGen agent the ability to send SMS, receive OTP codes, and place AI voice calls through
15
+ [Dial](https://getdial.ai). Agents automating signups and web tasks get a real phone identity.
16
+
17
+ It's the AutoGen sibling of [`dial-langchain`](https://pypi.org/project/dial-langchain/) and
18
+ [`dial-crewai`](https://pypi.org/project/dial-crewai/): each tool is an
19
+ [`autogen_core.tools.FunctionTool`](https://microsoft.github.io/autogen/stable//user-guide/core-user-guide/components/tools.html)
20
+ wrapping the [`dial-sdk`](https://pypi.org/project/dial-sdk/) client. It adds nothing to the REST
21
+ contract — it just shapes Dial's operations into AutoGen tools.
22
+
23
+ > Targeting Microsoft's newer **Agent Framework** instead of AutoGen? Use
24
+ > [`dial-agentframework`](https://pypi.org/project/dial-agentframework/).
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install dial-autogen
30
+ ```
31
+
32
+ This pulls in `dial-sdk` and `autogen-agentchat`.
33
+
34
+ ## Give the tools to an agent
35
+
36
+ Build one `DialClient`, pass it to `dial_tools`, and hand the result to an
37
+ `AssistantAgent`. Every tool shares that one client — a single connection pool for the
38
+ whole agent session — and you own its lifecycle (`await client.close()` when done):
39
+
40
+ ```python
41
+ from autogen_agentchat.agents import AssistantAgent
42
+ from autogen_ext.models.openai import AzureOpenAIChatCompletionClient
43
+ from dial_sdk import DialClient, DialConfig
44
+ from dial_autogen import dial_tools
45
+
46
+ dial = DialClient(DialConfig(api_key="sk_live_...")) # close with `await dial.close()`
47
+
48
+ model_client = AzureOpenAIChatCompletionClient(
49
+ azure_deployment="gpt-4o",
50
+ model="gpt-4o",
51
+ api_version="2024-10-21",
52
+ azure_endpoint="https://<your-resource>.openai.azure.com/",
53
+ )
54
+
55
+ agent = AssistantAgent(
56
+ name="phone_agent",
57
+ model_client=model_client,
58
+ tools=dial_tools(dial),
59
+ system_message="You operate the team's Dial phone number for SMS, OTP, and voice calls.",
60
+ )
61
+ ```
62
+
63
+ `DialConfig` also takes an optional `base_url` to target a non-default deployment.
64
+
65
+ ### Or pick individual tools
66
+
67
+ Each builder takes the shared `DialClient` and returns one `FunctionTool`:
68
+
69
+ ```python
70
+ from dial_autogen import send_message_tool, wait_for_message_tool
71
+
72
+ tools = [
73
+ send_message_tool(dial),
74
+ wait_for_message_tool(dial),
75
+ ]
76
+ ```
77
+
78
+ ## Available tools
79
+
80
+ Each builder takes your shared `DialClient`:
81
+
82
+ | Builder | Tool name | Action |
83
+ |---|---|---|
84
+ | `list_numbers_tool` | `list_numbers` | List your phone numbers |
85
+ | `purchase_number_tool` | `purchase_number` | Provision a new number (billable) |
86
+ | `set_number_properties_tool` | `set_number_properties` | Update a number's nickname / inbound instruction |
87
+ | `send_message_tool` | `send_message` | Send an SMS (optionally MMS) |
88
+ | `list_messages_tool` | `list_messages` | List recent messages |
89
+ | `make_call_tool` | `make_call` | Place an AI voice call |
90
+ | `list_calls_tool` | `list_calls` | List recent calls |
91
+ | `get_call_tool` | `get_call` | Fetch one call by id |
92
+ | `get_billing_tool` | `get_billing` | Credit balance, subscription, per-number mode |
93
+ | `wait_for_message_tool` | `wait_for_message` | Block until the next inbound SMS arrives, or time out |
94
+
95
+ ## OTP flow
96
+
97
+ The point of phone identity for an agent: send a code and read the reply.
98
+ `wait_for_message` blocks until the next inbound SMS arrives, so an agent can:
99
+
100
+ 1. trigger a signup that texts a code to your Dial number,
101
+ 2. call `wait_for_message` to read the inbound code,
102
+ 3. enter it back into the signup form.
103
+
104
+ See [`examples/signup_autogen.ipynb`](./examples/signup_autogen.ipynb) for a runnable agent.
105
+
106
+ ## Notes
107
+
108
+ - AutoGen runs tools in an async loop, so these tools are **async-native** — no
109
+ async→sync bridge (the one difference from the CrewAI sibling).
110
+ - `send_message` is a write action and **isn't idempotent** — a re-invoke after a
111
+ failure can send a duplicate. `make_call` accepts an `idempotency_key`.
112
+ - `wait_for_message` is backed by Dial's **presence-based** event stream — for
113
+ durable, at-least-once delivery, register a
114
+ [webhook](https://docs.getdial.ai/documentation/platform/webhooks).
115
+
116
+ See the [AutoGen integration docs](https://docs.getdial.ai/documentation/sdks/autogen)
117
+ for the full guide.
@@ -0,0 +1,107 @@
1
+ # dial-autogen
2
+
3
+ Official Dial tools for [Microsoft AutoGen](https://microsoft.github.io/autogen/) — give an
4
+ AutoGen agent the ability to send SMS, receive OTP codes, and place AI voice calls through
5
+ [Dial](https://getdial.ai). Agents automating signups and web tasks get a real phone identity.
6
+
7
+ It's the AutoGen sibling of [`dial-langchain`](https://pypi.org/project/dial-langchain/) and
8
+ [`dial-crewai`](https://pypi.org/project/dial-crewai/): each tool is an
9
+ [`autogen_core.tools.FunctionTool`](https://microsoft.github.io/autogen/stable//user-guide/core-user-guide/components/tools.html)
10
+ wrapping the [`dial-sdk`](https://pypi.org/project/dial-sdk/) client. It adds nothing to the REST
11
+ contract — it just shapes Dial's operations into AutoGen tools.
12
+
13
+ > Targeting Microsoft's newer **Agent Framework** instead of AutoGen? Use
14
+ > [`dial-agentframework`](https://pypi.org/project/dial-agentframework/).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install dial-autogen
20
+ ```
21
+
22
+ This pulls in `dial-sdk` and `autogen-agentchat`.
23
+
24
+ ## Give the tools to an agent
25
+
26
+ Build one `DialClient`, pass it to `dial_tools`, and hand the result to an
27
+ `AssistantAgent`. Every tool shares that one client — a single connection pool for the
28
+ whole agent session — and you own its lifecycle (`await client.close()` when done):
29
+
30
+ ```python
31
+ from autogen_agentchat.agents import AssistantAgent
32
+ from autogen_ext.models.openai import AzureOpenAIChatCompletionClient
33
+ from dial_sdk import DialClient, DialConfig
34
+ from dial_autogen import dial_tools
35
+
36
+ dial = DialClient(DialConfig(api_key="sk_live_...")) # close with `await dial.close()`
37
+
38
+ model_client = AzureOpenAIChatCompletionClient(
39
+ azure_deployment="gpt-4o",
40
+ model="gpt-4o",
41
+ api_version="2024-10-21",
42
+ azure_endpoint="https://<your-resource>.openai.azure.com/",
43
+ )
44
+
45
+ agent = AssistantAgent(
46
+ name="phone_agent",
47
+ model_client=model_client,
48
+ tools=dial_tools(dial),
49
+ system_message="You operate the team's Dial phone number for SMS, OTP, and voice calls.",
50
+ )
51
+ ```
52
+
53
+ `DialConfig` also takes an optional `base_url` to target a non-default deployment.
54
+
55
+ ### Or pick individual tools
56
+
57
+ Each builder takes the shared `DialClient` and returns one `FunctionTool`:
58
+
59
+ ```python
60
+ from dial_autogen import send_message_tool, wait_for_message_tool
61
+
62
+ tools = [
63
+ send_message_tool(dial),
64
+ wait_for_message_tool(dial),
65
+ ]
66
+ ```
67
+
68
+ ## Available tools
69
+
70
+ Each builder takes your shared `DialClient`:
71
+
72
+ | Builder | Tool name | Action |
73
+ |---|---|---|
74
+ | `list_numbers_tool` | `list_numbers` | List your phone numbers |
75
+ | `purchase_number_tool` | `purchase_number` | Provision a new number (billable) |
76
+ | `set_number_properties_tool` | `set_number_properties` | Update a number's nickname / inbound instruction |
77
+ | `send_message_tool` | `send_message` | Send an SMS (optionally MMS) |
78
+ | `list_messages_tool` | `list_messages` | List recent messages |
79
+ | `make_call_tool` | `make_call` | Place an AI voice call |
80
+ | `list_calls_tool` | `list_calls` | List recent calls |
81
+ | `get_call_tool` | `get_call` | Fetch one call by id |
82
+ | `get_billing_tool` | `get_billing` | Credit balance, subscription, per-number mode |
83
+ | `wait_for_message_tool` | `wait_for_message` | Block until the next inbound SMS arrives, or time out |
84
+
85
+ ## OTP flow
86
+
87
+ The point of phone identity for an agent: send a code and read the reply.
88
+ `wait_for_message` blocks until the next inbound SMS arrives, so an agent can:
89
+
90
+ 1. trigger a signup that texts a code to your Dial number,
91
+ 2. call `wait_for_message` to read the inbound code,
92
+ 3. enter it back into the signup form.
93
+
94
+ See [`examples/signup_autogen.ipynb`](./examples/signup_autogen.ipynb) for a runnable agent.
95
+
96
+ ## Notes
97
+
98
+ - AutoGen runs tools in an async loop, so these tools are **async-native** — no
99
+ async→sync bridge (the one difference from the CrewAI sibling).
100
+ - `send_message` is a write action and **isn't idempotent** — a re-invoke after a
101
+ failure can send a duplicate. `make_call` accepts an `idempotency_key`.
102
+ - `wait_for_message` is backed by Dial's **presence-based** event stream — for
103
+ durable, at-least-once delivery, register a
104
+ [webhook](https://docs.getdial.ai/documentation/platform/webhooks).
105
+
106
+ See the [AutoGen integration docs](https://docs.getdial.ai/documentation/sdks/autogen)
107
+ for the full guide.
@@ -0,0 +1,27 @@
1
+ from .tools import (
2
+ dial_tools,
3
+ list_numbers_tool,
4
+ purchase_number_tool,
5
+ set_number_properties_tool,
6
+ list_messages_tool,
7
+ send_message_tool,
8
+ list_calls_tool,
9
+ make_call_tool,
10
+ get_call_tool,
11
+ get_billing_tool,
12
+ wait_for_message_tool,
13
+ )
14
+
15
+ __all__ = [
16
+ "dial_tools",
17
+ "list_numbers_tool",
18
+ "purchase_number_tool",
19
+ "set_number_properties_tool",
20
+ "list_messages_tool",
21
+ "send_message_tool",
22
+ "list_calls_tool",
23
+ "make_call_tool",
24
+ "get_call_tool",
25
+ "get_billing_tool",
26
+ "wait_for_message_tool",
27
+ ]
@@ -0,0 +1,413 @@
1
+ """Dial tools for Microsoft AutoGen (``autogen-agentchat`` / ``autogen-core``).
2
+
3
+ Each builder takes a ``dial_sdk.DialClient`` and returns an
4
+ ``autogen_core.tools.FunctionTool`` that calls it. The client is **passed in, not
5
+ constructed per call** — you build one ``DialClient`` and share it across every tool,
6
+ so a whole agent session reuses a single connection pool. You own the client's
7
+ lifecycle (call ``await client.close()`` when done).
8
+
9
+ AutoGen runs tools in an async loop, so — unlike the CrewAI sibling — there is no
10
+ async→sync bridge: every tool is a plain ``async def``.
11
+
12
+ from autogen_agentchat.agents import AssistantAgent
13
+ from dial_sdk import DialClient, DialConfig
14
+ from dial_autogen import dial_tools
15
+
16
+ client = DialClient(DialConfig(api_key="sk_live_..."))
17
+ agent = AssistantAgent(
18
+ name="phone_agent",
19
+ model_client=model_client,
20
+ tools=dial_tools(client),
21
+ )
22
+ """
23
+
24
+ import asyncio
25
+ from typing import Annotated, Optional
26
+
27
+ from autogen_core.tools import FunctionTool
28
+ from dial_sdk import (
29
+ DialClient,
30
+ MakeCallParams,
31
+ PurchaseNumberParams,
32
+ SendMessageParams,
33
+ )
34
+
35
+
36
+ # ── Numbers ──────────────────────────────────────────────────────────────────
37
+
38
+
39
+ def list_numbers_tool(client: DialClient) -> FunctionTool:
40
+ async def list_numbers() -> str:
41
+ numbers = await client.list_numbers()
42
+ if not numbers:
43
+ return "No numbers found."
44
+ return "\n".join(
45
+ f"{n.id} | {n.number} | {n.country} | {n.capabilities}"
46
+ + (f' | "{n.nickname}"' if n.nickname else "")
47
+ for n in numbers
48
+ )
49
+
50
+ return FunctionTool(
51
+ list_numbers,
52
+ name="list_numbers",
53
+ description="List all available Dial phone numbers on the account.",
54
+ )
55
+
56
+
57
+ def purchase_number_tool(client: DialClient) -> FunctionTool:
58
+ async def purchase_number(
59
+ inbound_instruction: Annotated[
60
+ str,
61
+ "System prompt the AI voice agent uses on inbound calls to the new number",
62
+ ],
63
+ inbound_voice_gender: Annotated[
64
+ Optional[str],
65
+ "Voice gender for inbound calls to the new number: 'male' or 'female'. "
66
+ "Omit to use the default female voice.",
67
+ ] = None,
68
+ area_code: Annotated[
69
+ Optional[str],
70
+ "Preferred US area code to provision in, e.g. '415'. Omit for any available "
71
+ "US number. Only US numbers can be provisioned at this time.",
72
+ ] = None,
73
+ ) -> str:
74
+ number = await client.purchase_number(
75
+ PurchaseNumberParams(
76
+ inbound_instruction=inbound_instruction,
77
+ area_code=area_code,
78
+ inbound_voice_gender=inbound_voice_gender,
79
+ )
80
+ )
81
+ return f"Purchased {number.number} (id: {number.id}, country: {number.country})"
82
+
83
+ return FunctionTool(
84
+ purchase_number,
85
+ name="purchase_number",
86
+ description="Provision (buy) a new Dial phone number. Billable.",
87
+ )
88
+
89
+
90
+ def set_number_properties_tool(client: DialClient) -> FunctionTool:
91
+ async def set_number_properties(
92
+ number_id: Annotated[str, "ID of the Dial phone number to update"],
93
+ inbound_instruction: Annotated[
94
+ Optional[str], "New system prompt for inbound calls to this number"
95
+ ] = None,
96
+ inbound_voice_gender: Annotated[
97
+ Optional[str],
98
+ "Voice gender for inbound calls to this number: 'male' or 'female'. "
99
+ "Pass an empty string to clear it (reverts to the default, female).",
100
+ ] = None,
101
+ nickname: Annotated[
102
+ Optional[str],
103
+ "Human-readable label for the number, e.g. 'Support line'. "
104
+ "Pass an empty string to clear it.",
105
+ ] = None,
106
+ max_call_duration_seconds: Annotated[
107
+ Optional[int],
108
+ "Cap calls on this number to at most this many seconds, applied as a hard "
109
+ "ceiling to both inbound and outbound calls. The effective cap on a call is "
110
+ "the smallest of the per-number, account, and per-call limits.",
111
+ ] = None,
112
+ ) -> str:
113
+ kwargs: dict = {}
114
+ if inbound_instruction is not None:
115
+ kwargs["inbound_instruction"] = inbound_instruction
116
+ # Empty string clears the override (reverts to the default, female); else set it.
117
+ if inbound_voice_gender is not None:
118
+ kwargs["inbound_voice_gender"] = inbound_voice_gender or None
119
+ if nickname is not None:
120
+ kwargs["nickname"] = nickname
121
+ if max_call_duration_seconds is not None:
122
+ kwargs["max_call_duration_seconds"] = max_call_duration_seconds
123
+ if not kwargs:
124
+ return (
125
+ "Provide at least one of inbound_instruction, inbound_voice_gender, "
126
+ "nickname, or max_call_duration_seconds."
127
+ )
128
+ number = await client.set_number_properties(number_id, **kwargs)
129
+ nick = f' | nickname: "{number.nickname}"' if number.nickname else ""
130
+ return f"Updated {number.number} (id: {number.id}){nick}"
131
+
132
+ return FunctionTool(
133
+ set_number_properties,
134
+ name="set_number_properties",
135
+ description=(
136
+ "Update a Dial phone number's properties: its inbound instruction (the system "
137
+ "prompt its AI voice agent uses on inbound calls) and/or its nickname. "
138
+ "Provide at least one."
139
+ ),
140
+ )
141
+
142
+
143
+ # ── Messages ─────────────────────────────────────────────────────────────────
144
+
145
+
146
+ def list_messages_tool(client: DialClient) -> FunctionTool:
147
+ async def list_messages(
148
+ number_id: Annotated[
149
+ Optional[str], "Restrict to messages on a single owned phone number id"
150
+ ] = None,
151
+ direction: Annotated[
152
+ Optional[str], "Filter by direction: 'inbound' or 'outbound'"
153
+ ] = None,
154
+ ) -> str:
155
+ messages = await client.list_messages(number_id=number_id, direction=direction)
156
+ if not messages:
157
+ return "No messages found."
158
+ return "\n".join(
159
+ f"[{m.direction}] {m.from_} → {m.to}: {m.body[:50]}" for m in messages
160
+ )
161
+
162
+ return FunctionTool(
163
+ list_messages,
164
+ name="list_messages",
165
+ description="List recent inbound and outbound messages on the account.",
166
+ )
167
+
168
+
169
+ def send_message_tool(client: DialClient) -> FunctionTool:
170
+ async def send_message(
171
+ to: Annotated[str, "Destination phone number in E.164 format, e.g. +1234567890"],
172
+ from_number_id: Annotated[str, "ID of the Dial phone number to send from"],
173
+ body: Annotated[str, "Message body text"],
174
+ channel: Annotated[str, "Channel to use: 'sms'"] = "sms",
175
+ media_urls: Annotated[
176
+ Optional[list[str]],
177
+ "Publicly reachable http(s) URLs of media to attach (MMS), max 10. "
178
+ "Dial downloads and re-hosts each one.",
179
+ ] = None,
180
+ ) -> str:
181
+ msg = await client.send_message(
182
+ SendMessageParams(
183
+ to=to,
184
+ from_number_id=from_number_id,
185
+ body=body,
186
+ channel=channel,
187
+ media=list(media_urls or []),
188
+ )
189
+ )
190
+ media_note = (
191
+ f", media: {', '.join(m.url for m in msg.media)}" if msg.media else ""
192
+ )
193
+ return f"Message sent successfully. ID: {msg.id}, status: {msg.status}{media_note}"
194
+
195
+ return FunctionTool(
196
+ send_message,
197
+ name="send_message",
198
+ description="Send an SMS message to a phone number, optionally with media attachments (MMS).",
199
+ )
200
+
201
+
202
+ # ── Calls ────────────────────────────────────────────────────────────────────
203
+
204
+
205
+ def list_calls_tool(client: DialClient) -> FunctionTool:
206
+ async def list_calls(
207
+ number_id: Annotated[
208
+ Optional[str], "Restrict to calls on a single owned phone number id"
209
+ ] = None,
210
+ direction: Annotated[
211
+ Optional[str], "Filter by direction: 'inbound' or 'outbound'"
212
+ ] = None,
213
+ ) -> str:
214
+ calls = await client.list_calls(number_id=number_id, direction=direction)
215
+ if not calls:
216
+ return "No calls found."
217
+ return "\n".join(
218
+ f"{c.id} | [{c.direction}] {c.from_} → {c.to} | "
219
+ f"{c.status.get('label')} | {c.duration}s"
220
+ for c in calls
221
+ )
222
+
223
+ return FunctionTool(
224
+ list_calls,
225
+ name="list_calls",
226
+ description="List recent inbound and outbound calls on the account.",
227
+ )
228
+
229
+
230
+ def make_call_tool(client: DialClient) -> FunctionTool:
231
+ async def make_call(
232
+ to: Annotated[str, "Destination phone number in E.164 format, e.g. +1234567890"],
233
+ from_number_id: Annotated[str, "ID of the Dial phone number to call from"],
234
+ outbound_instruction: Annotated[
235
+ str, "System prompt for the AI voice agent during this call"
236
+ ],
237
+ language: Annotated[
238
+ Optional[str],
239
+ "BCP-47 language tag, e.g. 'en-US', 'he-IL'. Omit to auto-detect from the "
240
+ "destination number's country (the agent also handles en-US).",
241
+ ] = None,
242
+ voice_gender: Annotated[
243
+ Optional[str],
244
+ "Voice gender for the agent: 'male' or 'female'. Omit to use the default "
245
+ "female voice (the default for every language); pass 'male' to override.",
246
+ ] = None,
247
+ transfer_to: Annotated[
248
+ Optional[str],
249
+ "Forward-to number in E.164 format, e.g. +1987654321. When set, the agent "
250
+ "waits for a real human (riding out hold music and IVR menus) and then "
251
+ "cold-transfers the call to this number. Must differ from `to` and the from number.",
252
+ ] = None,
253
+ idempotency_key: Annotated[
254
+ Optional[str],
255
+ "Unique key (e.g. a UUID) making the placement idempotent: re-invoking with "
256
+ "the same key returns the already-placed call instead of dialing again.",
257
+ ] = None,
258
+ max_call_duration_seconds: Annotated[
259
+ Optional[int],
260
+ "Maximum duration for this call in seconds. Omit to use the account-level "
261
+ "or number-level cap (if any).",
262
+ ] = None,
263
+ ) -> str:
264
+ call = await client.make_call(
265
+ MakeCallParams(
266
+ to=to,
267
+ from_number_id=from_number_id,
268
+ outbound_instruction=outbound_instruction,
269
+ language=language,
270
+ voice_gender=voice_gender,
271
+ transfer_to=transfer_to,
272
+ idempotency_key=idempotency_key,
273
+ max_call_duration_seconds=max_call_duration_seconds,
274
+ )
275
+ )
276
+ return f"Call initiated. ID: {call.id}, state: {call.status.get('state')}"
277
+
278
+ return FunctionTool(
279
+ make_call,
280
+ name="make_call",
281
+ description="Initiate an AI voice call to a phone number.",
282
+ )
283
+
284
+
285
+ def get_call_tool(client: DialClient) -> FunctionTool:
286
+ async def get_call(call_id: Annotated[str, "ID of the call to fetch"]) -> str:
287
+ call = await client.get_call(call_id)
288
+ return (
289
+ f"Call {call.id}: [{call.direction}] {call.from_} → {call.to} | "
290
+ f"state={call.status.get('state')} | label={call.status.get('label')} | "
291
+ f"duration={call.duration}s"
292
+ )
293
+
294
+ return FunctionTool(
295
+ get_call,
296
+ name="get_call",
297
+ description="Fetch a single call by its id, including its current status.",
298
+ )
299
+
300
+
301
+ # ── Billing ──────────────────────────────────────────────────────────────────
302
+
303
+
304
+ def get_billing_tool(client: DialClient) -> FunctionTool:
305
+ async def get_billing() -> str:
306
+ b = await client.get_billing()
307
+ balance = f"${b.balance_cents / 100:.2f}"
308
+ if b.subscription:
309
+ plan = (
310
+ f"subscribed ({b.subscription.interval}, "
311
+ f"{b.subscription.quantity} numbers, renews {b.subscription.period_end})"
312
+ )
313
+ else:
314
+ plan = "pay-as-you-go"
315
+ numbers = ", ".join(f"{n.number} [{n.mode}]" for n in b.numbers) or "none"
316
+ return f"Balance: {balance} | Plan: {plan} | Numbers: {numbers}"
317
+
318
+ return FunctionTool(
319
+ get_billing,
320
+ name="get_billing",
321
+ description=(
322
+ "Get the account's billing status: credit balance, subscription (if any), "
323
+ "each number's billing mode (PAYG or subscription), and recent usage."
324
+ ),
325
+ )
326
+
327
+
328
+ # ── Events (wait-for) ────────────────────────────────────────────────────────
329
+
330
+
331
+ def wait_for_message_tool(client: DialClient) -> FunctionTool:
332
+ async def wait_for_message(
333
+ phone_number_id: Annotated[
334
+ str,
335
+ "Optional id of the Dial phone number to wait on. If omitted, returns the "
336
+ "first inbound message on any number.",
337
+ ] = "",
338
+ timeout_seconds: Annotated[
339
+ int, "How long to wait for a message in seconds"
340
+ ] = 30,
341
+ ) -> str:
342
+ # Resolve the phone number id to its E.164 number so we can match the
343
+ # inbound event's `to` field (events carry the number, not its id).
344
+ target_number: Optional[str] = None
345
+ if phone_number_id:
346
+ numbers = await client.list_numbers()
347
+ match = next((n for n in numbers if n.id == phone_number_id), None)
348
+ if match is None:
349
+ return f"No owned number with id {phone_number_id}."
350
+ target_number = match.number
351
+
352
+ async def _first_message() -> Optional[dict]:
353
+ async with client.new_events_connection() as conn:
354
+ async for event in conn:
355
+ if event.get("type") != "message.received":
356
+ continue
357
+ # Event fields live under `data` in the standardized envelope.
358
+ data = event.get("data", {})
359
+ if target_number and data.get("to") != target_number:
360
+ continue
361
+ return event
362
+ return None
363
+
364
+ try:
365
+ event = await asyncio.wait_for(_first_message(), timeout=timeout_seconds)
366
+ except asyncio.TimeoutError:
367
+ return f"No message received within {timeout_seconds}s."
368
+
369
+ if not event:
370
+ return f"No message received within {timeout_seconds}s."
371
+ data = event.get("data", {})
372
+ return f"Received message from {data.get('from')}: {data.get('body')}"
373
+
374
+ return FunctionTool(
375
+ wait_for_message,
376
+ name="wait_for_message",
377
+ description=(
378
+ "Wait for the next inbound SMS message and return it. Blocks until a message "
379
+ "arrives or the timeout elapses. Use after sending a code to await the user's reply (OTP)."
380
+ ),
381
+ )
382
+
383
+
384
+ # ── Bundle ───────────────────────────────────────────────────────────────────
385
+
386
+
387
+ def dial_tools(client: DialClient) -> list[FunctionTool]:
388
+ """Every Dial tool in one list, all sharing the given ``DialClient``.
389
+
390
+ Build one client, hand the tools to an AutoGen ``AssistantAgent``, and close the
391
+ client yourself when the agent is done::
392
+
393
+ from autogen_agentchat.agents import AssistantAgent
394
+ from dial_sdk import DialClient, DialConfig
395
+ from dial_autogen import dial_tools
396
+
397
+ client = DialClient(DialConfig(api_key="sk_live_..."))
398
+ agent = AssistantAgent(name="phone_agent", model_client=mc, tools=dial_tools(client))
399
+ ...
400
+ await client.close()
401
+ """
402
+ return [
403
+ list_numbers_tool(client),
404
+ purchase_number_tool(client),
405
+ set_number_properties_tool(client),
406
+ list_messages_tool(client),
407
+ send_message_tool(client),
408
+ list_calls_tool(client),
409
+ make_call_tool(client),
410
+ get_call_tool(client),
411
+ get_billing_tool(client),
412
+ wait_for_message_tool(client),
413
+ ]