lithe 0.9.4__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.
- lithe/__init__.py +64 -0
- lithe/actions.py +105 -0
- lithe/bundles/__init__.py +62 -0
- lithe/bundles/_textmatch.py +115 -0
- lithe/bundles/admin.py +133 -0
- lithe/bundles/download.py +272 -0
- lithe/bundles/host.py +602 -0
- lithe/bundles/images.py +362 -0
- lithe/bundles/mcp.py +742 -0
- lithe/bundles/patch.py +487 -0
- lithe/bundles/sandbox.py +249 -0
- lithe/bundles/skills.py +485 -0
- lithe/bundles/store/__init__.py +18 -0
- lithe/bundles/store/jsonl.py +392 -0
- lithe/bundles/store/protocol.py +214 -0
- lithe/bundles/subagents.py +543 -0
- lithe/bundles/todos.py +275 -0
- lithe/bundles/workspace.py +654 -0
- lithe/context.py +82 -0
- lithe/events.py +85 -0
- lithe/llm.py +304 -0
- lithe/memory.py +353 -0
- lithe/modes.py +70 -0
- lithe/py.typed +0 -0
- lithe/runtime.py +899 -0
- lithe/skills.py +28 -0
- lithe/tools.py +281 -0
- lithe/transports.py +726 -0
- lithe-0.9.4.dist-info/METADATA +334 -0
- lithe-0.9.4.dist-info/RECORD +33 -0
- lithe-0.9.4.dist-info/WHEEL +5 -0
- lithe-0.9.4.dist-info/licenses/LICENSE +21 -0
- lithe-0.9.4.dist-info/top_level.txt +1 -0
lithe/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""lithe — a reusable, storage-free ReAct agent kernel.
|
|
2
|
+
|
|
3
|
+
Hosts (e.g. a thesis-writing application) plug in their own tools, prompt,
|
|
4
|
+
domain model and persistence. The kernel knows nothing about how runs are
|
|
5
|
+
stored, or even whether they are: it emits events (which a host may sink to a
|
|
6
|
+
DB, a file, or drop) and reads prior turns through an optional memory provider
|
|
7
|
+
(added in a later step).
|
|
8
|
+
|
|
9
|
+
Layout:
|
|
10
|
+
- llm : OpenAI-compatible chat client (retry/backoff/jitter)
|
|
11
|
+
- events : Event vocabulary, EventSink, SSE serialization
|
|
12
|
+
- context : AgentContext (typed, mapping-compatible)
|
|
13
|
+
- modes : AgentMode / ToolCategory + category filtering
|
|
14
|
+
- actions : Action model + UndoEngine (pure logic, no storage)
|
|
15
|
+
- tools : ToolSpec / ToolResult / ToolRegistry (dispatch + undo wiring)
|
|
16
|
+
"""
|
|
17
|
+
# Single source of truth for the package version; pyproject reads it via
|
|
18
|
+
# [tool.setuptools.dynamic] (statically, without importing this module).
|
|
19
|
+
__version__ = "0.9.4"
|
|
20
|
+
|
|
21
|
+
from lithe.actions import ( # noqa: F401
|
|
22
|
+
Action, Reverter, UndoEngine, UndoReport,
|
|
23
|
+
)
|
|
24
|
+
from lithe.context import AgentContext # noqa: F401
|
|
25
|
+
from lithe.events import Event, EventSink, EventType, to_sse # noqa: F401
|
|
26
|
+
from lithe.llm import ( # noqa: F401
|
|
27
|
+
bearer_headers, chat_completion, first_content, strip_think,
|
|
28
|
+
)
|
|
29
|
+
from lithe.memory import ( # noqa: F401
|
|
30
|
+
DEFAULT_REPLAY_LIMIT, DEFAULT_TOOL_RESULT_CAP, MemoryProvider,
|
|
31
|
+
recap_text, replay_messages, reasoning_summary_text, run_timeline,
|
|
32
|
+
truncate_tool_result, window_with_recap,
|
|
33
|
+
)
|
|
34
|
+
from lithe.modes import ( # noqa: F401
|
|
35
|
+
AgentMode, ToolCategory, categories_for, register_mode,
|
|
36
|
+
)
|
|
37
|
+
from lithe.runtime import ( # noqa: F401
|
|
38
|
+
DEFAULT_CONTEXT_BUDGET, DEFAULT_REPEAT_CALL_LIMIT, AgentRuntime,
|
|
39
|
+
LLMConfig, RunStats,
|
|
40
|
+
)
|
|
41
|
+
from lithe.tools import ( # noqa: F401
|
|
42
|
+
ToolHandler, ToolMiddleware, ToolRegistry, ToolResult, ToolSpec,
|
|
43
|
+
)
|
|
44
|
+
from lithe.transports import ( # noqa: F401
|
|
45
|
+
ChatCompletionsTransport, LLMTransport, ResponsesTransport, make_transport,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Action", "AgentContext", "AgentMode", "AgentRuntime",
|
|
50
|
+
"ChatCompletionsTransport",
|
|
51
|
+
"DEFAULT_CONTEXT_BUDGET", "DEFAULT_REPEAT_CALL_LIMIT",
|
|
52
|
+
"DEFAULT_REPLAY_LIMIT", "DEFAULT_TOOL_RESULT_CAP",
|
|
53
|
+
"Event", "EventSink", "EventType", "LLMConfig", "LLMTransport",
|
|
54
|
+
"MemoryProvider",
|
|
55
|
+
"ResponsesTransport",
|
|
56
|
+
"Reverter", "RunStats",
|
|
57
|
+
"ToolCategory", "ToolHandler", "ToolMiddleware",
|
|
58
|
+
"ToolRegistry", "ToolResult", "ToolSpec", "UndoEngine", "UndoReport",
|
|
59
|
+
"bearer_headers", "categories_for", "chat_completion", "first_content",
|
|
60
|
+
"make_transport", "register_mode",
|
|
61
|
+
"recap_text", "replay_messages", "reasoning_summary_text",
|
|
62
|
+
"run_timeline", "strip_think",
|
|
63
|
+
"to_sse", "truncate_tool_result", "window_with_recap",
|
|
64
|
+
]
|
lithe/actions.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Reversible mutations and the undo engine.
|
|
2
|
+
|
|
3
|
+
A write tool records an :class:`Action` (what it changed: kind/target/old/new)
|
|
4
|
+
and registers a :class:`Reverter` that knows how to reverse that kind.
|
|
5
|
+
:class:`UndoEngine` replays the reversals newest-first. None of this touches
|
|
6
|
+
storage: the engine takes a list of actions (from this run's in-memory state,
|
|
7
|
+
or read back by a host from wherever it persisted them), so undo works with or
|
|
8
|
+
without a database.
|
|
9
|
+
|
|
10
|
+
Reverters may be sync or async (``async def`` / coroutine-returning) — an
|
|
11
|
+
async reverter can roll back through remote APIs; :meth:`UndoEngine.undo`
|
|
12
|
+
awaits whatever the reverter returns.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import inspect
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
from lithe.context import AgentContext
|
|
22
|
+
|
|
23
|
+
# Action statuses that mean "the mutation is currently in effect" and so should
|
|
24
|
+
# be reverted. Pending/proposed changes that were never applied are skipped.
|
|
25
|
+
_REVERTIBLE = frozenset({"applied", "approved"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Action:
|
|
30
|
+
"""One recorded mutation produced by a write tool.
|
|
31
|
+
|
|
32
|
+
``id`` is opaque and host-assigned (e.g. a DB row id); the kernel only uses
|
|
33
|
+
it for ordering/reporting. ``old_value``/``new_value`` are domain-shaped and
|
|
34
|
+
interpreted by the matching reverter.
|
|
35
|
+
"""
|
|
36
|
+
kind: str
|
|
37
|
+
target: str
|
|
38
|
+
old_value: Any = None
|
|
39
|
+
new_value: Any = None
|
|
40
|
+
status: str = "applied"
|
|
41
|
+
id: Any = None
|
|
42
|
+
subagent: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# A reverter reverses one action kind: ``fn(action, ctx)`` returning None (sync)
|
|
46
|
+
# or an awaitable (async, e.g. rolling back through a remote API). It is
|
|
47
|
+
# best-effort: a partial failure (e.g. a file already gone) must not abort the
|
|
48
|
+
# rest of an undo run, so each reverter owns its own error handling.
|
|
49
|
+
Reverter = Callable[[Action, AgentContext], Any]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class UndoReport:
|
|
54
|
+
ok: bool = True
|
|
55
|
+
reverted: int = 0
|
|
56
|
+
skipped: int = 0
|
|
57
|
+
errors: list[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UndoEngine:
|
|
61
|
+
"""Revert a list of actions newest-first via registered reverters.
|
|
62
|
+
|
|
63
|
+
Pure logic: no storage. Unknown kinds and non-revertible statuses are
|
|
64
|
+
skipped; a reverter that raises is recorded as an error (not fatal) so the
|
|
65
|
+
rest of the run still reverts.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, reverters: dict[str, Reverter] | None = None):
|
|
69
|
+
self._reverters: dict[str, Reverter] = dict(reverters or {})
|
|
70
|
+
|
|
71
|
+
def register(self, kind: str, reverter: Reverter) -> None:
|
|
72
|
+
self._reverters[kind] = reverter
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def reverters(self) -> dict[str, Reverter]:
|
|
76
|
+
return dict(self._reverters)
|
|
77
|
+
|
|
78
|
+
async def undo(self, actions: list[Action], ctx: AgentContext) -> UndoReport:
|
|
79
|
+
"""Revert *actions* newest-first, awaiting async reverters.
|
|
80
|
+
|
|
81
|
+
Pure logic: no storage. Unknown kinds and non-revertible statuses are
|
|
82
|
+
skipped; a reverter that raises is recorded as an error (not fatal) so
|
|
83
|
+
the rest of the run still reverts. A reverter's awaitable result is
|
|
84
|
+
awaited — an error *inside* the awaitable counts the same as a sync
|
|
85
|
+
raise.
|
|
86
|
+
"""
|
|
87
|
+
report = UndoReport()
|
|
88
|
+
for action in reversed(actions):
|
|
89
|
+
if action.status not in _REVERTIBLE:
|
|
90
|
+
report.skipped += 1
|
|
91
|
+
continue
|
|
92
|
+
fn = self._reverters.get(action.kind)
|
|
93
|
+
if fn is None:
|
|
94
|
+
report.skipped += 1
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
result = fn(action, ctx)
|
|
98
|
+
if inspect.isawaitable(result):
|
|
99
|
+
await result
|
|
100
|
+
except Exception as exc: # noqa: BLE001
|
|
101
|
+
report.errors.append(f"{action.kind} {action.target}: {exc}")
|
|
102
|
+
report.ok = False
|
|
103
|
+
continue
|
|
104
|
+
report.reverted += 1
|
|
105
|
+
return report
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Optional capability bundles for lithe.
|
|
2
|
+
|
|
3
|
+
These are NOT part of the zero-I/O core engine; they are reusable, host-agnostic
|
|
4
|
+
capabilities a host opts into. The core never imports from here, so
|
|
5
|
+
``import lithe`` alone stays storage- and I/O-free — bundles are pulled in
|
|
6
|
+
only when a host needs them.
|
|
7
|
+
"""
|
|
8
|
+
from lithe.bundles.admin import ( # noqa: F401
|
|
9
|
+
ToolPackage, check_packages, list_tool_packages_admin, list_tools_admin,
|
|
10
|
+
package_meta, package_of, tool_categories,
|
|
11
|
+
)
|
|
12
|
+
from lithe.bundles.download import ( # noqa: F401
|
|
13
|
+
register_download_tools,
|
|
14
|
+
)
|
|
15
|
+
from lithe.bundles.host import ( # noqa: F401
|
|
16
|
+
AgentHost, DictToolAdapter, StoreSink, assemble_messages, undo_run,
|
|
17
|
+
)
|
|
18
|
+
from lithe.bundles.images import ( # noqa: F401
|
|
19
|
+
register_image_tools,
|
|
20
|
+
)
|
|
21
|
+
from lithe.bundles.mcp import ( # noqa: F401
|
|
22
|
+
MCPServerConfig, MCPManager, parse_servers,
|
|
23
|
+
)
|
|
24
|
+
from lithe.bundles.patch import ( # noqa: F401
|
|
25
|
+
register_apply_patch_tool,
|
|
26
|
+
)
|
|
27
|
+
from lithe.bundles.sandbox import CodeRunner, register_code_tools # noqa: F401
|
|
28
|
+
from lithe.bundles.skills import ( # noqa: F401
|
|
29
|
+
PACKAGE_FILE, ROOT_PACKAGE, SKILL_MAIN, RemoteSkillSource, SkillLibrary,
|
|
30
|
+
SkillPackages, register_skill_tool,
|
|
31
|
+
)
|
|
32
|
+
from lithe.bundles.store import ( # noqa: F401
|
|
33
|
+
BlobStore, ConversationStore, JsonlRunStore, RunStore, StoredAction,
|
|
34
|
+
StoredMessage, StoredRun,
|
|
35
|
+
)
|
|
36
|
+
from lithe.bundles.subagents import ( # noqa: F401
|
|
37
|
+
SubagentEngine, SubagentRoster, SubagentSpec, make_delegate_tool,
|
|
38
|
+
make_parallel_delegate_tool, register_delegate_tool, register_delegate_tools,
|
|
39
|
+
)
|
|
40
|
+
from lithe.bundles.todos import ( # noqa: F401
|
|
41
|
+
JsonTodoStore, TodoStore, register_todo_tools, todos_block,
|
|
42
|
+
)
|
|
43
|
+
from lithe.bundles.workspace import Workspace, register_file_tools # noqa: F401
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AgentHost", "BlobStore", "CodeRunner", "ConversationStore",
|
|
47
|
+
"DictToolAdapter", "JsonTodoStore", "JsonlRunStore", "MCPManager",
|
|
48
|
+
"MCPServerConfig", "RunStore",
|
|
49
|
+
"PACKAGE_FILE", "ROOT_PACKAGE", "SKILL_MAIN", "RemoteSkillSource",
|
|
50
|
+
"SkillLibrary", "SkillPackages", "StoreSink", "StoredAction",
|
|
51
|
+
"StoredMessage", "StoredRun",
|
|
52
|
+
"SubagentEngine", "SubagentRoster", "SubagentSpec", "TodoStore",
|
|
53
|
+
"ToolPackage", "Workspace",
|
|
54
|
+
"assemble_messages", "check_packages", "list_tool_packages_admin",
|
|
55
|
+
"list_tools_admin", "make_delegate_tool", "make_parallel_delegate_tool",
|
|
56
|
+
"package_meta", "package_of", "parse_servers", "register_apply_patch_tool",
|
|
57
|
+
"register_code_tools", "register_download_tools",
|
|
58
|
+
"register_delegate_tool", "register_delegate_tools", "register_file_tools",
|
|
59
|
+
"register_image_tools", "register_skill_tool", "register_todo_tools",
|
|
60
|
+
"tool_categories",
|
|
61
|
+
"todos_block", "undo_run",
|
|
62
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Shared fuzzy line-matching ladder for the file-editing tools.
|
|
2
|
+
|
|
3
|
+
The ladder locates a block of expected lines inside a file, trying
|
|
4
|
+
progressively weaker comparisons so a model's near-miss output (a stray
|
|
5
|
+
trailing space, missing indentation, typographic Unicode punctuation) still
|
|
6
|
+
lands instead of failing the edit:
|
|
7
|
+
|
|
8
|
+
1. exact equality
|
|
9
|
+
2. ignore trailing whitespace (``rstrip``)
|
|
10
|
+
3. ignore leading/trailing whitespace (``strip``)
|
|
11
|
+
4. fold typographic Unicode punctuation to ASCII look-alikes, then compare
|
|
12
|
+
|
|
13
|
+
Each pass scans the whole candidate range, so a weaker comparator never
|
|
14
|
+
shadows a stronger hit at a later offset. ``apply_patch``'s chunk seeking and
|
|
15
|
+
``edit_file``'s whole-line fallback both build on these primitives.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import operator
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize_unicode(s: str) -> str:
|
|
24
|
+
"""Fold common typographic punctuation to its ASCII look-alikes."""
|
|
25
|
+
for src, dst in (("\u2018", "'"), ("\u2019", "'"), ("\u201a", "'"),
|
|
26
|
+
("\u201b", "'"), ("\u201c", '"'), ("\u201d", '"'),
|
|
27
|
+
("\u201e", '"'), ("\u201f", '"'), ("\u2010", "-"),
|
|
28
|
+
("\u2011", "-"), ("\u2012", "-"), ("\u2013", "-"),
|
|
29
|
+
("\u2014", "-"), ("\u2015", "-"), ("\u2026", "..."),
|
|
30
|
+
("\u00a0", " ")):
|
|
31
|
+
s = s.replace(src, dst)
|
|
32
|
+
return s
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Ordered exact → rstrip → strip → unicode-folded.
|
|
36
|
+
COMPARATORS: tuple[Callable[[str, str], bool], ...] = (
|
|
37
|
+
operator.eq,
|
|
38
|
+
lambda a, b: a.rstrip() == b.rstrip(),
|
|
39
|
+
lambda a, b: a.strip() == b.strip(),
|
|
40
|
+
lambda a, b: normalize_unicode(a.strip()) == normalize_unicode(b.strip()),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def try_match(lines: list[str], pattern: list[str], start: int,
|
|
45
|
+
compare: Callable[[str, str], bool], eof: bool = False) -> int:
|
|
46
|
+
"""First ladder-position match of *pattern* in *lines* at/after *start*.
|
|
47
|
+
|
|
48
|
+
With ``eof``, a tail-anchored match is preferred (for appending at end of
|
|
49
|
+
file) before the forward scan. Returns ``-1`` when *compare* finds nothing.
|
|
50
|
+
"""
|
|
51
|
+
n = len(pattern)
|
|
52
|
+
if eof:
|
|
53
|
+
from_end = len(lines) - n
|
|
54
|
+
if from_end >= start and all(
|
|
55
|
+
compare(lines[from_end + j], pattern[j]) for j in range(n)):
|
|
56
|
+
return from_end
|
|
57
|
+
for i in range(start, len(lines) - n + 1):
|
|
58
|
+
if all(compare(lines[i + j], pattern[j]) for j in range(n)):
|
|
59
|
+
return i
|
|
60
|
+
return -1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def seek_sequence(lines: list[str], pattern: list[str], start: int = 0,
|
|
64
|
+
eof: bool = False) -> int:
|
|
65
|
+
"""Locate *pattern* via the comparator ladder; ``-1`` when nothing hits.
|
|
66
|
+
|
|
67
|
+
``eof`` only *prefers* a tail-anchored hit (it then falls back to a
|
|
68
|
+
forward scan) — callers that need a hard tail anchor (patch
|
|
69
|
+
``*** End of File`` chunks) use :func:`seek_tail` instead."""
|
|
70
|
+
if not pattern:
|
|
71
|
+
return -1
|
|
72
|
+
for compare in COMPARATORS:
|
|
73
|
+
found = try_match(lines, pattern, start, compare, eof)
|
|
74
|
+
if found != -1:
|
|
75
|
+
return found
|
|
76
|
+
return -1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def seek_tail(lines: list[str], pattern: list[str]) -> int:
|
|
80
|
+
"""Hard tail anchor: the ONLY acceptable position is the last
|
|
81
|
+
``len(pattern)`` lines of the file, tried through the comparator ladder.
|
|
82
|
+
|
|
83
|
+
Returns that position, or ``-1`` when no comparator matches the tail —
|
|
84
|
+
a weaker forward hit elsewhere in the file is never accepted. This is
|
|
85
|
+
what ``*** End of File`` patch chunks need: "append at end of file"
|
|
86
|
+
must fail loudly when the tail doesn't line up, not silently edit a
|
|
87
|
+
look-alike site earlier in the file."""
|
|
88
|
+
if not pattern:
|
|
89
|
+
return -1
|
|
90
|
+
pos = len(lines) - len(pattern)
|
|
91
|
+
if pos < 0:
|
|
92
|
+
return -1
|
|
93
|
+
for compare in COMPARATORS:
|
|
94
|
+
if all(compare(lines[pos + j], pattern[j]) for j in range(len(pattern))):
|
|
95
|
+
return pos
|
|
96
|
+
return -1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def line_span_hits(lines: list[str], pattern: list[str]) -> list[int]:
|
|
100
|
+
"""All non-overlapping match starts, strongest comparator that hits.
|
|
101
|
+
|
|
102
|
+
Returns ``[]`` when no comparator matches anywhere. Strength has priority
|
|
103
|
+
over coverage: hits from a weaker comparator are never merged in.
|
|
104
|
+
"""
|
|
105
|
+
if not pattern:
|
|
106
|
+
return []
|
|
107
|
+
for compare in COMPARATORS:
|
|
108
|
+
hits: list[int] = []
|
|
109
|
+
i = 0
|
|
110
|
+
while (found := try_match(lines, pattern, i, compare)) != -1:
|
|
111
|
+
hits.append(found)
|
|
112
|
+
i = found + len(pattern)
|
|
113
|
+
if hits:
|
|
114
|
+
return hits
|
|
115
|
+
return []
|
lithe/bundles/admin.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Admin bundle: tool-management helpers over a :class:`~lithe.tools.ToolRegistry`.
|
|
2
|
+
|
|
3
|
+
Source for an admin panel: group the registered tools by category and by display
|
|
4
|
+
package, flag enabled/disabled, and check package integrity at startup. Pure over
|
|
5
|
+
the registry + a packages list — **packages carry no enable/disable semantics**
|
|
6
|
+
(tools stay togglable per-name); they are display grouping only, exactly as a
|
|
7
|
+
host's ``TOOL_PACKAGES`` already are.
|
|
8
|
+
|
|
9
|
+
The package *content* (ids, displays, which tools belong where) is host data
|
|
10
|
+
passed in as :class:`ToolPackage`; only the *mechanism* lives here.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from collections.abc import Collection, Iterable
|
|
16
|
+
|
|
17
|
+
from lithe.tools import ToolRegistry
|
|
18
|
+
|
|
19
|
+
_CAT_ORDER = ("read", "write", "meta")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ToolPackage:
|
|
24
|
+
"""Display-only grouping of tools (no enable/disable semantics)."""
|
|
25
|
+
id: str
|
|
26
|
+
display: str
|
|
27
|
+
description: str = ""
|
|
28
|
+
tools: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _entry(ts) -> dict:
|
|
32
|
+
return {"name": ts.name, "description": ts.description or "",
|
|
33
|
+
"parameters": ts.parameters or {"type": "object", "properties": {}}}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def tool_categories(registry: ToolRegistry) -> dict[str, list[dict]]:
|
|
37
|
+
"""Group every registered tool by read/write/meta category.
|
|
38
|
+
|
|
39
|
+
Each entry is a compact ``{name, description, parameters}`` (no OpenAI
|
|
40
|
+
envelope) suitable for display. Order within a category is registration order.
|
|
41
|
+
"""
|
|
42
|
+
cats: dict[str, list[dict]] = {c: [] for c in _CAT_ORDER}
|
|
43
|
+
for name in registry.names():
|
|
44
|
+
ts = registry.spec(name)
|
|
45
|
+
if ts is None:
|
|
46
|
+
continue
|
|
47
|
+
cats.setdefault(ts.category.value, []).append(_entry(ts))
|
|
48
|
+
return cats
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def package_of(packages: Iterable[ToolPackage], name: str) -> str | None:
|
|
52
|
+
"""Return the package id a tool belongs to, or None if unregistered."""
|
|
53
|
+
for p in packages:
|
|
54
|
+
if name in p.tools:
|
|
55
|
+
return p.id
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def package_meta(packages: Iterable[ToolPackage], pkg_id: str) -> ToolPackage | None:
|
|
60
|
+
"""Return the raw package (id/display/description/tools) or None."""
|
|
61
|
+
for p in packages:
|
|
62
|
+
if p.id == pkg_id:
|
|
63
|
+
return p
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_tools_admin(registry: ToolRegistry, *,
|
|
68
|
+
disabled: Collection[str] = (),
|
|
69
|
+
packages: Iterable[ToolPackage] = ()) -> list[dict]:
|
|
70
|
+
"""Flat tool list for the admin panel, ordered read → write → meta.
|
|
71
|
+
|
|
72
|
+
Each entry: ``{name, description, parameters, category, package, enabled}``.
|
|
73
|
+
``disabled`` is the per-name set the host turns tools off with; there is no
|
|
74
|
+
package-level disable.
|
|
75
|
+
"""
|
|
76
|
+
dis = frozenset(disabled or ())
|
|
77
|
+
pkgs = tuple(packages or ())
|
|
78
|
+
buckets: dict[str, list[dict]] = {c: [] for c in _CAT_ORDER}
|
|
79
|
+
for name in registry.names():
|
|
80
|
+
ts = registry.spec(name)
|
|
81
|
+
if ts is None:
|
|
82
|
+
continue
|
|
83
|
+
cat = ts.category.value
|
|
84
|
+
buckets.setdefault(cat, []).append(
|
|
85
|
+
{**_entry(ts), "category": cat,
|
|
86
|
+
"package": package_of(pkgs, name), "enabled": name not in dis})
|
|
87
|
+
return [e for c in _CAT_ORDER for e in buckets.get(c, [])]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_tool_packages_admin(registry: ToolRegistry,
|
|
91
|
+
packages: Iterable[ToolPackage], *,
|
|
92
|
+
disabled: Collection[str] = ()) -> list[dict]:
|
|
93
|
+
"""Group every tool by its package for the admin panel (display only).
|
|
94
|
+
|
|
95
|
+
One entry per package in the given order; each package carries
|
|
96
|
+
``id`` / ``display`` / ``description`` and a ``tools[]`` list where every tool
|
|
97
|
+
has ``name`` / ``description`` / ``parameters`` / ``category`` / ``enabled``.
|
|
98
|
+
"""
|
|
99
|
+
dis = frozenset(disabled or ())
|
|
100
|
+
cat_of = {e["name"]: cat for cat, entries in tool_categories(registry).items()
|
|
101
|
+
for e in entries}
|
|
102
|
+
out: list[dict] = []
|
|
103
|
+
for p in packages:
|
|
104
|
+
tools = []
|
|
105
|
+
for name in p.tools:
|
|
106
|
+
ts = registry.spec(name)
|
|
107
|
+
entry = _entry(ts) if ts is not None else {
|
|
108
|
+
"name": name, "description": "", "parameters": {}}
|
|
109
|
+
tools.append({**entry, "category": cat_of.get(name, ""),
|
|
110
|
+
"enabled": name not in dis})
|
|
111
|
+
out.append({"id": p.id, "display": p.display,
|
|
112
|
+
"description": p.description, "tools": tools})
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def check_packages(registry: ToolRegistry,
|
|
117
|
+
packages: Iterable[ToolPackage], *,
|
|
118
|
+
extra_names: Collection[str] = ()) -> dict:
|
|
119
|
+
"""Integrity check: packages must cover every tool exactly once.
|
|
120
|
+
|
|
121
|
+
Returns ``{'ok': bool, 'missing': [...], 'extra': [...]}`` for startup
|
|
122
|
+
logging. ``missing`` = registry tools (plus ``extra_names``) with no package;
|
|
123
|
+
``extra`` = registered names that no longer exist. Non-blocking.
|
|
124
|
+
"""
|
|
125
|
+
declared = set(registry.names()) | set(extra_names or ())
|
|
126
|
+
registered = {t for p in packages for t in p.tools}
|
|
127
|
+
return {"ok": declared == registered,
|
|
128
|
+
"missing": sorted(declared - registered),
|
|
129
|
+
"extra": sorted(registered - declared)}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ["ToolPackage", "check_packages", "list_tool_packages_admin",
|
|
133
|
+
"list_tools_admin", "package_meta", "package_of", "tool_categories"]
|