xdog-coding 2.0.1__tar.gz → 2.1.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.
Files changed (80) hide show
  1. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/PKG-INFO +5 -5
  2. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/pyproject.toml +4 -4
  3. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/agent_session.py +40 -8
  4. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/sdk.py +27 -1
  5. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/system_prompt.py +13 -1
  6. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_slash_skills.py +85 -5
  7. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/.gitignore +0 -0
  8. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/LICENSE +0 -0
  9. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/LICENSE-EXCEPTION.md +0 -0
  10. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/README.md +0 -0
  11. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/__init__.py +0 -0
  12. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/cli/__init__.py +0 -0
  13. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/cli/args.py +0 -0
  14. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/cli/config_selector.py +0 -0
  15. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/cli/file_processor.py +0 -0
  16. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/cli/initial_message.py +0 -0
  17. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/cli/list_models.py +0 -0
  18. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/cli/session_picker.py +0 -0
  19. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/config.py +0 -0
  20. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/__init__.py +0 -0
  21. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/bash_executor.py +0 -0
  22. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/compaction/__init__.py +0 -0
  23. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/compaction/compaction.py +0 -0
  24. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/compaction/utils.py +0 -0
  25. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/defaults.py +0 -0
  26. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/event_bus.py +0 -0
  27. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/exec_utils.py +0 -0
  28. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/extensions/__init__.py +0 -0
  29. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/extensions/loader.py +0 -0
  30. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/extensions/runner.py +0 -0
  31. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/extensions/types.py +0 -0
  32. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/keybindings.py +0 -0
  33. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/messages.py +0 -0
  34. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/prompt_templates.py +0 -0
  35. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/resource_loader.py +0 -0
  36. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/session_manager.py +0 -0
  37. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/settings_manager.py +0 -0
  38. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/slash_commands.py +0 -0
  39. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/timings.py +0 -0
  40. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/core/tools/__init__.py +0 -0
  41. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/main.py +0 -0
  42. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/__init__.py +0 -0
  43. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/__init__.py +0 -0
  44. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/components/__init__.py +0 -0
  45. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/components/assistant_message.py +0 -0
  46. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/components/chat_log.py +0 -0
  47. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/components/custom_editor.py +0 -0
  48. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/components/footer.py +0 -0
  49. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/components/tool_execution.py +0 -0
  50. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/components/user_message.py +0 -0
  51. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/interactive_mode.py +0 -0
  52. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/interactive/theme.py +0 -0
  53. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/print_mode.py +0 -0
  54. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/rpc/__init__.py +0 -0
  55. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/modes/rpc/rpc_mode.py +0 -0
  56. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/py.typed +0 -0
  57. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/utils/__init__.py +0 -0
  58. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/utils/frontmatter.py +0 -0
  59. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/utils/git.py +0 -0
  60. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/utils/mime.py +0 -0
  61. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/utils/shell.py +0 -0
  62. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/src/xdog/coding/utils/sleep.py +0 -0
  63. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_agent_session.py +0 -0
  64. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_cli_args.py +0 -0
  65. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_cli_config_selector.py +0 -0
  66. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_cli_file_processor.py +0 -0
  67. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_coding_config.py +0 -0
  68. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_event_bus.py +0 -0
  69. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_footer_context.py +0 -0
  70. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_interactive_mode.py +0 -0
  71. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_messages.py +0 -0
  72. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_prompt_templates.py +0 -0
  73. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_resource_loader.py +0 -0
  74. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_rpc_mode.py +0 -0
  75. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_session_manager.py +0 -0
  76. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_session_smoke.py +0 -0
  77. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_settings_manager.py +0 -0
  78. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_slash_commands.py +0 -0
  79. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_system_prompt.py +0 -0
  80. {xdog_coding-2.0.1 → xdog_coding-2.1.0}/tests/test_timings.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: xdog-coding
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: Interactive coding agent CLI
5
5
  Project-URL: Homepage, https://xdog.942295.xyz
6
6
  Project-URL: Repository, https://github.com/litterzhang/x-dog
@@ -21,9 +21,9 @@ Requires-Python: >=3.12
21
21
  Requires-Dist: click>=8.1.0
22
22
  Requires-Dist: pydantic>=2.0
23
23
  Requires-Dist: pyyaml>=6.0
24
- Requires-Dist: xdog-agent
25
- Requires-Dist: xdog-ai
26
- Requires-Dist: xdog-tui
24
+ Requires-Dist: xdog-agent>=2.1.0
25
+ Requires-Dist: xdog-ai>=2.1.0
26
+ Requires-Dist: xdog-tui>=2.1.0
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # xdog-coding
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xdog-coding"
7
- version = "2.0.1"
7
+ version = "2.1.0"
8
8
  description = "Interactive coding agent CLI"
9
9
  requires-python = ">=3.12"
