dial-agentframework 0.14.0__tar.gz → 0.16.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.
@@ -1,10 +1,10 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: dial-agentframework
3
- Version: 0.14.0
3
+ Version: 0.16.0
4
4
  Summary: Official Dial tools for the Microsoft Agent Framework — phone numbers, SMS, OTP, and voice calls
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: agent-framework-core>=1.0
7
- Requires-Dist: dial-sdk>=0.16.0
7
+ Requires-Dist: dial-sdk>=0.22.0
8
8
  Description-Content-Type: text/markdown
9
9
 
10
10
  # dial-agentframework
@@ -4,6 +4,7 @@ from .tools import (
4
4
  purchase_number_tool,
5
5
  set_number_properties_tool,
6
6
  list_messages_tool,
7
+ list_groups_tool,
7
8
  send_message_tool,
8
9
  reply_to_message_tool,
9
10
  start_typing_tool,
@@ -21,6 +22,7 @@ __all__ = [
21
22
  "purchase_number_tool",
22
23
  "set_number_properties_tool",
23
24
  "list_messages_tool",
25
+ "list_groups_tool",
24
26
  "send_message_tool",
25
27
  "reply_to_message_tool",
26
28
  "start_typing_tool",
@@ -76,6 +76,12 @@ def purchase_number_tool(client: DialClient) -> FunctionTool:
76
76
  "Voice gender for inbound calls to the new number: 'male' or 'female'. "
77
77
  "Omit to use the default female voice.",
78
78
  ] = None,
79
+ inbound_language: Annotated[
80
+ Optional[str],
81
+ "BCP-47 language tag (e.g. 'es-ES') pinning inbound calls to this number to "
82
+ "one language. Omit to detect the language from the caller's country prefix "
83
+ "on each call.",
84
+ ] = None,
79
85
  area_code: Annotated[
80
86
  Optional[str],
81
87
  "Preferred US area code to provision in, e.g. '415'. Omit for any available "
@@ -94,6 +100,7 @@ def purchase_number_tool(client: DialClient) -> FunctionTool:
94
100
  explicit_programmatic_consent=explicit_programmatic_consent,
95
101
  area_code=area_code,
96
102
  inbound_voice_gender=inbound_voice_gender,
103
+ inbound_language=inbound_language,
97
104
  include_imessage=include_imessage,
98
105
  )
99
106
  )
@@ -108,7 +115,8 @@ def set_number_properties_tool(client: DialClient) -> FunctionTool:
108
115
  description=(
109
116
  "Update a Dial phone number's properties: its inbound instruction (the system "
110
117
  "prompt its AI voice agent uses on inbound calls) and/or its nickname. "
111
- "Provide at least one."
118
+ "On iMessage numbers you can also set the display identity (first/last name "
119
+ "and avatar photo) shown to recipients. Provide at least one."
112
120
  ),
113
121
  )
114
122
  async def set_number_properties(
@@ -121,6 +129,12 @@ def set_number_properties_tool(client: DialClient) -> FunctionTool:
121
129
  "Voice gender for inbound calls to this number: 'male' or 'female'. "
122
130
  "Pass an empty string to clear it (reverts to the default, female).",
123
131
  ] = None,
132
+ inbound_language: Annotated[
133
+ Optional[str],
134
+ "BCP-47 language tag (e.g. 'es-ES') pinning inbound calls to this number to "
135
+ "one language. Pass an empty string to clear it (reverts to detecting the "
136
+ "language from the caller's country prefix on each call).",
137
+ ] = None,
124
138
  nickname: Annotated[
125
139
  Optional[str],
126
140
  "Human-readable label for the number, e.g. 'Support line'. "
@@ -132,6 +146,24 @@ def set_number_properties_tool(client: DialClient) -> FunctionTool:
132
146
  "ceiling to both inbound and outbound calls. The effective cap on a call is "
133
147
  "the smallest of the per-number, account, and per-call limits.",
134
148
  ] = None,
