stabbur 0.6.0__py3-none-any.whl

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 (153) hide show
  1. stabbur/__init__.py +37 -0
  2. stabbur/agent.py +332 -0
  3. stabbur/app.py +333 -0
  4. stabbur/arch.py +49 -0
  5. stabbur/attach.py +71 -0
  6. stabbur/benchmark/__init__.py +70 -0
  7. stabbur/benchmark/__main__.py +6 -0
  8. stabbur/benchmark/app.py +58 -0
  9. stabbur/benchmark/core.py +622 -0
  10. stabbur/benchmark/dhis2_state.py +168 -0
  11. stabbur/benchmark/plugin.py +269 -0
  12. stabbur/benchmark/suites/python.toml +166 -0
  13. stabbur/benchmark/suites/rust.toml +178 -0
  14. stabbur/benchmark/suites/tools-datetime.toml +42 -0
  15. stabbur/benchmark/suites/tools-dhis2-write.toml +104 -0
  16. stabbur/benchmark/suites/tools-dhis2.toml +123 -0
  17. stabbur/benchmark/suites/tools-exec.toml +22 -0
  18. stabbur/benchmark/suites/tools-memory.toml +24 -0
  19. stabbur/benchmark/suites/tools-search.toml +23 -0
  20. stabbur/benchmark/suites/tools-utils.toml +50 -0
  21. stabbur/benchmark/suites/tools-weather-yr.toml +24 -0
  22. stabbur/benchmark/suites/tools-web.toml +25 -0
  23. stabbur/capabilities.py +283 -0
  24. stabbur/cards.py +95 -0
  25. stabbur/catalog.py +88 -0
  26. stabbur/chat_tui/__init__.py +12 -0
  27. stabbur/chat_tui/_util.py +77 -0
  28. stabbur/chat_tui/_widgets.py +254 -0
  29. stabbur/chat_tui/app.py +1158 -0
  30. stabbur/chatui.py +32 -0
  31. stabbur/cli/__init__.py +38 -0
  32. stabbur/cli/_app.py +216 -0
  33. stabbur/cli/_common.py +312 -0
  34. stabbur/cli/chat.py +726 -0
  35. stabbur/cli/config.py +90 -0
  36. stabbur/cli/ext_dev.py +108 -0
  37. stabbur/cli/health.py +214 -0
  38. stabbur/cli/library.py +840 -0
  39. stabbur/cli/mcp.py +219 -0
  40. stabbur/cli/project.py +440 -0
  41. stabbur/cli/serve.py +211 -0
  42. stabbur/cli/voice.py +275 -0
  43. stabbur/config.py +314 -0
  44. stabbur/consumers.py +264 -0
  45. stabbur/doctor.py +452 -0
  46. stabbur/fsatomic.py +35 -0
  47. stabbur/hfcache.py +51 -0
  48. stabbur/host.py +108 -0
  49. stabbur/library/__init__.py +39 -0
  50. stabbur/library/_manage.py +251 -0
  51. stabbur/library/_model.py +185 -0
  52. stabbur/library/_roots.py +92 -0
  53. stabbur/library/_scan.py +252 -0
  54. stabbur/locking.py +55 -0
  55. stabbur/mcp_catalog.py +337 -0
  56. stabbur/mcp_servers/__init__.py +6 -0
  57. stabbur/mcp_servers/datetime/__init__.py +28 -0
  58. stabbur/mcp_servers/datetime/__main__.py +6 -0
  59. stabbur/mcp_servers/datetime/app.py +329 -0
  60. stabbur/mcp_servers/datetime/plugin.py +30 -0
  61. stabbur/mcp_servers/exec/__init__.py +27 -0
  62. stabbur/mcp_servers/exec/__main__.py +6 -0
  63. stabbur/mcp_servers/exec/app.py +43 -0
  64. stabbur/mcp_servers/exec/plugin.py +29 -0
  65. stabbur/mcp_servers/files/__init__.py +27 -0
  66. stabbur/mcp_servers/files/__main__.py +6 -0
  67. stabbur/mcp_servers/files/app.py +60 -0
  68. stabbur/mcp_servers/files/core.py +153 -0
  69. stabbur/mcp_servers/files/plugin.py +52 -0
  70. stabbur/mcp_servers/git/__init__.py +23 -0
  71. stabbur/mcp_servers/git/__main__.py +6 -0
  72. stabbur/mcp_servers/git/app.py +250 -0
  73. stabbur/mcp_servers/git/plugin.py +43 -0
  74. stabbur/mcp_servers/http/__init__.py +23 -0
  75. stabbur/mcp_servers/http/__main__.py +6 -0
  76. stabbur/mcp_servers/http/app.py +282 -0
  77. stabbur/mcp_servers/http/plugin.py +50 -0
  78. stabbur/mcp_servers/memory/__init__.py +28 -0
  79. stabbur/mcp_servers/memory/__main__.py +6 -0
  80. stabbur/mcp_servers/memory/app.py +78 -0
  81. stabbur/mcp_servers/memory/core.py +119 -0
  82. stabbur/mcp_servers/memory/plugin.py +50 -0
  83. stabbur/mcp_servers/network/__init__.py +23 -0
  84. stabbur/mcp_servers/network/__main__.py +6 -0
  85. stabbur/mcp_servers/network/app.py +156 -0
  86. stabbur/mcp_servers/network/plugin.py +29 -0
  87. stabbur/mcp_servers/search/__init__.py +28 -0
  88. stabbur/mcp_servers/search/__main__.py +6 -0
  89. stabbur/mcp_servers/search/app.py +195 -0
  90. stabbur/mcp_servers/search/plugin.py +44 -0
  91. stabbur/mcp_servers/shell/__init__.py +23 -0
  92. stabbur/mcp_servers/shell/__main__.py +6 -0
  93. stabbur/mcp_servers/shell/app.py +230 -0
  94. stabbur/mcp_servers/shell/plugin.py +29 -0
  95. stabbur/mcp_servers/utils/__init__.py +27 -0
  96. stabbur/mcp_servers/utils/__main__.py +6 -0
  97. stabbur/mcp_servers/utils/app.py +306 -0
  98. stabbur/mcp_servers/utils/plugin.py +29 -0
  99. stabbur/mcp_servers/weather_yr/__init__.py +28 -0
  100. stabbur/mcp_servers/weather_yr/__main__.py +6 -0
  101. stabbur/mcp_servers/weather_yr/app.py +55 -0
  102. stabbur/mcp_servers/weather_yr/core.py +183 -0
  103. stabbur/mcp_servers/weather_yr/plugin.py +30 -0
  104. stabbur/mcp_servers/web/__init__.py +28 -0
  105. stabbur/mcp_servers/web/__main__.py +6 -0
  106. stabbur/mcp_servers/web/app.py +357 -0
  107. stabbur/mcp_servers/web/plugin.py +48 -0
  108. stabbur/mcpservers.py +177 -0
  109. stabbur/models.py +234 -0
  110. stabbur/plugins.py +168 -0
  111. stabbur/project/__init__.py +630 -0
  112. stabbur/project/scaffold.py +203 -0
  113. stabbur/project/templates.py +674 -0
  114. stabbur/routers/__init__.py +1 -0
  115. stabbur/routers/catalog.py +37 -0
  116. stabbur/routers/health.py +11 -0
  117. stabbur/routers/serving/__init__.py +29 -0
  118. stabbur/routers/serving/_base.py +88 -0
  119. stabbur/routers/serving/assistant.py +611 -0
  120. stabbur/routers/serving/chat.py +369 -0
  121. stabbur/routers/serving/core.py +434 -0
  122. stabbur/routers/serving/mcp.py +211 -0
  123. stabbur/routers/serving/proxy.py +69 -0
  124. stabbur/routers/serving/voice.py +286 -0
  125. stabbur/runtime/__init__.py +317 -0
  126. stabbur/runtime/sampling.py +99 -0
  127. stabbur/runtime/serve_registry.py +122 -0
  128. stabbur/runtime/supervisor.py +325 -0
  129. stabbur/server.py +425 -0
  130. stabbur/sources/__init__.py +5 -0
  131. stabbur/sources/base.py +132 -0
  132. stabbur/sources/huggingface.py +252 -0
  133. stabbur/sources/lmstudio.py +128 -0
  134. stabbur/sources/ollama.py +415 -0
  135. stabbur/tags.py +163 -0
  136. stabbur/targets.py +180 -0
  137. stabbur/tools.py +657 -0
  138. stabbur/transcript.py +45 -0
  139. stabbur/userconfig.py +111 -0
  140. stabbur/voice/__init__.py +25 -0
  141. stabbur/voice/audio.py +64 -0
  142. stabbur/voice/catalog.py +111 -0
  143. stabbur/voice/importer.py +170 -0
  144. stabbur/voice/kokoro.py +228 -0
  145. stabbur/voice/registry.py +171 -0
  146. stabbur/voice/runtime.py +133 -0
  147. stabbur/voice/tts.py +48 -0
  148. stabbur/wantlist.py +208 -0
  149. stabbur-0.6.0.dist-info/METADATA +232 -0
  150. stabbur-0.6.0.dist-info/RECORD +153 -0
  151. stabbur-0.6.0.dist-info/WHEEL +4 -0
  152. stabbur-0.6.0.dist-info/entry_points.txt +32 -0
  153. stabbur-0.6.0.dist-info/licenses/LICENSE +24 -0