10
10
  classifiers = [
@@ -21,9 +21,9 @@ readme = "README.md"
21
21
  license = "AGPL-3.0-or-later"
22
22
  license-files = ["LICENSE", "LICENSE-EXCEPTION.md"]
23
23
  dependencies = [
24
- "xdog-ai",
25
- "xdog-agent",
26
- "xdog-tui",
24
+ "xdog-ai>=2.1.0",
25
+ "xdog-agent>=2.1.0",
26
+ "xdog-tui>=2.1.0",
27
27
  "pyyaml>=6.0",
28
28
  "click>=8.1.0",
29
29
  "pydantic>=2.0",
@@ -94,10 +94,14 @@ class AgentSession:
94
94
  what the format's reference clients do, and it is a one-way door: a
95
95
  message cannot be taken back out of a conversation, so the body keeps
96
96
  occupying the window for the rest of the session whether or not it is
97
- still relevant. The system prompt is rebuilt before every turn, so
98
- putting it there makes deactivation possible at all.
97
+ still relevant. Keeping it in the system prompt is what makes
98
+ deactivation possible at all.
99
+
100
+ `Agent.set_skills` does the placing, so that reasoning now lives in one
101
+ place instead of being re-derived by every product that has skills.
99
102
  """
100
103
  self._active_skills = self._active_skills | {slug}
104
+ self._push_skills()
101
105
  self._rebuild_system_prompt()
102
106
 
103
107
  def deactivate_skill(self, slug: str) -> bool:
@@ -105,6 +109,7 @@ class AgentSession:
105
109
  if slug not in self._active_skills:
106
110
  return False
107
111
  self._active_skills = self._active_skills - {slug}
112
+ self._push_skills()
108
113
  self._rebuild_system_prompt()
109
114
  return True
110
115
 
@@ -159,6 +164,7 @@ class AgentSession:
159
164
 
160
165
  if expired:
161
166
  self._active_skills = self._active_skills - expired
167
+ self._push_skills()
162
168
  self._rebuild_system_prompt()
163
169
  logger.debug("expired turn-scoped skills: %s", sorted(expired))
164
170
 
@@ -287,6 +293,33 @@ class AgentSession:
287
293
 
288
294
  # -- Internal --
289
295
 
296
+ def _push_skills(self) -> None:
297
+ """Tell the Agent which skills are active; it decides where they go.
298
+
299
+ The index and the bodies are both its business now — this only says
300
+ *which*, which is the part a product legitimately differs on.
301
+ """
302
+ try:
303
+ self.agent.set_active_skills(sorted(self._active_skills))
304
+ except Exception:
305
+ logger.debug("could not push active skills to the agent", exc_info=True)
306
+
307
+ def _native_tool_calls(self) -> bool:
308
+ """Whether this model gets tool definitions through the API.
309
+
310
+ Reads the same answer the Agent uses to decide whether to send them, so
311
+ the two cannot disagree — a model whose tools are withheld from the API
312
+ while the prompt stays silent about them has no tools at all, and
313
+ nothing would report that.
314
+
315
+ Unknown answers False, so the tools stay described. Both halves then
316
+ fire, which wastes a few hundred tokens and cannot leave the model
317
+ unable to act.
318
+ """
319
+ from xdog.agent.helpers import model_supports_tool_calls
320
+
321
+ return model_supports_tool_calls(self.agent.state.model or "") is True
322
+
290
323
  def _rebuild_system_prompt(self) -> None:
291
324
  """Rebuild and set the system prompt from config and tools."""
292
325
  from xdog.coding.config import PlatformInfo, RuntimeConfig
@@ -310,6 +343,7 @@ class AgentSession:
310
343
  ),
311
344
  tool_defs,
312
345
  extra_context=_skills_context(self._active_skills),
346
+ native_tool_calls=self._native_tool_calls(),
313
347
  )
314
348
  self.agent.set_system_prompt(prompt)
315
349
 
@@ -356,16 +390,14 @@ def _skills_context(active: frozenset[str] = frozenset()) -> str:
356
390
  read is not a reason to start without a system prompt.
357
391
  """
358
392
  try:
359
- from xdog.agent.skills import render_skill_body
360
393
  from xdog.coding.core.slash_commands import skill_manager
361
394
 
362
395
  manager = skill_manager()
363
396
  summary = manager.skills_summary()
364
- bodies = []
365
- for slug in sorted(active):
366
- skill = manager.load_skill(slug)
367
- if skill is not None:
368
- bodies.append(f"## Active skill: {skill.name}\n\n{render_skill_body(skill)}")
397
+ # Bodies are the Agent's to place (`set_skills`); this section carries
398
+ # only the standing note about them. Rendering them here as well was how
399
+ # three packages ended up with three answers to the same question.
400
+ bodies = ["(active: " + ", ".join(sorted(active)) + ")"] if active else []
369
401
  except Exception:
370
402
  logger.debug("could not build the skills section of the system prompt", exc_info=True)
371
403
  return ""
@@ -7,6 +7,11 @@ and session management.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from xdog.agent.skills import SkillManager
14
+
10
15
  import logging
11
16
  from dataclasses import dataclass
12
17
  from pathlib import Path
@@ -149,19 +154,23 @@ def create_agent_session(options: CreateSessionOptions | None = None) -> CreateS
149
154
 
150
155
  # Create Agent with explicit stream_fn from provider
151
156
  from xdog.agent import AgentConfig
152
- from xdog.agent.helpers import stream_fn_from_provider
157
+ from xdog.agent.helpers import model_supports_tool_calls, stream_fn_from_provider
153
158
  from xdog.ai.types import StreamOptions
154
159
 
155
160
  agent = Agent(
156
161
  stream_fn_from_provider(provider),
157
162
  config=AgentConfig(
158
163
  model=model_id,
164
+ supports_tool_calls=model_supports_tool_calls(model_id),
159
165
  system_prompt="", # will be rebuilt on first prompt
160
166
  options=StreamOptions(
161
167
  thinking=_as_thinking_level(thinking_level),
162
168
  ),
163
169
  ),
164
170
  tools=agent_tools,
171
+ # The Agent owns where the index and the bodies go; this only says which
172
+ # manager to read them from.
173
+ skills=_coding_skill_manager(),
165
174
  )
166
175
 
167
176
  # Restore messages if resuming
@@ -183,3 +192,20 @@ def create_agent_session(options: CreateSessionOptions | None = None) -> CreateS
183
192
  session=session,
184
193
  model_fallback_message=model_fallback_message,
185
194
  )
195
+
196
+
197
+ def _coding_skill_manager() -> "SkillManager | None":
198
+ """coding's SkillManager, or None if it cannot be built.
199
+
200
+ Skills are a convenience; failing to read them must not stop a session from
201
+ starting, which is why this swallows rather than raises.
202
+ """
203
+ try:
204
+ from xdog.coding.core.slash_commands import skill_manager
205
+
206
+ return skill_manager()
207
+ except Exception: # pragma: no cover - defensive
208
+ import logging
209
+
210
+ logging.getLogger(__name__).debug("no skill manager available", exc_info=True)
211
+ return None
@@ -81,6 +81,7 @@ def build_system_prompt(
81
81
  *,
82
82
  file_entries: list[dict[str, Any]] | None = None,
83
83
  extra_context: str = "",
84
+ native_tool_calls: bool = False,
84
85
  ) -> str:
85
86
  """Assemble the complete system prompt.
86
87
 
@@ -94,12 +95,23 @@ def build_system_prompt(
94
95
  Optional processed file context entries.
95
96
  extra_context:
96
97
  Optional extra text appended at the end.
98
+ native_tool_calls:
99
+ True when the model receives tool definitions through the API, in which
100
+ case describing them again in the prompt sends every name, description
101
+ and parameter schema twice.
102
+
103
+ Defaults to False — *describe them* — because the two mistakes are not
104
+ symmetric. Describing tools to a model that already has them wastes
105
+ tokens in a cached prefix; omitting them for a model that has no other
106
+ way to see them leaves it unable to act, and that failure looks like the
107
+ model being useless rather than like a missing section.
97
108
  """
98
109
  sections: list[str] = [
99
110
  SYSTEM_PROMPT_HEADER,
100
111
  build_environment_section(config),
101
- build_tool_section(tools),
102
112
  ]
113
+ if not native_tool_calls:
114
+ sections.append(build_tool_section(tools))
103
115
 
104
116
  if config.custom_instructions:
105
117
  sections.append(
@@ -243,7 +243,15 @@ def test_parse_slash_command_splits_name_from_arguments() -> None:
243
243
  def test_the_system_prompt_gains_and_loses_the_body(
244
244
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
245
245
  ) -> None:
246
- """Deactivation has to leave no trace, or `/unload` is theatre."""
246
+ """Deactivation has to leave no trace, or `/unload` is theatre.
247
+
248
+ Asserted against the Agent's system prompt rather than against the section
249
+ coding builds: the body is placed by `Agent.set_skills` now, so the section
250
+ is no longer where it lands. Checking what the model actually receives is
251
+ the better test anyway — it survives the placement moving again.
252
+ """
253
+ from xdog.agent.agent import Agent
254
+ from xdog.agent.core import AgentConfig
247
255
  from xdog.coding.core.agent_session import _skills_context
248
256
 
249
257
  shared = tmp_path / "skills"
@@ -256,11 +264,21 @@ def test_the_system_prompt_gains_and_loses_the_body(
256
264
  assert "write a workflow" in idle, "the one-line description is always advertised"
257
265
  assert "STEP ONE" not in idle, "the body must wait to be asked for"
258
266
 
259
- active = _skills_context(frozenset({"flow"}))
260
- assert "STEP ONE: validate the graph." in active
267
+ agent = Agent(
268
+ lambda *a, **k: None,
269
+ config=AgentConfig(model="m", system_prompt="BASE"),
270
+ skills=manager,
271
+ )
272
+ idle_prompt = agent.state.system_prompt
273
+ assert "write a workflow" in idle_prompt, "the index is always there"
274
+ assert "STEP ONE" not in idle_prompt, "the body waits to be asked for"
275
+
276
+ agent.set_active_skills(["flow"])
277
+ assert "STEP ONE: validate the graph." in agent.state.system_prompt
278
+ assert agent.state.system_prompt.strip().endswith("BASE"), "the caller's prompt survives"
261
279
 
262
- # ...and back again, byte for byte.
263
- assert _skills_context(frozenset()) == idle
280
+ agent.set_active_skills([])
281
+ assert agent.state.system_prompt == idle_prompt, "byte for byte, or /unload is theatre"
264
282
 
265
283
 
266
284
  def test_the_skills_section_is_empty_when_nothing_is_installed(
@@ -295,10 +313,15 @@ class FakeExpiringSession:
295
313
  def __init__(self, active: set[str]) -> None:
296
314
  self._active_skills = frozenset(active)
297
315
  self.rebuilds = 0
316
+ self.pushed: list[list[str]] = []
298
317
 
299
318
  def _rebuild_system_prompt(self) -> None:
300
319
  self.rebuilds += 1
301
320
 
321
+ def _push_skills(self) -> None:
322
+ """Expiry must tell the Agent too, or the body outlives the scope."""
323
+ self.pushed.append(sorted(self._active_skills))
324
+
302
325
 
303
326
  def _expire(session: Any) -> None:
304
327
  from xdog.coding.core.agent_session import AgentSession
@@ -387,3 +410,60 @@ def test_the_model_is_told_it_may_suggest_but_not_unload(
387
410
  assert "cannot deactivate" in prompt
388
411
  assert "/unload" in prompt
389
412
  assert "the user decides" in prompt.lower()
413
+
414
+
415
+ # -- tool definitions were being sent twice ---------------------------------
416
+
417
+
418
+ def _cfg(model: str = "copilot/x"):
419
+ from xdog.coding.config import PlatformInfo, RuntimeConfig
420
+
421
+ return RuntimeConfig(
422
+ model=model, thinking_level="normal", allowed_tools=("read",),
423
+ custom_instructions="", extensions=(), working_dir="/tmp",
424
+ platform_info=PlatformInfo.detect(),
425
+ )
426
+
427
+
428
+ _TOOLS = [{"name": "read", "description": "Read a file", "parameters": {"path": "string"}}]
429
+
430
+
431
+ def test_a_native_tool_calling_model_is_not_told_its_tools_twice() -> None:
432
+ """They already arrive as structured tool definitions through the API.
433
+ Describing them again sends every name, description and parameter schema a
434
+ second time, in the cached prefix, for a model that did not need it."""
435
+ from xdog.coding.core.system_prompt import build_system_prompt
436
+
437
+ described = build_system_prompt(_cfg(), _TOOLS, native_tool_calls=False)
438
+ native = build_system_prompt(_cfg(), _TOOLS, native_tool_calls=True)
439
+
440
+ assert "Read a file" in described
441
+ assert "Read a file" not in native
442
+ assert len(native) < len(described)
443
+
444
+
445
+ def test_the_default_keeps_tools_visible() -> None:
446
+ """The two mistakes are not symmetric. A duplicate costs tokens; a model
447
+ that cannot see its tools cannot act, and that reads as the model being
448
+ useless rather than as a missing prompt section — so unknown means describe.
449
+ """
450
+ from xdog.coding.core.system_prompt import build_system_prompt
451
+
452
+ assert "Read a file" in build_system_prompt(_cfg(), _TOOLS)
453
+
454
+
455
+ def test_an_unresolvable_model_still_gets_its_tools_described(
456
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
457
+ ) -> None:
458
+ """The lookup fails on an unknown provider, a network problem, or an offline
459
+ box. Every one of those must land on 'describe them'."""
460
+ from xdog.agent.helpers import model_supports_tool_calls
461
+ from xdog.coding.core.agent_session import AgentSession
462
+
463
+ class _Unresolvable:
464
+ class agent: # noqa: N801
465
+ class state: # noqa: N801
466
+ model = "no-such-provider/no-such-model"
467
+
468
+ assert model_supports_tool_calls("no-such-provider/no-such-model") is None
469
+ assert AgentSession._native_tool_calls(_Unresolvable()) is False
File without changes
File without changes
File without changes