149
+ first_name: Annotated[
150
+ Optional[str],
151
+ "First name of the iMessage display identity — the name shown beside this "
152
+ "number's messages in recipients' Messages apps. iMessage numbers only "
153
+ "(400 otherwise). Max 30 chars; pass an empty string to clear it.",
154
+ ] = None,
155
+ last_name: Annotated[
156
+ Optional[str],
157
+ "Last name of the iMessage display identity, shown beside first_name. "
158
+ "iMessage numbers only (400 otherwise). Max 30 chars; pass an empty string "
159
+ "to clear it.",
160
+ ] = None,
161
+ avatar_url: Annotated[
162
+ Optional[str],
163
+ "Public URL of a jpeg/png/gif/webp image (≤5 MB) the server downloads to use "
164
+ "as the number's iMessage display photo. iMessage numbers only (400 "
165
+ "otherwise). The photo can be replaced but not removed.",
166
+ ] = None,
135
167
  ) -> str:
136
168
  kwargs: dict = {}
137
169
  if inbound_instruction is not None:
@@ -139,14 +171,27 @@ def set_number_properties_tool(client: DialClient) -> FunctionTool:
139
171
  # Empty string clears the override (reverts to the default, female); else set it.
140
172
  if inbound_voice_gender is not None:
141
173
  kwargs["inbound_voice_gender"] = inbound_voice_gender or None
174
+ # Empty string clears the pin (reverts to per-call detection from the caller's country prefix); else set it.
175
+ if inbound_language is not None:
176
+ kwargs["inbound_language"] = inbound_language or None
142
177
  if nickname is not None:
143
178
  kwargs["nickname"] = nickname
144
179
  if max_call_duration_seconds is not None:
145
180
  kwargs["max_call_duration_seconds"] = max_call_duration_seconds
181
+ # Only forward names the caller actually provided — the client leaves them
182
+ # untouched when unset, but forwarding None would clear the existing name.
183
+ # An empty string is forwarded intentionally to clear the name.
184
+ if first_name is not None:
185
+ kwargs["first_name"] = first_name
186
+ if last_name is not None:
187
+ kwargs["last_name"] = last_name
188
+ if avatar_url is not None:
189
+ kwargs["avatar"] = avatar_url
146
190
  if not kwargs:
147
191
  return (
148
192
  "Provide at least one of inbound_instruction, inbound_voice_gender, "
149
- "nickname, or max_call_duration_seconds."
193
+ "inbound_language, nickname, max_call_duration_seconds, first_name, "
194
+ "last_name, or avatar_url."
150
195
  )
151
196
  number = await client.set_number_properties(number_id, **kwargs)
152
197
  nick = f' | nickname: "{number.nickname}"' if number.nickname else ""
@@ -161,7 +206,10 @@ def set_number_properties_tool(client: DialClient) -> FunctionTool:
161
206
  def list_messages_tool(client: DialClient) -> FunctionTool:
162
207
  @tool(
163
208
  name="list_messages",
164
- description="List recent inbound and outbound messages on the account.",
209
+ description=(
210
+ "List recent inbound and outbound messages on the account. Pass group_id "
211
+ "to read one group conversation."
212
+ ),
165
213
  )
