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.
- stabbur/__init__.py +37 -0
- stabbur/agent.py +332 -0
- stabbur/app.py +333 -0
- stabbur/arch.py +49 -0
- stabbur/attach.py +71 -0
- stabbur/benchmark/__init__.py +70 -0
- stabbur/benchmark/__main__.py +6 -0
- stabbur/benchmark/app.py +58 -0
- stabbur/benchmark/core.py +622 -0
- stabbur/benchmark/dhis2_state.py +168 -0
- stabbur/benchmark/plugin.py +269 -0
- stabbur/benchmark/suites/python.toml +166 -0
- stabbur/benchmark/suites/rust.toml +178 -0
- stabbur/benchmark/suites/tools-datetime.toml +42 -0
- stabbur/benchmark/suites/tools-dhis2-write.toml +104 -0
- stabbur/benchmark/suites/tools-dhis2.toml +123 -0
- stabbur/benchmark/suites/tools-exec.toml +22 -0
- stabbur/benchmark/suites/tools-memory.toml +24 -0
- stabbur/benchmark/suites/tools-search.toml +23 -0
- stabbur/benchmark/suites/tools-utils.toml +50 -0
- stabbur/benchmark/suites/tools-weather-yr.toml +24 -0
- stabbur/benchmark/suites/tools-web.toml +25 -0
- stabbur/capabilities.py +283 -0
- stabbur/cards.py +95 -0
- stabbur/catalog.py +88 -0
- stabbur/chat_tui/__init__.py +12 -0
- stabbur/chat_tui/_util.py +77 -0
- stabbur/chat_tui/_widgets.py +254 -0
- stabbur/chat_tui/app.py +1158 -0
- stabbur/chatui.py +32 -0
- stabbur/cli/__init__.py +38 -0
- stabbur/cli/_app.py +216 -0
- stabbur/cli/_common.py +312 -0
- stabbur/cli/chat.py +726 -0
- stabbur/cli/config.py +90 -0
- stabbur/cli/ext_dev.py +108 -0
- stabbur/cli/health.py +214 -0
- stabbur/cli/library.py +840 -0
- stabbur/cli/mcp.py +219 -0
- stabbur/cli/project.py +440 -0
- stabbur/cli/serve.py +211 -0
- stabbur/cli/voice.py +275 -0
- stabbur/config.py +314 -0
- stabbur/consumers.py +264 -0
- stabbur/doctor.py +452 -0
- stabbur/fsatomic.py +35 -0
- stabbur/hfcache.py +51 -0
- stabbur/host.py +108 -0
- stabbur/library/__init__.py +39 -0
- stabbur/library/_manage.py +251 -0
- stabbur/library/_model.py +185 -0
- stabbur/library/_roots.py +92 -0
- stabbur/library/_scan.py +252 -0
- stabbur/locking.py +55 -0
- stabbur/mcp_catalog.py +337 -0
- stabbur/mcp_servers/__init__.py +6 -0
- stabbur/mcp_servers/datetime/__init__.py +28 -0
- stabbur/mcp_servers/datetime/__main__.py +6 -0
- stabbur/mcp_servers/datetime/app.py +329 -0
- stabbur/mcp_servers/datetime/plugin.py +30 -0
- stabbur/mcp_servers/exec/__init__.py +27 -0
- stabbur/mcp_servers/exec/__main__.py +6 -0
- stabbur/mcp_servers/exec/app.py +43 -0
- stabbur/mcp_servers/exec/plugin.py +29 -0
- stabbur/mcp_servers/files/__init__.py +27 -0
- stabbur/mcp_servers/files/__main__.py +6 -0
- stabbur/mcp_servers/files/app.py +60 -0
- stabbur/mcp_servers/files/core.py +153 -0
- stabbur/mcp_servers/files/plugin.py +52 -0
- stabbur/mcp_servers/git/__init__.py +23 -0
- stabbur/mcp_servers/git/__main__.py +6 -0
- stabbur/mcp_servers/git/app.py +250 -0
- stabbur/mcp_servers/git/plugin.py +43 -0
- stabbur/mcp_servers/http/__init__.py +23 -0
- stabbur/mcp_servers/http/__main__.py +6 -0
- stabbur/mcp_servers/http/app.py +282 -0
- stabbur/mcp_servers/http/plugin.py +50 -0
- stabbur/mcp_servers/memory/__init__.py +28 -0
- stabbur/mcp_servers/memory/__main__.py +6 -0
- stabbur/mcp_servers/memory/app.py +78 -0
- stabbur/mcp_servers/memory/core.py +119 -0
- stabbur/mcp_servers/memory/plugin.py +50 -0
- stabbur/mcp_servers/network/__init__.py +23 -0
- stabbur/mcp_servers/network/__main__.py +6 -0
- stabbur/mcp_servers/network/app.py +156 -0
- stabbur/mcp_servers/network/plugin.py +29 -0
- stabbur/mcp_servers/search/__init__.py +28 -0
- stabbur/mcp_servers/search/__main__.py +6 -0
- stabbur/mcp_servers/search/app.py +195 -0
- stabbur/mcp_servers/search/plugin.py +44 -0
- stabbur/mcp_servers/shell/__init__.py +23 -0
- stabbur/mcp_servers/shell/__main__.py +6 -0
- stabbur/mcp_servers/shell/app.py +230 -0
- stabbur/mcp_servers/shell/plugin.py +29 -0
- stabbur/mcp_servers/utils/__init__.py +27 -0
- stabbur/mcp_servers/utils/__main__.py +6 -0
- stabbur/mcp_servers/utils/app.py +306 -0
- stabbur/mcp_servers/utils/plugin.py +29 -0
- stabbur/mcp_servers/weather_yr/__init__.py +28 -0
- stabbur/mcp_servers/weather_yr/__main__.py +6 -0
- stabbur/mcp_servers/weather_yr/app.py +55 -0
- stabbur/mcp_servers/weather_yr/core.py +183 -0
- stabbur/mcp_servers/weather_yr/plugin.py +30 -0
- stabbur/mcp_servers/web/__init__.py +28 -0
- stabbur/mcp_servers/web/__main__.py +6 -0
- stabbur/mcp_servers/web/app.py +357 -0
- stabbur/mcp_servers/web/plugin.py +48 -0
- stabbur/mcpservers.py +177 -0
- stabbur/models.py +234 -0
- stabbur/plugins.py +168 -0
- stabbur/project/__init__.py +630 -0
- stabbur/project/scaffold.py +203 -0
- stabbur/project/templates.py +674 -0
- stabbur/routers/__init__.py +1 -0
- stabbur/routers/catalog.py +37 -0
- stabbur/routers/health.py +11 -0
- stabbur/routers/serving/__init__.py +29 -0
- stabbur/routers/serving/_base.py +88 -0
- stabbur/routers/serving/assistant.py +611 -0
- stabbur/routers/serving/chat.py +369 -0
- stabbur/routers/serving/core.py +434 -0
- stabbur/routers/serving/mcp.py +211 -0
- stabbur/routers/serving/proxy.py +69 -0
- stabbur/routers/serving/voice.py +286 -0
- stabbur/runtime/__init__.py +317 -0
- stabbur/runtime/sampling.py +99 -0
- stabbur/runtime/serve_registry.py +122 -0
- stabbur/runtime/supervisor.py +325 -0
- stabbur/server.py +425 -0
- stabbur/sources/__init__.py +5 -0
- stabbur/sources/base.py +132 -0
- stabbur/sources/huggingface.py +252 -0
- stabbur/sources/lmstudio.py +128 -0
- stabbur/sources/ollama.py +415 -0
- stabbur/tags.py +163 -0
- stabbur/targets.py +180 -0
- stabbur/tools.py +657 -0
- stabbur/transcript.py +45 -0
- stabbur/userconfig.py +111 -0
- stabbur/voice/__init__.py +25 -0
- stabbur/voice/audio.py +64 -0
- stabbur/voice/catalog.py +111 -0
- stabbur/voice/importer.py +170 -0
- stabbur/voice/kokoro.py +228 -0
- stabbur/voice/registry.py +171 -0
- stabbur/voice/runtime.py +133 -0
- stabbur/voice/tts.py +48 -0
- stabbur/wantlist.py +208 -0
- stabbur-0.6.0.dist-info/METADATA +232 -0
- stabbur-0.6.0.dist-info/RECORD +153 -0
- stabbur-0.6.0.dist-info/WHEEL +4 -0
- stabbur-0.6.0.dist-info/entry_points.txt +32 -0
- 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
|