Qizhou Guo commited on
Commit
dba1be0
·
1 Parent(s): df42c10

fix(encoding): preserve tool namespaces in prompts and completions

Browse files
encoding/README.md CHANGED
@@ -225,6 +225,38 @@ Tool execution results are wrapped in `<tool_result>` tags within user messages:
225
  <|User|><tool_result>{result_json}</tool_result><|Assistant|><think>...
226
  ```
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  ### Reasoning effort
229
 
230
  Pass `reasoning_effort` as an integer in `[1, 100]` or as one of `"low"` (50),
 
225
  <|User|><tool_result>{result_json}</tool_result><|Assistant|><think>...
226
  ```
227
 
228
+ ### Tool namespaces
229
+
230
+ Tool definitions may include a `namespace` alongside `function`, either as a
231
+ string or as an object with `name` and an optional `description`:
232
+
233
+ ```python
234
+ tool = {
235
+ "type": "function",
236
+ "namespace": {"name": "search", "description": "Search tools."},
237
+ "function": {
238
+ "name": "lookup",
239
+ "description": "Look up a value",
240
+ "parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
241
+ },
242
+ }
243
+ tool_call = {
244
+ "type": "function",
245
+ "namespace": "search",
246
+ "function": {"name": "lookup", "arguments": '{"query": "value"}'},
247
+ }
248
+ ```
249
+
250
+ The tool schema and DSML invocation both use `search::lookup`. The namespace
251
+ description is prepended to the tool description, separated by a newline.
252
+ The parser returns `function.name="lookup"` and `namespace="search"` on the
253
+ tool call, so its output can be passed back to `encode_messages()` directly.
254
+
255
+ Input also accepts `namespace` inside `function`, or a qualified function name
256
+ such as `search::lookup`. A qualified name must agree with any explicit
257
+ namespace; `::` separates exactly one namespace from the tool name. Tools
258
+ without a namespace retain their original names and output format.
259
+
260
  ### Reasoning effort
261
 
262
  Pass `reasoning_effort` as an integer in `[1, 100]` or as one of `"low"` (50),
encoding/encoding.py CHANGED
@@ -82,33 +82,75 @@ def to_json(value: Any) -> str:
82
 
83
 
84
  def tools_from_openai_format(tools):
85
- """Extract function definitions from OpenAI-format tool list."""
86
- return [tool["function"] for tool in tools]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
 
89
  def tool_calls_from_openai_format(tool_calls):
90
  """Convert OpenAI-format tool calls to internal format."""
91
- return [
92
- {
93
- "name": tool_call["function"]["name"],
94
- "arguments": tool_call["function"]["arguments"],
95
- }
96
- for tool_call in tool_calls
97
- ]
 
 
 
 
98
 
99
 
100
  def tool_calls_to_openai_format(tool_calls):
101
  """Convert internal tool calls to OpenAI format."""
102
- return [
103
- {
 
104
  "type": "function",
105
  "function": {
106
  "name": tool_call["name"],
107
  "arguments": tool_call["arguments"],
108
  }
109
  }
110
- for tool_call in tool_calls
111
- ]
 
 
112
 
113
 
114
  def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str]]) -> Dict[str, str]:
@@ -120,7 +162,7 @@ def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str
120
  tool_args: Dict mapping param_name -> (value, is_string_flag).
121
 
122
  Returns:
