tanglebrain 0.16.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.
- tanglebrain/__init__.py +23 -0
- tanglebrain/adapters/__init__.py +15 -0
- tanglebrain/adapters/api.py +65 -0
- tanglebrain/adapters/base.py +46 -0
- tanglebrain/adapters/cli.py +341 -0
- tanglebrain/adapters/openai_compat.py +197 -0
- tanglebrain/classifier.py +99 -0
- tanglebrain/cli.py +251 -0
- tanglebrain/config/pricing.yaml +14 -0
- tanglebrain/config/roster.yaml +129 -0
- tanglebrain/config/settings.yaml +39 -0
- tanglebrain/delegate.py +485 -0
- tanglebrain/gui/__init__.py +10 -0
- tanglebrain/gui/server.py +162 -0
- tanglebrain/gui/static/index.html +295 -0
- tanglebrain/gui/static/logo.png +0 -0
- tanglebrain/gui/views.py +180 -0
- tanglebrain/mcp_server.py +208 -0
- tanglebrain/measurement.py +548 -0
- tanglebrain/roster.py +415 -0
- tanglebrain/roster_edit.py +201 -0
- tanglebrain/router.py +232 -0
- tanglebrain/selector.py +117 -0
- tanglebrain/settings.py +132 -0
- tanglebrain-0.16.0.dist-info/METADATA +369 -0
- tanglebrain-0.16.0.dist-info/RECORD +30 -0
- tanglebrain-0.16.0.dist-info/WHEEL +5 -0
- tanglebrain-0.16.0.dist-info/entry_points.txt +4 -0
- tanglebrain-0.16.0.dist-info/licenses/LICENSE +21 -0
- tanglebrain-0.16.0.dist-info/top_level.txt +1 -0
tanglebrain/delegate.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Delegation logic — the sub-task offload behind the MCP tools.
|
|
2
|
+
|
|
3
|
+
A frontier orchestrator (e.g. claude / codex / gemini) decomposes a task and hands sub-tasks to a
|
|
4
|
+
**configured** backend, then reviews the result. The default target is TangleBrain's **free local
|
|
5
|
+
tier** at $0 marginal cost (:func:`run_local_delegate` / ``target=None``); the orchestrator may also
|
|
6
|
+
target any roster entry flagged ``can_delegate`` (:func:`run_delegate`) — a cheaper sub or a
|
|
7
|
+
better-fit backend you've configured. This module is the routing half of that: take a prompt, resolve
|
|
8
|
+
the target entry, route to it, return the text. The configured menu is exposed via
|
|
9
|
+
:func:`delegate_targets`.
|
|
10
|
+
|
|
11
|
+
It is deliberately **free of any MCP dependency** so the delegation logic is importable and
|
|
12
|
+
hermetically testable without the `mcp` SDK installed — the MCP server in
|
|
13
|
+
:mod:`tanglebrain.mcp_server` is a thin wrapper over these functions.
|
|
14
|
+
|
|
15
|
+
It **reuses** the roster + selector + ``OpenAICompatAdapter`` rather than re-implementing the call,
|
|
16
|
+
so the endpoint, model, and key live in exactly one place (the roster). Failures surface to the
|
|
17
|
+
caller — the orchestrator decides whether to retry, fall back, or surface (no transparent retry/swap
|
|
18
|
+
here), matching the adapter contract.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
26
|
+
|
|
27
|
+
from tanglebrain.measurement import PARENT_TASK_ID_ENV, record_task
|
|
28
|
+
from tanglebrain.roster import ROSTER_ENV_VAR, Roster, RosterEntry, load_roster
|
|
29
|
+
from tanglebrain.selector import SelectionError, build_adapter, select_local
|
|
30
|
+
from tanglebrain.settings import Settings, load_settings
|
|
31
|
+
|
|
32
|
+
#: A local reasoning model spends part of its budget on internal reasoning before the final answer,
|
|
33
|
+
#: so the delegate's token cap is generous by default. Matches the adapter's own default.
|
|
34
|
+
DEFAULT_DELEGATE_MAX_TOKENS = 2048
|
|
35
|
+
|
|
36
|
+
#: The MCP server name an orchestrator registers the delegate under. The tool an orchestrator
|
|
37
|
+
#: calls is then ``mcp__<DELEGATE_SERVER_NAME>__delegate_local``.
|
|
38
|
+
DELEGATE_SERVER_NAME = "tanglebrain-delegate"
|
|
39
|
+
|
|
40
|
+
#: Cost ordering for capability-routed delegation: cheapest tier first. ``api`` is deliberately
|
|
41
|
+
#: ABSENT — paid backends are never auto-selected by capability (the ratified "paid is last resort,
|
|
42
|
+
#: never preferred" invariant; the request-level router likewise never task-fits to ``api``). An
|
|
43
|
+
#: ``api`` backend stays reachable only via an explicit ``target=<id>`` through the billing gate.
|
|
44
|
+
TIER_RANK = {"local": 0, "sub": 1}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NoDelegateFit(RuntimeError):
|
|
48
|
+
"""Signal that no delegate target fits a requested capability — *not* a failure.
|
|
49
|
+
|
|
50
|
+
Raised by :func:`run_delegate` when ``task=`` is given but no ``can_delegate`` target's
|
|
51
|
+
``good_at`` matches (``api`` targets excluded — see :data:`TIER_RANK`). It is a **routing
|
|
52
|
+
signal**, deliberately NOT a :class:`~tanglebrain.selector.SelectionError`: it means TangleBrain
|
|
53
|
+
correctly found no cheaper/better-fit backend, so the frontier orchestrator should handle the
|
|
54
|
+
sub-task itself (it is the most capable backend available). The MCP ``delegate`` tool catches it
|
|
55
|
+
and returns that instruction to the orchestrator rather than surfacing an error.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def delegate_mcp_config_json() -> str:
|
|
60
|
+
"""Return the MCP-server config JSON that exposes the delegate to an orchestrator.
|
|
61
|
+
|
|
62
|
+
Launches the server as ``<python> -m tanglebrain.mcp_server`` (not the ``tanglebrain-delegate``
|
|
63
|
+
console script) so it resolves regardless of whether the script is on the orchestrator's PATH
|
|
64
|
+
— the current interpreter is always reachable. This is the shape claude's ``--mcp-config``
|
|
65
|
+
accepts; other CLIs reference the same server by name (see each entry's ``delegate_args``).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A JSON string: ``{"mcpServers": {"tanglebrain-delegate": {"command": ..., "args": ...}}}``.
|
|
69
|
+
"""
|
|
70
|
+
return json.dumps(
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
DELEGATE_SERVER_NAME: {
|
|
74
|
+
"command": sys.executable,
|
|
75
|
+
"args": ["-m", "tanglebrain.mcp_server"],
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def delegate_substitutions() -> dict[str, str]:
|
|
83
|
+
"""Return the token→value map applied to a roster entry's ``delegate_args``.
|
|
84
|
+
|
|
85
|
+
Tokens (so per-CLI flags stay config-driven in the roster, not hardcoded in adapters):
|
|
86
|
+
|
|
87
|
+
- ``{delegate_mcp_json}`` → the full MCP-server JSON (:func:`delegate_mcp_config_json`), for
|
|
88
|
+
CLIs that take a config blob (claude's ``--mcp-config``).
|
|
89
|
+
- ``{delegate_mcp_command}`` → the interpreter that launches the server (``sys.executable``),
|
|
90
|
+
for CLIs configured field-by-field (codex's ``-c mcp_servers...`` overrides).
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
A mapping of literal token to replacement string.
|
|
94
|
+
"""
|
|
95
|
+
return {
|
|
96
|
+
"{delegate_mcp_json}": delegate_mcp_config_json(),
|
|
97
|
+
"{delegate_mcp_command}": sys.executable,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# ``ROSTER_ENV_VAR`` ("TANGLEBRAIN_ROSTER") is re-exported from :mod:`tanglebrain.roster`, where the
|
|
101
|
+
# whole resolution order (env → ~/.config/tanglebrain/roster.yaml → packaged) now lives. An MCP
|
|
102
|
+
# client can still set it to point the delegate server at a non-default roster.
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_local_delegate(
|
|
106
|
+
prompt: str,
|
|
107
|
+
max_tokens: int = DEFAULT_DELEGATE_MAX_TOKENS,
|
|
108
|
+
roster_path: str | None = None,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Route ``prompt`` to the roster's free local tier and return its final text.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
prompt: The self-contained sub-task to delegate to the local backend.
|
|
114
|
+
max_tokens: Completion token cap (default 2048 — gpt-oss needs headroom for its
|
|
115
|
+
internal reasoning before the final answer).
|
|
116
|
+
roster_path: Optional roster YAML path. When ``None``, the roster is resolved by
|
|
117
|
+
:func:`tanglebrain.roster.default_roster_path` (``TANGLEBRAIN_ROSTER`` env →
|
|
118
|
+
``~/.config/tanglebrain/roster.yaml`` → the packaged generic example).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The local tier's final response text.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
RosterError: If the roster cannot be loaded.
|
|
125
|
+
SelectionError: If the roster has no invocable local entry.
|
|
126
|
+
AdapterError: If the local adapter cannot produce text.
|
|
127
|
+
"""
|
|
128
|
+
return run_delegate(prompt, target=None, max_tokens=max_tokens, roster_path=roster_path)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _resolve_target(roster: Roster, target: str) -> RosterEntry:
|
|
132
|
+
"""Resolve a named delegate target, enforcing the ``can_delegate`` opt-in.
|
|
133
|
+
|
|
134
|
+
An orchestrator may only delegate to entries explicitly flagged ``can_delegate`` — this stops it
|
|
135
|
+
from invoking arbitrary roster entries (e.g. another orchestrator, or an undeclared paid key) by
|
|
136
|
+
naming an id. The free local default target is reached via ``target=None`` (see
|
|
137
|
+
:func:`run_delegate`) and is intentionally **not** resolved here.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
roster: The loaded roster.
|
|
141
|
+
target: The roster id the orchestrator asked to delegate to.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The resolved, delegate-eligible :class:`~tanglebrain.roster.RosterEntry`.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
SelectionError: If no entry has that id, or the entry exists but is not a delegate target.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
entry = roster.by_id(target)
|
|
151
|
+
except KeyError:
|
|
152
|
+
configured = ", ".join(e.id for e in roster.delegate_targets()) or "(none configured)"
|
|
153
|
+
raise SelectionError(
|
|
154
|
+
f"unknown delegate target {target!r}; configured targets: {configured}"
|
|
155
|
+
)
|
|
156
|
+
if not entry.can_delegate:
|
|
157
|
+
configured = ", ".join(e.id for e in roster.delegate_targets()) or "(none configured)"
|
|
158
|
+
raise SelectionError(
|
|
159
|
+
f"entry {target!r} is not a delegate target (set can_delegate: true to allow it); "
|
|
160
|
+
f"configured targets: {configured}"
|
|
161
|
+
)
|
|
162
|
+
return entry
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def available_capabilities(roster: Roster) -> list[str]:
|
|
166
|
+
"""Return the sorted unique ``good_at`` tags across the capability-routable delegate targets.
|
|
167
|
+
|
|
168
|
+
Only ``local`` + ``sub`` ``can_delegate`` targets are considered (``api`` is never
|
|
169
|
+
capability-routed — see :data:`TIER_RANK`). Used for the no-fit message and the tool description.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
roster: The loaded roster.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The sorted, de-duplicated capability tags an orchestrator may route to by ``task``.
|
|
176
|
+
"""
|
|
177
|
+
caps: set[str] = set()
|
|
178
|
+
for entry in roster.delegate_targets():
|
|
179
|
+
if entry.tier in TIER_RANK:
|
|
180
|
+
caps.update(entry.good_at)
|
|
181
|
+
return sorted(caps)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _select_by_capability(roster: Roster, task: str) -> RosterEntry:
|
|
185
|
+
"""Select the cheapest ``can_delegate`` target whose ``good_at`` contains ``task``.
|
|
186
|
+
|
|
187
|
+
Mirrors the request-level router's task-fit at the sub-task level, but as a deterministic
|
|
188
|
+
selection (not rotation): among the ``can_delegate`` targets whose ``good_at`` lists ``task``,
|
|
189
|
+
pick the cheapest by :data:`TIER_RANK` (``local`` before ``sub``), ties broken by declared roster
|
|
190
|
+
order (``min`` is stable). ``api`` targets are excluded entirely — paid is never auto-selected.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
roster: The loaded roster.
|
|
194
|
+
task: The capability tag the orchestrator needs (a ``good_at`` value, e.g. ``code``).
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The selected :class:`~tanglebrain.roster.RosterEntry`.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
NoDelegateFit: If no eligible target advertises ``task`` — the orchestrator should handle the
|
|
201
|
+
sub-task itself.
|
|
202
|
+
"""
|
|
203
|
+
candidates = [
|
|
204
|
+
e for e in roster.delegate_targets() if e.tier in TIER_RANK and task in e.good_at
|
|
205
|
+
]
|
|
206
|
+
if not candidates:
|
|
207
|
+
available = ", ".join(available_capabilities(roster)) or "(none configured)"
|
|
208
|
+
raise NoDelegateFit(
|
|
209
|
+
f"no delegate target is good_at {task!r}; available capabilities: {available}"
|
|
210
|
+
)
|
|
211
|
+
return min(candidates, key=lambda e: TIER_RANK[e.tier])
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def run_delegate(
|
|
215
|
+
prompt: str,
|
|
216
|
+
target: str | None = None,
|
|
217
|
+
task: str | None = None,
|
|
218
|
+
max_tokens: int = DEFAULT_DELEGATE_MAX_TOKENS,
|
|
219
|
+
roster_path: str | None = None,
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Route ``prompt`` to a configured delegate target and return its final text.
|
|
222
|
+
|
|
223
|
+
Selection precedence:
|
|
224
|
+
|
|
225
|
+
1. ``target`` (explicit id) — routes to that ``can_delegate`` entry (see :func:`_resolve_target`).
|
|
226
|
+
Wins when both ``target`` and ``task`` are given (an explicit id is the most specific request).
|
|
227
|
+
2. ``task`` (capability) — TangleBrain picks the cheapest ``can_delegate`` target whose
|
|
228
|
+
``good_at`` contains ``task`` (see :func:`_select_by_capability`); ``api`` targets are never
|
|
229
|
+
auto-selected. No fit raises :class:`NoDelegateFit` (the orchestrator handles it itself).
|
|
230
|
+
3. neither — the free local default tier (same as :func:`run_local_delegate`).
|
|
231
|
+
|
|
232
|
+
The selected target is built as a **leaf** (``inject_delegate=False``) — a delegate target never
|
|
233
|
+
receives its own delegate tool, so there is no recursive delegation. ``api`` targets (reachable
|
|
234
|
+
only via explicit ``target``) flow through the existing billing gate in
|
|
235
|
+
:func:`tanglebrain.selector.build_adapter`; ``cli`` targets keep their env-scrub. Each sub-call is
|
|
236
|
+
**metered** as a ``kind="delegate"`` usage record, kept out of the spend-avoided headline (see
|
|
237
|
+
:func:`tanglebrain.measurement.rollup`).
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
prompt: The self-contained sub-task to delegate. Give it everything it needs — the target
|
|
241
|
+
backend has no access to the orchestrator's conversation context.
|
|
242
|
+
target: The roster id of a ``can_delegate`` backend (explicit). Takes precedence over ``task``.
|
|
243
|
+
task: A capability tag (a ``good_at`` value) to route by fit when no explicit ``target`` is
|
|
244
|
+
given. ``None`` with no ``target`` uses the free local default.
|
|
245
|
+
max_tokens: Completion token cap (default 2048 — a local reasoning model needs headroom for
|
|
246
|
+
its internal reasoning before the final answer).
|
|
247
|
+
roster_path: Optional roster YAML path. When ``None``, the roster is resolved by
|
|
248
|
+
:func:`tanglebrain.roster.default_roster_path` (``TANGLEBRAIN_ROSTER`` env →
|
|
249
|
+
``~/.config/tanglebrain/roster.yaml`` → the packaged generic example).
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The target backend's final response text.
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
RosterError: If the roster cannot be loaded.
|
|
256
|
+
SelectionError: If ``target`` is unknown, not a delegate target, or (for the local default)
|
|
257
|
+
the roster has no invocable local entry.
|
|
258
|
+
NoDelegateFit: If ``task`` is given (and no ``target``) but no eligible target fits it.
|
|
259
|
+
AdapterError: If the target is an ``api`` entry while billing is gated off / disabled, or
|
|
260
|
+
the target's adapter cannot produce text.
|
|
261
|
+
"""
|
|
262
|
+
roster = load_roster(roster_path)
|
|
263
|
+
if target is not None:
|
|
264
|
+
entry = _resolve_target(roster, target)
|
|
265
|
+
elif task is not None:
|
|
266
|
+
entry = _select_by_capability(roster, task)
|
|
267
|
+
else:
|
|
268
|
+
entry = select_local(roster)
|
|
269
|
+
adapter = build_adapter(entry, inject_delegate=False)
|
|
270
|
+
text = adapter.run(prompt, {"max_tokens": max_tokens})
|
|
271
|
+
# Meter the sub-call for orchestration-tree observability. Tagged kind="delegate" so the rollup
|
|
272
|
+
# keeps it OUT of the spend-avoided headline (the parent task already credits the whole job) and
|
|
273
|
+
# in a separate by-backend breakdown. The parent task id, propagated from the orchestrator via
|
|
274
|
+
# PARENT_TASK_ID_ENV, links this sub-call to its top-level task (absent → recorded as unlinked).
|
|
275
|
+
#
|
|
276
|
+
# LOAD-BEARING ASSUMPTION (verified live for claude, not asserted by any hermetic test): the
|
|
277
|
+
# linkage depends on the orchestrator CLI forwarding its environment to the MCP delegate child it
|
|
278
|
+
# spawns. That forwarding is the orchestrator's behavior, not TangleBrain's — if a CLI stops
|
|
279
|
+
# forwarding env, or a roster adds TANGLEBRAIN_TASK_ID to an orchestrator's invoke.scrub_env, the
|
|
280
|
+
# linkage silently degrades to "unlinked" (never an error — the delegation itself is unaffected).
|
|
281
|
+
#
|
|
282
|
+
# record_task never raises; the extra guard is belt-and-suspenders — metering must never break a
|
|
283
|
+
# delegation.
|
|
284
|
+
try:
|
|
285
|
+
record_task(
|
|
286
|
+
path="delegate",
|
|
287
|
+
entry=entry,
|
|
288
|
+
prompt=prompt,
|
|
289
|
+
response=text,
|
|
290
|
+
kind="delegate",
|
|
291
|
+
parent_task_id=os.environ.get(PARENT_TASK_ID_ENV),
|
|
292
|
+
)
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
return text
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
#: Concurrency cap to use only when :func:`os.cpu_count` can't report a core count (rare).
|
|
299
|
+
DEFAULT_CONCURRENCY_FALLBACK = 4
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _default_concurrency() -> int:
|
|
303
|
+
"""Derive the default fan-out concurrency from this machine's core count.
|
|
304
|
+
|
|
305
|
+
The true limit on concurrent delegation is the *backend's* parallelism (e.g. a local model
|
|
306
|
+
server's ``OLLAMA_NUM_PARALLEL``), which TangleBrain can't portably introspect — so the default
|
|
307
|
+
is a system-derived proxy that scales with the machine. An operator who knows their backend pins
|
|
308
|
+
the real number via ``settings.delegate_max_concurrency``.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
``os.cpu_count()``, or :data:`DEFAULT_CONCURRENCY_FALLBACK` if the OS won't report it.
|
|
312
|
+
"""
|
|
313
|
+
return os.cpu_count() or DEFAULT_CONCURRENCY_FALLBACK
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _effective_concurrency(settings: Settings, max_concurrency: int | None) -> int:
|
|
317
|
+
"""Resolve the concurrency cap: operator setting (or derived) default, optionally lowered.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
settings: Global settings; ``delegate_max_concurrency`` pins the cap when set.
|
|
321
|
+
max_concurrency: Optional per-call override that may **lower** the cap, never raise it.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
The number of workers to use — at least 1.
|
|
325
|
+
"""
|
|
326
|
+
base = settings.delegate_max_concurrency or _default_concurrency()
|
|
327
|
+
if max_concurrency is not None:
|
|
328
|
+
base = min(base, max_concurrency)
|
|
329
|
+
return max(1, base)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _run_one_of_many(item: object, index: int, roster_path: str | None) -> dict:
|
|
333
|
+
"""Run a single ``delegate_many`` item and map its outcome to a per-item result dict.
|
|
334
|
+
|
|
335
|
+
Never raises — every failure mode becomes a result with a ``status`` so one bad sub-task can't
|
|
336
|
+
sink the batch.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
item: One task descriptor, expected to be a mapping with a ``prompt`` (str) and optional
|
|
340
|
+
``target`` / ``task`` / ``max_tokens``.
|
|
341
|
+
index: The item's position in the input list (echoed back for correlation).
|
|
342
|
+
roster_path: Optional roster path threaded to :func:`run_delegate`.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
``{"index", "status", ...}`` — ``status`` is ``ok`` (+``text``), ``no_fit`` (+``message``),
|
|
346
|
+
or ``error`` (+``error``).
|
|
347
|
+
"""
|
|
348
|
+
if not isinstance(item, dict) or not isinstance(item.get("prompt"), str):
|
|
349
|
+
return {
|
|
350
|
+
"index": index,
|
|
351
|
+
"status": "error",
|
|
352
|
+
"error": "each task must be a mapping with a string 'prompt'",
|
|
353
|
+
}
|
|
354
|
+
try:
|
|
355
|
+
text = run_delegate(
|
|
356
|
+
item["prompt"],
|
|
357
|
+
target=item.get("target"),
|
|
358
|
+
task=item.get("task"),
|
|
359
|
+
max_tokens=item.get("max_tokens", DEFAULT_DELEGATE_MAX_TOKENS),
|
|
360
|
+
roster_path=roster_path,
|
|
361
|
+
)
|
|
362
|
+
return {"index": index, "status": "ok", "text": text}
|
|
363
|
+
except NoDelegateFit as exc:
|
|
364
|
+
return {
|
|
365
|
+
"index": index,
|
|
366
|
+
"status": "no_fit",
|
|
367
|
+
"message": (
|
|
368
|
+
f"{exc}. Handle this sub-task yourself — you are the most capable backend available."
|
|
369
|
+
),
|
|
370
|
+
}
|
|
371
|
+
except Exception as exc: # AdapterError / SelectionError / RosterError / anything: isolate it
|
|
372
|
+
return {"index": index, "status": "error", "error": str(exc)}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def run_delegate_many(
|
|
376
|
+
tasks: list,
|
|
377
|
+
max_concurrency: int | None = None,
|
|
378
|
+
roster_path: str | None = None,
|
|
379
|
+
settings: Settings | None = None,
|
|
380
|
+
) -> list[dict]:
|
|
381
|
+
"""Fan out several sub-tasks concurrently and collect their results in input order.
|
|
382
|
+
|
|
383
|
+
The parallel-dispatch primitive: each item is routed independently via :func:`run_delegate` (so a
|
|
384
|
+
batch can mix targets — grunt to local, code to a sub), run on a thread pool, and collected.
|
|
385
|
+
Concurrency is bounded by :func:`_effective_concurrency` (operator setting or system-derived
|
|
386
|
+
default, optionally lowered per call). This is **dispatch + collect only** — synthesising the
|
|
387
|
+
results is the orchestrator's job.
|
|
388
|
+
|
|
389
|
+
Partial failure never sinks the batch: each result carries a ``status`` (``ok`` / ``no_fit`` /
|
|
390
|
+
``error``), and results are returned **ordered by input index** even though workers finish out of
|
|
391
|
+
order. Each dispatched sub-call is metered (via :func:`run_delegate`) as a ``kind="delegate"``
|
|
392
|
+
usage record, kept out of the spend-avoided headline.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
tasks: A list of task descriptors, each a mapping ``{prompt, target?, task?, max_tokens?}``.
|
|
396
|
+
max_concurrency: Optional per-call cap that may lower (never exceed) the effective concurrency.
|
|
397
|
+
roster_path: Optional roster YAML path, threaded to each :func:`run_delegate`.
|
|
398
|
+
settings: Global settings (for the concurrency cap). Defaults to :func:`load_settings`.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
One result dict per input task, in input order:
|
|
402
|
+
``{"index", "status": "ok"|"no_fit"|"error", "text"|"message"|"error"}``.
|
|
403
|
+
|
|
404
|
+
Raises:
|
|
405
|
+
ValueError: If ``tasks`` is not a list (a batch-level precondition). Per-item failures do
|
|
406
|
+
**not** raise — they surface as ``status: "error"`` entries.
|
|
407
|
+
"""
|
|
408
|
+
if not isinstance(tasks, list):
|
|
409
|
+
raise ValueError(f"tasks must be a list of task mappings, got {type(tasks).__name__}")
|
|
410
|
+
if not tasks:
|
|
411
|
+
return []
|
|
412
|
+
if settings is None:
|
|
413
|
+
settings = load_settings()
|
|
414
|
+
|
|
415
|
+
workers = _effective_concurrency(settings, max_concurrency)
|
|
416
|
+
results: list[dict | None] = [None] * len(tasks)
|
|
417
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
418
|
+
futures = {
|
|
419
|
+
pool.submit(_run_one_of_many, item, i, roster_path): i
|
|
420
|
+
for i, item in enumerate(tasks)
|
|
421
|
+
}
|
|
422
|
+
for future in futures:
|
|
423
|
+
index = futures[future]
|
|
424
|
+
results[index] = future.result()
|
|
425
|
+
# Every index is filled by exactly one worker (one future per task, _run_one_of_many never
|
|
426
|
+
# raises). Preserve batch length + order; surface an (impossible) hole as a loud error entry
|
|
427
|
+
# rather than silently shrinking the result list.
|
|
428
|
+
return [
|
|
429
|
+
r if r is not None else {"index": i, "status": "error", "error": "internal: no result produced"}
|
|
430
|
+
for i, r in enumerate(results)
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def delegate_targets(roster_path: str | None = None) -> list[dict]:
|
|
435
|
+
"""Return the configured delegate-target menu — the backends an orchestrator may target.
|
|
436
|
+
|
|
437
|
+
Each target is described by what an orchestrator needs to pick by fit: its id, tier, ``good_at``
|
|
438
|
+
tags, cost annotation, and invoke kind. Secret-safe — emits no ``key_ref`` or credential, and
|
|
439
|
+
resolves nothing. The free local default target (reached via ``target=None``) is intentionally
|
|
440
|
+
not listed here unless it is also explicitly flagged ``can_delegate``.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
roster_path: Optional roster YAML path. When ``None``, resolved by
|
|
444
|
+
:func:`tanglebrain.roster.default_roster_path`.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
One dict per ``can_delegate`` entry, in declared order:
|
|
448
|
+
``{"id", "tier", "good_at", "cost", "kind"}``.
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
RosterError: If the roster cannot be loaded.
|
|
452
|
+
"""
|
|
453
|
+
roster = load_roster(roster_path)
|
|
454
|
+
return [
|
|
455
|
+
{
|
|
456
|
+
"id": entry.id,
|
|
457
|
+
"tier": entry.tier,
|
|
458
|
+
"good_at": list(entry.good_at),
|
|
459
|
+
"cost": entry.cost,
|
|
460
|
+
"kind": entry.invoke.kind,
|
|
461
|
+
}
|
|
462
|
+
for entry in roster.delegate_targets()
|
|
463
|
+
]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _render_target_menu(targets: list[dict]) -> str:
|
|
467
|
+
"""Render the delegate-target menu as human-readable lines for a tool description.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
targets: The menu from :func:`delegate_targets`.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
A newline-joined bullet list (one line per target), or a short note when the menu is empty.
|
|
474
|
+
"""
|
|
475
|
+
if not targets:
|
|
476
|
+
return (
|
|
477
|
+
" (no additional delegate targets configured — only the default local target, "
|
|
478
|
+
"via delegate_local or delegate with target omitted, is available)"
|
|
479
|
+
)
|
|
480
|
+
lines = []
|
|
481
|
+
for target in targets:
|
|
482
|
+
skills = ", ".join(target["good_at"]) or "—"
|
|
483
|
+
cost = target["cost"] or target["tier"]
|
|
484
|
+
lines.append(f" - {target['id']}: good_at [{skills}] (cost: {cost})")
|
|
485
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""TangleBrain knob GUI — a thin, localhost-only web panel over the roster/pricing config.
|
|
2
|
+
|
|
3
|
+
View the roster, pricing, and the local spend-avoided rollup, and run a prompt through the router.
|
|
4
|
+
A focused set of knobs (pricing, and per-entry roster fields) is editable, writing config back to
|
|
5
|
+
YAML with validation, an atomic write, a backup, and comment-preserving edits.
|
|
6
|
+
|
|
7
|
+
Zero new runtime dependencies — the server is stdlib :mod:`http.server` and the page is a single
|
|
8
|
+
vanilla HTML/CSS/JS file. See :mod:`tanglebrain.gui.server` for the entry point and
|
|
9
|
+
:mod:`tanglebrain.gui.views` for the (testable, transport-free) view functions.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Knob GUI HTTP server — stdlib :mod:`http.server`, localhost-only, zero new deps.
|
|
2
|
+
|
|
3
|
+
The handler is a thin shell over :func:`dispatch`, a pure ``(method, path, body) -> (status,
|
|
4
|
+
content_type, body)`` function holding all routing so it can be tested without a socket. The page
|
|
5
|
+
itself is the packaged single-file ``static/index.html``.
|
|
6
|
+
|
|
7
|
+
Launched via the ``tanglebrain-gui`` console script. Binds ``127.0.0.1`` only — not configurable:
|
|
8
|
+
the panel runs prompts (spending real backend quota) and reads the roster, so it must never be
|
|
9
|
+
network-exposed.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
+
from importlib import resources
|
|
17
|
+
|
|
18
|
+
from tanglebrain.gui.views import (
|
|
19
|
+
DEFAULT_PORT,
|
|
20
|
+
run_prompt,
|
|
21
|
+
save_pricing_view,
|
|
22
|
+
save_roster_view,
|
|
23
|
+
view_pricing,
|
|
24
|
+
view_roster,
|
|
25
|
+
view_settings,
|
|
26
|
+
view_stats,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_JSON = "application/json; charset=utf-8"
|
|
30
|
+
_HTML = "text/html; charset=utf-8"
|
|
31
|
+
_PNG = "image/png"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _index_html() -> bytes:
|
|
35
|
+
"""Read the packaged single-file panel (``tanglebrain/gui/static/index.html``)."""
|
|
36
|
+
return (resources.files("tanglebrain.gui") / "static" / "index.html").read_bytes()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _logo_png() -> bytes:
|
|
40
|
+
"""Read the packaged panel logo (``tanglebrain/gui/static/logo.png``)."""
|
|
41
|
+
return (resources.files("tanglebrain.gui") / "static" / "logo.png").read_bytes()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _json(status: int, obj: object) -> tuple[int, str, bytes]:
|
|
45
|
+
"""Serialize ``obj`` as a JSON HTTP response triple."""
|
|
46
|
+
return status, _JSON, json.dumps(obj).encode("utf-8")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dispatch(method: str, path: str, body: bytes = b"") -> tuple[int, str, bytes]:
|
|
50
|
+
"""Route one request to a view and return ``(status, content_type, body)``.
|
|
51
|
+
|
|
52
|
+
Pure and side-effect-light (only the views touch config/log/subprocess), so tests call it
|
|
53
|
+
directly with no socket. The query string, if any, is ignored.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
method: HTTP method (``GET``/``POST``).
|
|
57
|
+
path: Request path (may include a ``?query``).
|
|
58
|
+
body: Raw request body bytes (for ``POST``).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
``(status_code, content_type, body_bytes)``.
|
|
62
|
+
"""
|
|
63
|
+
path = path.split("?", 1)[0]
|
|
64
|
+
|
|
65
|
+
if method == "GET":
|
|
66
|
+
if path in ("/", "/index.html"):
|
|
67
|
+
return 200, _HTML, _index_html()
|
|
68
|
+
if path == "/logo.png":
|
|
69
|
+
return 200, _PNG, _logo_png()
|
|
70
|
+
view = {
|
|
71
|
+
"/api/roster": view_roster,
|
|
72
|
+
"/api/pricing": view_pricing,
|
|
73
|
+
"/api/stats": view_stats,
|
|
74
|
+
"/api/settings": view_settings,
|
|
75
|
+
}.get(path)
|
|
76
|
+
if view is not None:
|
|
77
|
+
try:
|
|
78
|
+
return _json(200, view())
|
|
79
|
+
except Exception as exc: # a read view failed (e.g. malformed roster) — clean JSON, not a 500 traceback
|
|
80
|
+
return _json(500, {"error": str(exc)})
|
|
81
|
+
return _json(404, {"error": "not found"})
|
|
82
|
+
|
|
83
|
+
if method == "POST":
|
|
84
|
+
action = {
|
|
85
|
+
"/api/run": run_prompt,
|
|
86
|
+
"/api/pricing": save_pricing_view,
|
|
87
|
+
"/api/roster": save_roster_view,
|
|
88
|
+
}.get(path)
|
|
89
|
+
if action is not None:
|
|
90
|
+
try:
|
|
91
|
+
payload = json.loads(body.decode("utf-8")) if body else {}
|
|
92
|
+
except (ValueError, UnicodeDecodeError):
|
|
93
|
+
return _json(400, {"ok": False, "error": "invalid JSON body"})
|
|
94
|
+
if not isinstance(payload, dict):
|
|
95
|
+
return _json(400, {"ok": False, "error": "body must be a JSON object"})
|
|
96
|
+
result = action(payload)
|
|
97
|
+
return _json(200 if result.get("ok") else 400, result)
|
|
98
|
+
return _json(404, {"error": "not found"})
|
|
99
|
+
|
|
100
|
+
return _json(405, {"error": "method not allowed"})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Handler(BaseHTTPRequestHandler):
|
|
104
|
+
"""Thin HTTP handler delegating all routing to :func:`dispatch`."""
|
|
105
|
+
|
|
106
|
+
def do_GET(self) -> None: # noqa: N802 (stdlib naming)
|
|
107
|
+
"""Handle a GET by dispatching and writing the response."""
|
|
108
|
+
self._respond(*dispatch("GET", self.path))
|
|
109
|
+
|
|
110
|
+
def do_POST(self) -> None: # noqa: N802 (stdlib naming)
|
|
111
|
+
"""Handle a POST by reading the body, dispatching, and writing the response."""
|
|
112
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
113
|
+
body = self.rfile.read(length) if length else b""
|
|
114
|
+
self._respond(*dispatch("POST", self.path, body))
|
|
115
|
+
|
|
116
|
+
def _respond(self, status: int, content_type: str, body: bytes) -> None:
|
|
117
|
+
"""Write a complete HTTP response."""
|
|
118
|
+
self.send_response(status)
|
|
119
|
+
self.send_header("Content-Type", content_type)
|
|
120
|
+
self.send_header("Content-Length", str(len(body)))
|
|
121
|
+
self.end_headers()
|
|
122
|
+
self.wfile.write(body)
|
|
123
|
+
|
|
124
|
+
def log_message(self, *args: object) -> None:
|
|
125
|
+
"""Silence the default per-request stderr logging."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv: list[str] | None = None) -> int:
|
|
129
|
+
"""Console entry point: serve the knob panel until interrupted.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
argv: Optional argument list (defaults to ``sys.argv[1:]``).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Process exit code (``0``).
|
|
136
|
+
"""
|
|
137
|
+
parser = argparse.ArgumentParser(
|
|
138
|
+
prog="tanglebrain-gui",
|
|
139
|
+
description="Serve the TangleBrain knob panel (read-only) on localhost.",
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument(
|
|
142
|
+
"--port", type=int, default=DEFAULT_PORT,
|
|
143
|
+
help=f"Port to bind (default {DEFAULT_PORT}).",
|
|
144
|
+
)
|
|
145
|
+
args = parser.parse_args(argv)
|
|
146
|
+
|
|
147
|
+
# Loopback only, not configurable: the panel is unauthenticated and runs prompts / reads the
|
|
148
|
+
# roster, so it must never be reachable off the machine.
|
|
149
|
+
host = "127.0.0.1"
|
|
150
|
+
server = ThreadingHTTPServer((host, args.port), Handler)
|
|
151
|
+
print(f"TangleBrain knob panel: http://{host}:{args.port}/ (Ctrl-C to stop)")
|
|
152
|
+
try:
|
|
153
|
+
server.serve_forever()
|
|
154
|
+
except KeyboardInterrupt:
|
|
155
|
+
print("\nstopping…")
|
|
156
|
+
finally:
|
|
157
|
+
server.server_close()
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
raise SystemExit(main())
|