stabbur/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Backup and browse local LLM models from Hugging Face, Ollama, and LM Studio.
2
+
3
+ This package's ``__init__`` deliberately runs two Hugging Face environment tweaks **at import
4
+ time**, before ``huggingface_hub`` is first imported. That timing is load-bearing, not
5
+ incidental: ``huggingface_hub`` freezes its cache path (from ``HF_HOME`` / ``HF_HUB_CACHE``) at
6
+ *its* import, and importing almost any stabbur module (e.g. ``stabbur.cli``) transitively imports
7
+ ``huggingface_hub`` — so this ``__init__`` is the only hook that reliably runs first. (A8 in the
8
+ review proposed moving these into the CLI/app entry points; that was verified *unsafe* precisely
9
+ because the entry points run after hf_hub is already imported.) These are the sole intentional
10
+ import-time side effects; everything else is lazy.
11
+ """
12
+
13
+ import os
14
+
15
+ # Ask huggingface_hub for high-performance Xet transfer (the fast, parallel path
16
+ # in hub >=1.21, backed by the hf_xet dependency) — big model pulls otherwise fall
17
+ # back to slow single-stream HTTP. Set before huggingface_hub is imported anywhere.
18
+ # (The old HF_HUB_ENABLE_HF_TRANSFER flag is deprecated/removed in 1.21.)
19
+ os.environ.setdefault("HF_XET_HIGH_PERFORMANCE", "1")
20
+
21
+ # Point the HF hub cache at the library drive (before huggingface_hub is imported), so
22
+ # assets some runtimes fetch by repo id — e.g. an mlx-audio model's codec — travel with
23
+ # the drive instead of living in ~/.cache/huggingface. Best-effort + guarded: a no-op if
24
+ # the user set HF_HOME/HF_HUB_CACHE, or there's no real configured library (unset, or an
25
+ # unmounted drive → falls back to the machine cache). See stabbur.hfcache.
26
+ from stabbur import hfcache as _hfcache # noqa: E402
27
+
28
+ _hfcache.configure()
29
+
30
+ # Read the version from package metadata (chapkit's pattern) rather than restating it here:
31
+ # a second copy only drifts — this one still said 0.1.0 at the 0.4.0 release.
32
+ try:
33
+ from importlib.metadata import version as _get_version
34
+
35
+ __version__ = _get_version("stabbur")
36
+ except Exception: # noqa: BLE001 - a source tree that was never installed has no metadata
37
+ __version__ = "unknown"
stabbur/agent.py ADDED
@@ -0,0 +1,332 @@
1
+ """The tool-calling agent loop: model ⇄ tools over the OpenAI /v1 API.
2
+
3
+ The model may emit ``tool_call``s; stabbur executes each via the MCP toolset, feeds
4
+ the results back, and repeats until the model answers with plain text.
5
+ """
6
+
7
+ import json
8
+ from collections.abc import Awaitable, Callable
9
+ from inspect import isawaitable
10
+ from typing import Any, Literal
11
+
12
+ import httpx
13
+
14
+ from stabbur.tools import MCPToolset, ToolResult
15
+
16
+ # Prefaces the user message that carries images a tool returned (e.g. a screenshot), so a
17
+ # vision model reads them as its own multimodal input right after the tool results.
18
+ _TOOL_IMAGE_PREAMBLE = "Image(s) returned by the tool call(s) above:"
19
+
20
+ # Callbacks: on_event(kind, detail) for tool activity; on_token(text) for streamed reply.
21
+ # Sinks may be sync (TUI/CLI append to a buffer) or async (the /api/chat SSE path uses an
22
+ # async sink so a full, bounded queue back-pressures generation instead of buffering the
23
+ # whole reply in memory). ``_emit`` awaits the result only when it's awaitable.
24
+ ToolEvent = Callable[[str, str], None | Awaitable[None]]
25
+ TokenSink = Callable[[str], None | Awaitable[None]]
26
+ # on_usage(usage) receives the server's token accounting for a turn (OpenAI `usage`:
27
+ # prompt_tokens / completion_tokens / total_tokens) so a REPL can show context used.
28
+ UsageSink = Callable[[dict[str, Any]], None]
29
+ # on_confirm(name, args) is consulted before a gated tool call runs; it returns whether the user
30
+ # approved the action. Async-only (the /api/chat + UI path awaits a user decision over a channel),
31
+ # so the loop can suspend on it — a missing sink is treated as a denial (fail-safe) at the gate.
32
+ ConfirmSink = Callable[[str, dict[str, Any]], Awaitable[bool]]
33
+
34
+
35
+ def _needs_confirm(policy: Literal["all", "writes", "none"], toolset: MCPToolset, name: str) -> bool:
36
+ """Whether a tool call must be confirmed under ``policy``.
37
+
38
+ ``"all"`` gates every call; ``"writes"`` gates only tools not known read-only (fail-safe —
39
+ an unknown/unannotated tool is treated as a write); anything else (``"none"``) gates nothing.
40
+ """
41
+ if policy == "all":
42
+ return True
43
+ if policy == "writes":
44
+ return not toolset.is_readonly(name)
45
+ return False
46
+
47
+
48
+ async def _emit(sink: Callable[..., None | Awaitable[None]] | None, *args: Any) -> None:
49
+ """Call an optional sync-or-async sink, awaiting it when it returns an awaitable."""
50
+ if sink is None:
51
+ return
52
+ result = sink(*args)
53
+ if isawaitable(result):
54
+ await result
55
+
56
+
57
+ def _audio_part(data_url: str) -> dict[str, Any]:
58
+ """Convert an audio ``data:`` URL to an OpenAI ``input_audio`` content part.
59
+
60
+ llama-server / mlx-vlm want ``{data: <base64 (no prefix)>, format: "wav"|"mp3"}``
61
+ (not a data URL like images), so split the mime + payload out.
62
+ """
63
+ fmt = "wav"
64
+ b64 = data_url
65
+ if data_url.startswith("data:"):
66
+ header, _, b64 = data_url.partition(",")
67
+ mime = header[len("data:") :].split(";")[0] # e.g. audio/wav
68
+ subtype = mime.split("/")[-1] or "wav"
69
+ fmt = {"mpeg": "mp3", "x-wav": "wav", "wave": "wav"}.get(subtype, subtype)
70
+ return {"type": "input_audio", "input_audio": {"data": b64, "format": fmt}}
71
+
72
+
73
+ def user_content(
74
+ text: str, images: list[str] | None = None, audios: list[str] | None = None
75
+ ) -> str | list[dict[str, Any]]:
76
+ """Build a user message's content: plain text, or OpenAI multimodal parts.
77
+
78
+ ``images`` / ``audios`` are ``data:`` URL strings. With none, returns the plain
79
+ string (backward compatible); otherwise a ``content`` array of an optional text
80
+ part followed by ``image_url`` and ``input_audio`` parts — the format both
81
+ llama-server (with ``--mmproj``) and mlx-vlm accept.
82
+ """
83
+ if not images and not audios:
84
+ return text
85
+ parts: list[dict[str, Any]] = []
86
+ if text:
87
+ parts.append({"type": "text", "text": text})
88
+ parts += [{"type": "image_url", "image_url": {"url": u}} for u in images or []]
89
+ parts += [_audio_part(a) for a in audios or []]
90
+ return parts
91
+
92
+
93
+ async def _stream_turn(
94
+ http: httpx.AsyncClient,
95
+ base_url: str,
96
+ body: dict[str, Any],
97
+ on_token: TokenSink | None,
98
+ on_reasoning: TokenSink | None = None,
99
+ ) -> tuple[str, list[dict[str, Any]], dict[str, Any] | None]:
100
+ """Stream one completion; return (content, tool_calls, usage). Emits content + reasoning live."""
101
+ content = ""
102
+ calls: dict[int, dict[str, str]] = {}
103
+ usage: dict[str, Any] | None = None
104
+ async with http.stream("POST", f"{base_url}/v1/chat/completions", json=body) as resp:
105
+ if resp.status_code >= 400:
106
+ # Read the body on error — llama-server puts the real cause (e.g. context overflow)
107
+ # in the JSON detail, which raise_for_status alone would discard.
108
+ detail = (await resp.aread()).decode("utf-8", errors="replace").strip()[:500]
109
+ raise httpx.HTTPStatusError(
110
+ f"runtime returned {resp.status_code}: {detail}" if detail else f"runtime returned {resp.status_code}",
111
+ request=resp.request,
112
+ response=resp,
113
+ )
114
+ async for line in resp.aiter_lines():
115
+ if not line.startswith("data:"):
116
+ continue
117
+ payload = line[len("data:") :].strip()
118
+ if payload == "[DONE]":
119
+ break
120
+ chunk = json.loads(payload)
121
+ # With include_usage the final chunk carries `usage` and an empty
122
+ # `choices` list; capture it and skip the (missing) delta.
123
+ if chunk.get("usage"):
124
+ captured = chunk["usage"]
125
+ usage = dict(captured) if isinstance(captured, dict) else {}
126
+ # llama.cpp adds its own `timings` (prompt_ms, predicted_ms,
127
+ # predicted_per_second) to that chunk. Pass them through: the runtime's
128
+ # measurement of its own decode rate beats anything a client can infer
129
+ # from arrival times. Absent on other servers, hence the guard.
130
+ timings = chunk.get("timings")
131
+ if isinstance(timings, dict):
132
+ usage["timings"] = timings
133
+ if not chunk.get("choices"):
134
+ continue
135
+ delta = chunk["choices"][0]["delta"]
136
+ if delta.get("content"):
137
+ content += delta["content"]
138
+ await _emit(on_token, delta["content"])
139
+ # Reasoning models (gemma-4, Qwen3.5, …) stream their thinking here, not in
140
+ # content; surface it separately instead of dropping it (→ blank replies).
141
+ if delta.get("reasoning_content") and on_reasoning:
142
+ await _emit(on_reasoning, delta["reasoning_content"])
143
+ for tc in delta.get("tool_calls") or []:
144
+ slot = calls.setdefault(tc["index"], {"id": "", "name": "", "args": ""})
145
+ if tc.get("id"):
146
+ slot["id"] = tc["id"]
147
+ fn = tc.get("function") or {}
148
+ if fn.get("name"):
149
+ slot["name"] = fn["name"]
150
+ if fn.get("arguments"):
151
+ slot["args"] += fn["arguments"]
152
+ ordered = [calls[i] for i in sorted(calls)]
153
+ return content, ordered, usage
154
+
155
+
156
+ ReasoningLevel = Literal["off", "low", "medium", "high", "max"]
157
+ """Reasoning-effort levels for thinking models, mirroring the llama.cpp webui's control."""
158
+
159
+ _REASONING_BUDGETS: dict[str, int] = {"low": 512, "medium": 2048, "high": 8192}
160
+
161
+
162
+ def reasoning_fields(level: "ReasoningLevel | None") -> dict[str, Any]:
163
+ """The request fields for a reasoning level; empty for ``None`` (the model default).
164
+
165
+ Speaks llama-server's dialect — exactly what its own webui sends: ``chat_template_kwargs.
166
+ enable_thinking`` toggles thinking in the chat template (Qwen-style models), ``thinking_
167
+ budget_tokens`` caps the thinking length (low 512 / medium 2048 / high 8192; ``max`` sends
168
+ no cap), and ``reasoning_control`` marks the request as reasoning-managed. Servers without
169
+ reasoning support ignore the unknown fields, so sending them is always safe.
170
+ """
171
+ if level is None:
172
+ return {}
173
+ fields: dict[str, Any] = {
174
+ "chat_template_kwargs": {"enable_thinking": level != "off"},
175
+ "reasoning_control": True,
176
+ }
177
+ if level in _REASONING_BUDGETS:
178
+ fields["thinking_budget_tokens"] = _REASONING_BUDGETS[level]
179
+ return fields
180
+
181
+
182
+ async def run(
183
+ base_url: str,
184
+ messages: list[dict[str, Any]],
185
+ toolset: MCPToolset,
186
+ max_tokens: int | None = None,
187
+ on_event: ToolEvent | None = None,
188
+ on_token: TokenSink | None = None,
189
+ on_reasoning: TokenSink | None = None,
190
+ temperature: float | None = None,
191
+ top_p: float | None = None,
192
+ top_k: int | None = None,
193
+ min_p: float | None = None,
194
+ repeat_penalty: float | None = None,
195
+ model: str | None = None,
196
+ max_rounds: int = 8,
197
+ on_usage: UsageSink | None = None,
198
+ tool_timeout: float | None = None,
199
+ vision: bool = False,
200
+ on_confirm: ConfirmSink | None = None,
201
+ confirm_policy: Literal["all", "writes", "none"] = "none",
202
+ reasoning: "ReasoningLevel | None" = None,
203
+ ) -> str:
204
+ """Run the agent loop against ``base_url``, streaming the reply; return its text.
205
+
206
+ ``messages`` is mutated in place (assistant/tool turns) so a REPL keeps the
207
+ conversation. ``on_event`` reports tool activity; ``on_token`` receives the
208
+ final reply's tokens; ``on_reasoning`` receives a reasoning model's thinking
209
+ tokens (separate channel); ``on_usage`` receives the server's token accounting
210
+ (prompt/completion/total) after each round. ``model`` is sent as the OpenAI
211
+ ``model`` field — required by mlx-vlm (which 422s without it), ignored by
212
+ llama-server/mlx-lm. Bounded by ``max_rounds``; each tool call is bounded by
213
+ ``tool_timeout`` seconds so a hung MCP server can't stall the loop forever —
214
+ ``None`` (default) reads ``STABBUR_TOOL_TIMEOUT`` (120s; set 0 to disable the bound).
215
+ ``vision`` is set when the model can see images: an image a tool returns (e.g. a
216
+ screenshot) is then fed back as a follow-up user image message so the model reads it
217
+ as multimodal input; a text-only model instead gets a note that an image was returned.
218
+ ``confirm_policy`` gates tool execution behind ``on_confirm``: ``"none"`` (default) runs
219
+ every tool as before; ``"writes"`` confirms only tools not known read-only (via each tool's
220
+ ``readOnlyHint`` annotation — an unannotated tool is treated as a write); ``"all"`` confirms
221
+ every call. When a call is gated, ``on_confirm(name, args)`` is awaited for approval; if it
222
+ returns falsy (or no ``on_confirm`` is supplied — fail-safe deny), the tool is NOT run and the
223
+ model gets a ``tool`` turn whose text is exactly ``error: user declined this action``.
224
+ """
225
+ if tool_timeout is None:
226
+ from stabbur.config import get_settings # noqa: PLC0415 - lazy to keep agent import light
227
+
228
+ tool_timeout = get_settings().tool_timeout or None # 0 → no bound
229
+ async with httpx.AsyncClient(timeout=600) as http:
230
+ for _ in range(max_rounds):
231
+ body: dict[str, Any] = {"messages": messages, "stream": True}
232
+ # Ask for a final usage chunk (prompt/completion tokens) so callers can
233
+ # report real context consumption; runtimes that ignore it just omit it.
234
+ if on_usage is not None:
235
+ body["stream_options"] = {"include_usage": True}
236
+ if model is not None:
237
+ body["model"] = model
238
+ # Omit tools entirely when there are none, so a no-tool chat is plain
239
+ # completion (no --jinja tool parsing / buffering).
240
+ if toolset.schemas:
241
+ body["tools"] = toolset.schemas
242
+ body["tool_choice"] = "auto"
243
+ if max_tokens is not None:
244
+ body["max_tokens"] = max_tokens
245
+ if temperature is not None:
246
+ body["temperature"] = temperature
247
+ if top_p is not None:
248
+ body["top_p"] = top_p
249
+ # top_k / min_p / repeat_penalty are OpenAI extensions supported by
250
+ # llama-server and the MLX servers; unknown ones are ignored upstream.
251
+ if top_k is not None:
252
+ body["top_k"] = top_k
253
+ if min_p is not None:
254
+ body["min_p"] = min_p
255
+ if repeat_penalty is not None:
256
+ body["repeat_penalty"] = repeat_penalty
257
+ # Reasoning effort (thinking on/off + budget) — llama-server dialect, see reasoning_fields.
258
+ body.update(reasoning_fields(reasoning))
259
+ content, calls, usage = await _stream_turn(http, base_url, body, on_token, on_reasoning)
260
+ if usage and on_usage:
261
+ on_usage(usage)
262
+ if not calls:
263
+ messages.append({"role": "assistant", "content": content})
264
+ return content
265
+
266
+ messages.append(
267
+ {
268
+ "role": "assistant",
269
+ "content": content or None,
270
+ "tool_calls": [
271
+ {"id": c["id"], "type": "function", "function": {"name": c["name"], "arguments": c["args"]}}
272
+ for c in calls
273
+ ],
274
+ }
275
+ )
276
+ round_images: list[str] = [] # images tools returned this round (fed back below)
277
+ for c in calls:
278
+ await _emit(on_event, "call", f"{c['name']}({c['args']})")
279
+ try:
280
+ args = json.loads(c["args"] or "{}")
281
+ except json.JSONDecodeError as exc:
282
+ # Don't run the tool with empty args on unparseable JSON — for a tool with all
283
+ # optional params that silently returns a plausible-but-wrong result. Feed the
284
+ # parse error back so the model resends valid arguments.
285
+ result = ToolResult(
286
+ text=f"error: could not parse tool arguments as JSON ({exc}); resend valid JSON."
287
+ )
288
+ else:
289
+ if _needs_confirm(confirm_policy, toolset, c["name"]):
290
+ # Gated action: no confirmation channel means deny (fail-safe); otherwise ask.
291
+ approved = await on_confirm(c["name"], args) if on_confirm is not None else False
292
+ else:
293
+ approved = True
294
+ if not approved:
295
+ # Declined: skip the side-effecting call but still give the model a tool turn
296
+ # (mirroring the error-branch shape) so the loop continues with a clear signal.
297
+ result = ToolResult(text="error: user declined this action")
298
+ else:
299
+ try:
300
+ result = await toolset.call(c["name"], args, timeout=tool_timeout)
301
+ except Exception as exc: # noqa: BLE001 - report tool failures (incl. timeout) to the model
302
+ result = ToolResult(text=f"error: {exc}")
303
+ display = result.text + (f" [+{len(result.images)} image(s)]" if result.images else "")
304
+ await _emit(on_event, "result", display)
305
+ content = result.text
306
+ if result.images and vision:
307
+ # Feed the pixels back below; leave the tool message a short marker so the
308
+ # tool_call_id still has content and the model knows where the image came from.
309
+ round_images.extend(result.images)
310
+ content = content or "[image returned by the tool; shown in the next message]"
311
+ elif result.images:
312
+ # Text-only model: it can't see the image, so say so rather than drop it silently
313
+ # (a screenshot vanishing makes a vision-less model hallucinate what it "saw").
314
+ note = "[a tool returned an image, but this model cannot view images]"
315
+ content = f"{content}\n{note}" if content else note
316
+ messages.append({"role": "tool", "tool_call_id": c["id"], "content": content})
317
+
318
+ # A vision model reads tool-returned images as its own input: deliver them in a user
319
+ # message right after the tool results (the exercised multimodal path — an image_url
320
+ # part in a tool message isn't understood by llama-server/mlx-vlm).
321
+ if round_images:
322
+ messages.append({"role": "user", "content": user_content(_TOOL_IMAGE_PREAMBLE, images=round_images)})
323
+
324
+ # Ran out of tool rounds: surface a terminal message the same way a normal
325
+ # reply is delivered — stream it (so streaming clients, incl. the web UI whose
326
+ # /api/chat discards the return value, actually see it) and record it in history.
327
+ stopped = "[agent stopped: too many tool rounds]"
328
+ # _emit, not a bare call: the /api/chat sink is async (queue.put) — calling it
329
+ # unawaited would silently drop the message for exactly the clients it's for.
330
+ await _emit(on_token, stopped)
331
+ messages.append({"role": "assistant", "content": stopped})
332
+ return stopped