123
- Dict with "name" and "arguments" (JSON string) keys.
124
  """
125
  def _decode_value(key: str, value: str, string: str):
126
  if string == "true":
@@ -128,7 +170,11 @@ def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str
128
  return f"{to_json(key)}: {value}"
129
 
130
  tool_args_json = "{" + ", ".join([_decode_value(k, v, string=is_str) for k, (v, is_str) in tool_args.items()]) + "}"
131
- return dict(name=tool_name, arguments=tool_args_json)
 
 
 
 
132
 
133
 
134
  # ============================================================
@@ -614,7 +660,7 @@ def render_message(
614
  tool_call_template.format(
615
  dsml_token=dsml_token,
616
  tool_call_tag_name=tool_call_tag_name,
617
- name=tc.get("name"),
618
  arguments=encode_arguments_to_dsml(tc)
619
  )
620
  for tc in tool_calls
 
82
 
83
 
84
  def tools_from_openai_format(tools):
85
+ """Extract function definitions with namespace-qualified names."""
86
+ functions = []
87
+ for tool in tools:
88
+ function = dict(tool["function"])
89
+ if tool.get("namespace") is not None:
90
+ function["namespace"] = tool["namespace"]
91
+ function["name"] = _tool_name_for_encoding(function)
92
+ namespace = function.pop("namespace", None)
93
+ if isinstance(namespace, dict) and namespace.get("description"):
94
+ function["description"] = (
95
+ namespace["description"] + "\n" + (function.get("description") or "")
96
+ )
97
+ functions.append(function)
98
+ return functions
99
+
100
+
101
+ def _split_tool_name(name: str, namespace: Optional[str] = None) -> Tuple[Optional[str], str]:
102
+ """Split a qualified name and validate any explicit namespace."""
103
+ prefix, separator, bare_name = name.partition("::")
104
+ if separator:
105
+ assert namespace in (None, prefix), (
106
+ f"Conflicting tool namespaces: {namespace} != {prefix}"
107
+ )
108
+ namespace, name = prefix, bare_name
109
+ assert "::" not in name, f"Tool name must not contain '::': {name}"
110
+ assert namespace is None or "::" not in namespace, (
111
+ f"Tool namespace must not contain '::': {namespace}"
112
+ )
113
+ return namespace, name
114
+
115
+
116
+ def _tool_name_for_encoding(tool: Dict[str, Any]) -> str:
117
+ namespace = tool.get("namespace")
118
+ if isinstance(namespace, dict):
119
+ namespace = namespace["name"]
120
+ namespace, name = _split_tool_name(tool["name"], namespace)
121
+ return name if namespace is None else f"{namespace}::{name}"
122
 
123
 
124
  def tool_calls_from_openai_format(tool_calls):
125
  """Convert OpenAI-format tool calls to internal format."""
126
+ calls = []
127
+ for tool_call in tool_calls:
128
+ function = tool_call["function"]
129
+ namespace, name = _split_tool_name(
130
+ function["name"], tool_call.get("namespace") or function.get("namespace")
131
+ )
132
+ call = {"name": name, "arguments": function["arguments"]}
133
+ if namespace is not None:
134
+ call["namespace"] = namespace
135
+ calls.append(call)
136
+ return calls
137
 
138
 
139
  def tool_calls_to_openai_format(tool_calls):
140
  """Convert internal tool calls to OpenAI format."""
141
+ calls = []
142
+ for tool_call in tool_calls:
143
+ call = {
144
  "type": "function",
145
  "function": {
146
  "name": tool_call["name"],
147
  "arguments": tool_call["arguments"],
148
  }
149
  }
150
+ if tool_call.get("namespace") is not None:
151
+ call["namespace"] = tool_call["namespace"]
152
+ calls.append(call)
153
+ return calls
154
 
155
 
156
  def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str]]) -> Dict[str, str]:
 
162
  tool_args: Dict mapping param_name -> (value, is_string_flag).
163
 
164
  Returns:
165
+ Dict with "name", "arguments" (JSON string), and optional "namespace".
166
  """
167
  def _decode_value(key: str, value: str, string: str):
168
  if string == "true":
 
170
  return f"{to_json(key)}: {value}"
171
 
172
  tool_args_json = "{" + ", ".join([_decode_value(k, v, string=is_str) for k, (v, is_str) in tool_args.items()]) + "}"
173
+ namespace, name = _split_tool_name(tool_name)
174
+ tool_call = dict(name=name, arguments=tool_args_json)
175
+ if namespace is not None:
176
+ tool_call["namespace"] = namespace
177
+ return tool_call
178
 
179
 
180
  # ============================================================
 
660
  tool_call_template.format(
661
  dsml_token=dsml_token,
662
  tool_call_tag_name=tool_call_tag_name,
663
+ name=_tool_name_for_encoding(tc),
664
  arguments=encode_arguments_to_dsml(tc)
665
  )
666
  for tc in tool_calls
encoding/test_encoding.py CHANGED
@@ -5,6 +5,7 @@ Adapted from dsv41-master/deepseek_harmony/tests/test_deepseek_v41.py for the
5
  self-contained dict-based API in this repo.
6
  """
7
 
 
8
  import json
9
  from pathlib import Path
10
  from typing import Any
@@ -299,6 +300,134 @@ def test_v41_parse_rejects_unspaced_v4_dsml() -> None:
299
  parse_message_from_completion_text(v4_output, thinking_mode="thinking")
300
 
301
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  # ============================================================
303
  # Multi-turn flow
304
  # ============================================================
 
5
  self-contained dict-based API in this repo.
6
  """
7
 
8
+ import copy
9
  import json
10
  from pathlib import Path
11
  from typing import Any
 
300
  parse_message_from_completion_text(v4_output, thinking_mode="thinking")
301
 
302
 
