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 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"]