166
214
  async def list_messages(
167
215
  number_id: Annotated[
@@ -170,28 +218,73 @@ def list_messages_tool(client: DialClient) -> FunctionTool:
170
218
  direction: Annotated[
171
219
  Optional[str], "Filter by direction: 'inbound' or 'outbound'"
172
220
  ] = None,
221
+ group_id: Annotated[
222
+ Optional[str],
223
+ "Restrict to one group conversation (from list_groups). Combines with the "
224
+ "other filters.",
225
+ ] = None,
173
226
  ) -> str:
174
- messages = await client.list_messages(number_id=number_id, direction=direction)
227
+ messages = await client.list_messages(
228
+ number_id=number_id, direction=direction, group_id=group_id
229
+ )
175
230
  if not messages:
176
231
  return "No messages found."
232
+ # A group message has no `to` — the destination is the group — so name the
233
+ # group rather than rendering "None" at the recipient.
177
234
  return "\n".join(
178
- f"[{m.direction}] {m.from_} → {m.to}: {m.body[:50]}" for m in messages
235
+ f"[{m.direction}] {m.from_} → {m.to or f'group {m.group_id}'}: {m.body[:50]}"
236
+ for m in messages
179
237
  )
180
238
 
181
239
  return list_messages
182
240
 
183
241
 
242
+ def list_groups_tool(client: DialClient) -> FunctionTool:
243
+ @tool(
244
+ name="list_groups",
245
+ description=(
246
+ "List the group conversations your lines are in. A group is a conversation "
247
+ "that isn't a phone number, so it has an id of its own: pass it as group_id "
248
+ "to send_message, or to list_messages to read that conversation. Groups "
249
+ "exist on WhatsApp lines. A group's name can be empty — it is read live "
250
+ "from the line holding the conversation, so a line that can't answer in "
251
+ "time yields no name rather than hiding the group. There is no join event: "
252
+ "list again to see a group your line was just added to."
253
+ ),
254
+ )
255
+ async def list_groups() -> str:
256
+ groups = await client.list_groups()
257
+ if not groups:
258
+ return "No groups found."
259
+ return "\n".join(
260
+ f"{g.id}: {g.name or '(unnamed)'} (since {g.created_at})" for g in groups
261
+ )
262
+
263
+ return list_groups
264
+
265
+
184
266
  def send_message_tool(client: DialClient) -> FunctionTool:
185
267
  @tool(
186
268
  name="send_message",
187
269
  description=(
188
- "Send a message to a phone number, optionally with media attachments (MMS). "
189
- "On an iMessage number, a single audio attachment is delivered as a voice "
190
- "message unless force_audio_file is true."
270
+ "Send a message to a phone number or into a group conversation, optionally "
271
+ "with media attachments (MMS). On an iMessage number, a single audio "
272
+ "attachment is delivered as a voice message unless force_audio_file is "
273
+ "true. Address it with exactly one of to or group_id."
191
274
  ),
192
275
  )
193
276
  async def send_message(
194
- to: Annotated[str, "Destination phone number in E.164 format, e.g. +1234567890"],
277
+ to: Annotated[
278
+ str,
279
+ "Destination phone number in E.164 format, e.g. +1234567890. Provide "
280
+ "exactly one of to or group_id.",
281
+ ] = "",
282
+ group_id: Annotated[
283
+ Optional[str],
284
+ "A group conversation to send into (from list_groups), instead of a `to` "
285
+ "number. The sending line comes from the group, so no from-number is "
286
+ "needed. Provide exactly one of to or group_id.",
287
+ ] = None,
195
288
  from_number: Annotated[
196
289
  Optional[str],
197
290
  "Number to send from: a phone number ID, one of your numbers in E.164, or "
@@ -205,7 +298,12 @@ def send_message_tool(client: DialClient) -> FunctionTool:
205
298
  body: Annotated[
206
299
  str, "Message body text; optional when media_urls is given (media-only send)"
207
300
  ] = "",
208
- channel: Annotated[str, "Channel to use: 'sms'"] = "sms",
301
+ channel: Annotated[
302
+ Optional[str],
303
+ "Which channel to send on, for a line that carries more than one: "
304
+ "'imessage' or 'whatsapp'. Omit to use the number's own default. "
305
+ "WhatsApp sends are text-only.",
306
+ ] = None,
209
307
  media_urls: Annotated[
210
308
  Optional[list[str]],
211
309
  "Publicly reachable http(s) URLs of media to attach (MMS), max 10. "
@@ -220,6 +318,7 @@ def send_message_tool(client: DialClient) -> FunctionTool:
220
318
  msg = await client.send_message(
221
319
  SendMessageParams(
222
320
  to=to,
321
+ group_id=group_id,
223
322
  from_number=from_number,
224
323
  from_number_id=from_number_id,
225
324
  body=body,
@@ -294,8 +393,16 @@ def start_typing_tool(client: DialClient) -> FunctionTool:
294
393
  "Number the indicator appears from: a phone number ID, one of your numbers "
295
394
  "in E.164, or a nickname",
296
395
  ],
396
+ channel: Annotated[
397
+ Optional[str],
398
+ "Which channel to show it on, for a line that carries more than one: "
399
+ "'imessage' or 'whatsapp'. Omit to use the number's own default. Typing "
400
+ "inside a group is not supported.",
401
+ ] = None,
297
402
  ) -> str:
298
- await client.start_typing(to_number=to_number, from_number=from_number)
403
+ await client.start_typing(
404
+ to_number=to_number, from_number=from_number, channel=channel
405
+ )
299
406
  return "Typing indicator shown (iMessage numbers only — SMS numbers ignore it)."
300
407
 
301
408
  return start_typing
@@ -319,8 +426,14 @@ def stop_typing_tool(client: DialClient) -> FunctionTool:
319
426
  "Number the indicator appears from: a phone number ID, one of your numbers "
320
427
  "in E.164, or a nickname",
321
428
  ],
429
+ channel: Annotated[
430
+ Optional[str],
431
+ "Which channel to clear it on. Pass the same channel start_typing was given.",
432
+ ] = None,
322
433
  ) -> str:
323
- await client.stop_typing(to_number=to_number, from_number=from_number)
434
+ await client.stop_typing(
435
+ to_number=to_number, from_number=from_number, channel=channel
436
+ )
324
437
  return "Typing indicator cleared."
325
438
 
326
439
  return stop_typing
@@ -541,6 +654,7 @@ def dial_tools(client: DialClient) -> list[FunctionTool]:
541
654
  purchase_number_tool(client),
542
655
  set_number_properties_tool(client),
543
656
  list_messages_tool(client),
657
+ list_groups_tool(client),
544
658
  send_message_tool(client),
545
659
  reply_to_message_tool(client),
546
660
  start_typing_tool(client),
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dial-agentframework"
3
- version = "0.14.0"
3
+ version = "0.16.0"
4
4
  description = "Official Dial tools for the Microsoft Agent Framework — phone numbers, SMS, OTP, and voice calls"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -8,7 +8,7 @@ dependencies = [
8
8
  # Core has `tool` + `FunctionTool` (all this package needs). Chat clients ship in
9
9
  # separate integration packages (agent-framework-openai, …) the user installs.
10
10
  "agent-framework-core>=1.0",
11
- "dial-sdk>=0.16.0",
11
+ "dial-sdk>=0.22.0",
12
12
  ]
13
13
 
14
14
  [tool.uv.sources]
@@ -3,12 +3,14 @@ import asyncio
3
3
  from dial_agentframework import (
4
4
  dial_tools,
5
5
  purchase_number_tool,
6
+ set_number_properties_tool,
6
7
  send_message_tool,
7
8
  reply_to_message_tool,
8
9
  make_call_tool,
9
10
  start_typing_tool,
10
11
  stop_typing_tool,
11
12
  wait_for_message_tool,
13
+ list_groups_tool,
12
14
  )
13
15
 
14
16
 
@@ -30,6 +32,7 @@ class _FakeNumber:
30
32
  id = "pn_new"
31
33
  number = "+14155550000"
32
34
  country = "US"
35
+ nickname = None
33
36
 
34
37
 
35
38
  class FakeClient:
@@ -57,7 +60,7 @@ def _run(tool, **args) -> str:
57
60
  # ── Tools ────────────────────────────────────────────────────────────────────
58
61
 
59
62
 
60
- def test_dial_tools_bundles_all_thirteen():
63
+ def test_dial_tools_bundles_all_fourteen():
61
64
  names = sorted(t.name for t in dial_tools(FakeClient()))
62
65
  assert names == sorted(
63
66
  [
@@ -65,6 +68,7 @@ def test_dial_tools_bundles_all_thirteen():
65
68
  "purchase_number",
66
69
  "set_number_properties",
67
70
  "list_messages",
71
+ "list_groups",
68
72
  "send_message",
69
73
  "reply_to_message",
70
74
  "start_typing",
@@ -128,7 +132,48 @@ def test_start_typing_invokes_client():
128
132
  assert "Typing indicator shown" in out
129
133
  call = client.calls["start_typing"]
130
134
  assert call["args"] == ()
131
- assert call["kwargs"] == {"to_number": "+14155550123", "from_number": "pn_9"}
135
+ assert call["kwargs"] == {
136
+ "to_number": "+14155550123",
137
+ "from_number": "pn_9",
138
+ "channel": None,
139
+ }
140
+
141
+
142
+ def test_typing_forwards_an_explicit_channel():
143
+ client = FakeClient(start_typing=None)
144
+ _run(start_typing_tool(client), to_number="+14155550123", from_number="pn_9", channel="whatsapp")
145
+ assert client.calls["start_typing"]["kwargs"]["channel"] == "whatsapp"
146
+
147
+
148
+ def test_send_message_into_a_group():
149
+ client = FakeClient(send_message=_FakeMessage())
150
+ out = _run(send_message_tool(client), group_id="grp_1", body="hi")
151
+
152
+ assert "Message sent successfully" in out
153
+ params = client.calls["send_message"]["args"][0]
154
+ assert params.group_id == "grp_1"
155
+ # A group send names no recipient number: the group is the destination.
156
+ assert params.to == ""
157
+
158
+
159
+ def test_list_groups_renders_ids_names_and_an_unnamed_group():
160
+ class _FakeGroup:
161
+ def __init__(self, id, name, created_at):
162
+ self.id = id
163
+ self.name = name
164
+ self.created_at = created_at
165
+
166
+ client = FakeClient(
167
+ list_groups=[
168
+ _FakeGroup("grp_1", "Planning bday party", "2026-09-01T00:00:00Z"),
169
+ _FakeGroup("grp_2", None, "2026-09-02T00:00:00Z"),
170
+ ]
171
+ )
172
+ out = _run(list_groups_tool(client))
173
+
174
+ assert "grp_1: Planning bday party" in out
175
+ # A group no line could name is still listed — never hidden.
176
+ assert "grp_2: (unnamed)" in out
132
177
 
133
178
 
134
179
  def test_stop_typing_invokes_client():
@@ -143,6 +188,7 @@ def test_stop_typing_invokes_client():
143
188
  assert call["kwargs"] == {
144
189
  "to_number": "+14155550123",
145
190
  "from_number": "+14155559999",
191
+ "channel": None,
146
192
  }
147
193
 
148
194
 
@@ -195,10 +241,56 @@ def test_purchase_number_include_imessage():
195
241
  inbound_instruction="Greet the caller.",
196
242
  explicit_programmatic_consent="Account holder approved programmatic provisioning.",
197
243
  include_imessage=True,
244
+ inbound_language="es-ES",
198
245
  )
199
246
 
200
247
  params = client.calls["purchase_number"]["args"][0]
201
248
  assert params.include_imessage is True
249
+ assert params.inbound_language == "es-ES"
250
+
251
+
252
+ def test_set_number_properties_forwards_display_identity():
253
+ client = FakeClient(set_number_properties=_FakeNumber())
254
+ tool = set_number_properties_tool(client)
255
+ out = _run(
256
+ tool,
257
+ number_id="pn_9",
258
+ first_name="Ada",
259
+ last_name="Lovelace",
260
+ avatar_url="https://cdn.example.com/ada.png",
261
+ )
262
+
263
+ assert "pn_new" in out
264
+ call = client.calls["set_number_properties"]
265
+ assert call["args"] == ("pn_9",)
266
+ assert call["kwargs"]["first_name"] == "Ada"
267
+ assert call["kwargs"]["last_name"] == "Lovelace"
268
+ # avatar_url forwards to the client's `avatar` param (a URL string).
269
+ assert call["kwargs"]["avatar"] == "https://cdn.example.com/ada.png"
270
+
271
+
272
+ def test_set_number_properties_empty_name_clears():
273
+ # An explicit empty string is forwarded (the server clears the name); it must
274
+ # not be swallowed like an omitted argument.
275
+ client = FakeClient(set_number_properties=_FakeNumber())
276
+ tool = set_number_properties_tool(client)
277
+ _run(tool, number_id="pn_9", first_name="")
278
+
279
+ assert client.calls["set_number_properties"]["kwargs"]["first_name"] == ""
280
+
281
+
282
+ def test_set_number_properties_omits_unprovided_name_fields():
283
+ # Not passing first_name/last_name/avatar_url must NOT forward them — the client
284
+ # uses an _UNSET sentinel and forwarding None would clear the existing identity.
285
+ client = FakeClient(set_number_properties=_FakeNumber())
286
+ tool = set_number_properties_tool(client)
287
+ _run(tool, number_id="pn_9", nickname="Support")
288
+
289
+ kwargs = client.calls["set_number_properties"]["kwargs"]
290
+ assert "first_name" not in kwargs
291
+ assert "last_name" not in kwargs
292
+ assert "avatar" not in kwargs
293
+ assert kwargs["nickname"] == "Support"
202
294
 
203
295
 
204
296
  def test_make_call_passes_idempotency_key():
@@ -345,7 +345,7 @@ wheels = [
345
345
 
346
346
  [[package]]
347
347
  name = "dial-agentframework"
348
- version = "0.14.0"
348
+ version = "0.16.0"
349
349
  source = { editable = "." }
350
350
  dependencies = [
351
351
  { name = "agent-framework-core" },
@@ -368,7 +368,7 @@ dev = [{ name = "pytest", specifier = ">=8" }]
368
368
 
369
369
  [[package]]
370
370
  name = "dial-sdk"
371
- version = "0.16.0"
371
+ version = "0.22.0"
372
372
  source = { editable = "../sdk-python" }
373
373
  dependencies = [
374
374
  { name = "httpx" },