303
+ # ============================================================
304
+ # Tool namespaces
305
+ # ============================================================
306
+
307
+ @pytest.mark.parametrize("location", ["tool", "function"])
308
+ @pytest.mark.parametrize("namespace", ["search", {"name": "search", "description": "Search tools."}])
309
+ def test_v41_renders_namespaced_tool_schemas(location: str, namespace: Any) -> None:
310
+ tool = make_tool()
311
+ target = tool if location == "tool" else tool["function"]
312
+ target["namespace"] = namespace
313
+ original = copy.deepcopy(tool)
314
+
315
+ prompt = encode_messages(
316
+ [{"role": "system", "content": "system", "tools": [tool]}],
317
+ thinking_mode="chat",
318
+ )
319
+
320
+ schema = dict(make_tool()["function"], name="search::lookup")
321
+ if isinstance(namespace, dict):
322
+ schema["description"] = "Search tools.\nLook up a value"
323
+ assert json.dumps(schema) in prompt
324
+ assert '"namespace":' not in prompt
325
+ assert tool == original
326
+
327
+
328
+ @pytest.mark.parametrize("thinking_mode", ["chat", "thinking"])
329
+ @pytest.mark.parametrize("location", ["tool", "function", "qualified_name"])
330
+ def test_v41_namespaced_tool_calls_roundtrip(thinking_mode: str, location: str) -> None:
331
+ messages = make_tool_call_messages()
332
+ call = messages[1]["tool_calls"][0]
333
+ if location == "qualified_name":
334
+ call["function"]["name"] = "search::lookup"
335
+ else:
336
+ target = call if location == "tool" else call["function"]
337
+ target["namespace"] = "search"
338
+ original = copy.deepcopy(messages)
339
+
340
+ expected = V41_TOOL_CALL_OUTPUT.replace('name="lookup"', 'name="search::lookup"')
341
+ if thinking_mode == "chat":
342
+ expected = expected.split("</think>", 1)[1]
343
+ assert render_message(1, messages, thinking_mode=thinking_mode) == expected
344
+
345
+ parsed = parse_message_from_completion_text(expected, thinking_mode=thinking_mode)
346
+ assert parsed["tool_calls"] == [{
347
+ "type": "function",
348
+ "namespace": "search",
349
+ "function": {
350
+ "name": "lookup",
351
+ "arguments": '{"query": "value", "limit": 2}',
352
+ },
353
+ }]
354
+ assert encode_messages(
355
+ [parsed], thinking_mode=thinking_mode, context=messages[:1]
356
+ ) == expected
357
+ assert messages == original
358
+
359
+
360
+ def test_v41_keeps_same_named_tools_in_separate_namespaces() -> None:
361
+ tools, calls = [], []
362
+ for namespace in (None, "search", "files"):
363
+ tool = make_tool()
364
+ call = {
365
+ "type": "function",
366
+ "function": {"name": "lookup", "arguments": '{"query":"value"}'},
367
+ }
368
+ if namespace is not None:
369
+ tool["namespace"] = {"name": namespace}
370
+ call["namespace"] = namespace
371
+ tools.append(tool)
372
+ calls.append(call)
373
+
374
+ messages = [
375
+ {"role": "system", "content": "system", "tools": tools},
376
+ {"role": "user", "content": "question"},
377
+ {"role": "assistant", "content": "summary", "tool_calls": calls},
378
+ ]
379
+ prompt = encode_messages(messages, thinking_mode="chat")
380
+ for name in ("lookup", "search::lookup", "files::lookup"):
381
+ assert f'"name": "{name}"' in prompt
382
+ assert f'<|DSML| invoke name="{name}">' in prompt
383
+
384
+ completion = render_message(2, messages, thinking_mode="chat")
385
+ parsed = parse_message_from_completion_text(completion, thinking_mode="chat")
386
+ assert "namespace" not in parsed["tool_calls"][0]
387
+ assert [call.get("namespace") for call in parsed["tool_calls"]] == [None, "search", "files"]
388
+ assert all(call["function"]["name"] == "lookup" for call in parsed["tool_calls"])
389
+
390
+
391
+ def test_v41_does_not_duplicate_a_qualified_namespace() -> None:
392
+ tool = make_tool()
393
+ tool["function"]["name"] = "search::lookup"
394
+ tool["namespace"] = {"name": "search", "description": "Search tools."}
395
+ schema = enc.tools_from_openai_format([tool])[0]
396
+ assert schema["name"] == "search::lookup"
397
+ assert schema["description"] == "Search tools.\nLook up a value"
398
+
399
+ messages = make_tool_call_messages()
400
+ call = messages[1]["tool_calls"][0]
401
+ call["function"]["name"] = "search::lookup"
402
+ call["namespace"] = "search"
403
+ assert render_message(1, messages, thinking_mode="thinking") == (
404
+ V41_TOOL_CALL_OUTPUT.replace('name="lookup"', 'name="search::lookup"')
405
+ )
406
+
407
+
408
+ @pytest.mark.parametrize(
409
+ ("name", "namespace", "error"),
410
+ [
411
+ ("search::lookup", "files", "Conflicting tool namespaces"),
412
+ ("search::nested::lookup", None, "Tool name must not contain"),
413
+ ("lookup", "search::nested", "Tool namespace must not contain"),
414
+ ],
415
+ )
416
+ def test_v41_rejects_ambiguous_tool_namespaces(name: str, namespace: Any, error: str) -> None:
417
+ tool = make_tool()
418
+ tool["function"]["name"] = name
419
+ tool["namespace"] = namespace
420
+ with pytest.raises(AssertionError, match=error):
421
+ enc.tools_from_openai_format([tool])
422
+
423
+ messages = make_tool_call_messages()
424
+ call = messages[1]["tool_calls"][0]
425
+ call["function"]["name"] = name
426
+ call["namespace"] = namespace
427
+ with pytest.raises(AssertionError, match=error):
428
+ render_message(1, messages, thinking_mode="thinking")
429
+
430
+
431
  # ============================================================
432
  # Multi-turn flow
433
  # ============================================================