lorebase-guide 0.2.3__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.
@@ -0,0 +1,10 @@
1
+ """Lorebase Guide: optional exploration assistant and query embeddings over Keeper.
2
+
3
+ Importing the package registers the `guide` settings section with `lorebase_platform`.
4
+ """
5
+
6
+ from lorebase_guide.settings import GuideSettings, guide_settings
7
+
8
+ __version__ = "0.2.3"
9
+
10
+ __all__ = ["GuideSettings", "guide_settings"]
@@ -0,0 +1,93 @@
1
+ """Run Guide: `lorebase-guide` or `python -m lorebase_guide`.
2
+
3
+ Host, port and everything else come from the `guide` and `inference` settings.
4
+
5
+ lorebase-guide serve until stopped
6
+ lorebase-guide modes clear forget the structured output modes remembered per chat endpoint
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import logging
14
+ import sys
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+
18
+ import uvicorn
19
+
20
+ from lorebase_guide.app import create_app
21
+ from lorebase_guide.modes import FileModeMemory, mode_memory_path
22
+ from lorebase_guide.settings import guide_settings
23
+ from lorebase_inference import inference_settings
24
+ from lorebase_inference.chat import use_mode_memory
25
+ from lorebase_platform.logging import configure_logging
26
+ from lorebase_platform.settings import ConfigError, load_settings
27
+ from lorebase_platform.telemetry import setup_telemetry
28
+
29
+ SERVICE_NAME = "guide"
30
+
31
+
32
+ def _parser() -> argparse.ArgumentParser:
33
+ parser = argparse.ArgumentParser(prog="lorebase-guide", description=__doc__.split("\n")[0])
34
+ commands = parser.add_subparsers(dest="command")
35
+ commands.add_parser("run", help="Serve until stopped (default).")
36
+ modes = commands.add_parser("modes", help="Structured output modes of the chat endpoints.")
37
+ modes.add_argument("action", choices=["clear"], help="Forget every remembered mode.")
38
+ return parser
39
+
40
+
41
+ def _clear(path: Path) -> int:
42
+ try:
43
+ return FileModeMemory(path).clear()
44
+ except OSError as exc:
45
+ sys.exit(f"cannot clear {path}: {exc}")
46
+
47
+
48
+ def main(argv: Sequence[str] | None = None) -> None:
49
+ """Load settings, configure logging and telemetry, and serve until stopped (or run the
50
+ command)."""
51
+ args = _parser().parse_args(argv)
52
+ try:
53
+ settings = load_settings()
54
+ guide = guide_settings(settings)
55
+ inference = inference_settings(settings)
56
+ except ConfigError as exc:
57
+ sys.exit(str(exc))
58
+ if args.command == "modes":
59
+ path = mode_memory_path(guide.data_dir, guide.mode_memory_file)
60
+ cleared = 0 if path is None else _clear(path)
61
+ sys.stdout.write(json.dumps({"cleared": cleared}) + "\n")
62
+ return
63
+ configure_logging(settings.logging, service=SERVICE_NAME)
64
+ # httpx logs every request URL at INFO; keep it quiet so record keys stay out of the logs.
65
+ for name in ("httpx", "httpcore"):
66
+ logging.getLogger(name).setLevel(logging.WARNING)
67
+ telemetry = setup_telemetry(settings.telemetry)
68
+ modes = mode_memory_path(guide.data_dir, guide.mode_memory_file)
69
+ if modes is not None:
70
+ use_mode_memory(
71
+ FileModeMemory(modes),
72
+ secret=(
73
+ None
74
+ if inference.mode_memory_key is None
75
+ else inference.mode_memory_key.get_secret_value()
76
+ ),
77
+ max_age_s=inference.mode_memory_ttl_s,
78
+ )
79
+ try:
80
+ uvicorn.run(
81
+ create_app(guide, inference),
82
+ host=guide.host,
83
+ port=guide.port,
84
+ log_config=None,
85
+ access_log=False,
86
+ proxy_headers=False,
87
+ )
88
+ finally:
89
+ telemetry.shutdown()
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,337 @@
1
+ """The exploration loop: model turn, read-only tool call, repeat; bounded on every axis.
2
+
3
+ Budgets (from `guide.agent`, lowered per request, never raised):
4
+
5
+ - `max_steps` tool calls; when they are used up the model gets one last turn to answer;
6
+ - `max_tokens_per_call` completion tokens per model call;
7
+ - `max_total_tokens` input plus output tokens over the whole exploration; before each call the
8
+ input is estimated from the text length, and the call is only made when that estimate fits in
9
+ what remains, with a completion cap of at most what is left after it;
10
+ - `timeout_s` wall-clock time from the start, covering model calls and Keeper reads alike.
11
+
12
+ The loop yields `Event`s that the HTTP layer streams as server-sent events. Logs and metrics
13
+ carry counts, tool names and outcomes only: never the question, data or answers.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import math
22
+ import secrets
23
+ import time
24
+ from collections.abc import AsyncIterator, Awaitable
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ from lorebase_client import AsyncLorebaseClient
29
+ from lorebase_client.errors import LorebaseError
30
+ from lorebase_guide import prompts
31
+ from lorebase_guide.embed import Embedder
32
+ from lorebase_guide.keeper import tool_scope
33
+ from lorebase_guide.settings import AgentSettings
34
+ from lorebase_guide.telemetry import STEPS, TOKENS, tracer
35
+ from lorebase_guide.tools import TOOLS, ToolContext, ToolResult, run_tool
36
+ from lorebase_guide.trace import TraceSession
37
+ from lorebase_inference import (
38
+ ChatModel,
39
+ ChatOptions,
40
+ InferenceError,
41
+ InvalidOutputError,
42
+ Message,
43
+ )
44
+
45
+ logger = logging.getLogger("lorebase.guide.agent")
46
+
47
+ _CHARS_PER_TOKEN = 4 # input estimate before a call; same ratio as the inference package
48
+ _MIN_CHARS_PER_TOKEN = 1.0 # floor of the ratio measured on a call
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Event:
53
+ """One server-sent event: `name` and a JSON object."""
54
+
55
+ name: str
56
+ data: dict[str, Any]
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Budget:
61
+ max_steps: int
62
+ max_total_tokens: int
63
+ max_tokens_per_call: int
64
+ timeout_s: float
65
+
66
+ @classmethod
67
+ def of(cls, settings: AgentSettings, *, max_steps: int | None, budget: int | None) -> Budget:
68
+ return cls(
69
+ max_steps=min(max_steps or settings.max_steps, settings.max_steps),
70
+ max_total_tokens=min(budget or settings.max_total_tokens, settings.max_total_tokens),
71
+ max_tokens_per_call=settings.max_tokens_per_call,
72
+ timeout_s=settings.timeout_s,
73
+ )
74
+
75
+
76
+ @dataclass
77
+ class _Usage:
78
+ input_tokens: int = 0
79
+ output_tokens: int = 0
80
+
81
+ @property
82
+ def total(self) -> int:
83
+ return self.input_tokens + self.output_tokens
84
+
85
+
86
+ @dataclass
87
+ class Exploration:
88
+ """Outcome counters, read by the HTTP layer for logs and metrics."""
89
+
90
+ steps: int = 0
91
+ outcome: str = "running"
92
+ usage: _Usage = field(default_factory=_Usage)
93
+ # Characters per input token: 4 until the endpoint reports its own count of a call, then
94
+ # what that call measured (JSON tool results ran at about 2.2 on Claude models).
95
+ chars_per_token: float = _CHARS_PER_TOKEN
96
+
97
+
98
+ class _StopError(Exception):
99
+ def __init__(self, code: str, message: str) -> None:
100
+ super().__init__(message)
101
+ self.code = code
102
+ self.message = message
103
+
104
+
105
+ class Explorer:
106
+ """Runs explorations with one chat model; the Keeper client is per exploration."""
107
+
108
+ def __init__(self, chat: ChatModel, settings: AgentSettings, embedder: Embedder | None) -> None:
109
+ self._chat = chat
110
+ self._settings = settings
111
+ self._embedder = embedder
112
+
113
+ async def run(
114
+ self,
115
+ *,
116
+ client: AsyncLorebaseClient,
117
+ trace: TraceSession,
118
+ domain: str,
119
+ branch: str | None,
120
+ question: str,
121
+ budget: Budget,
122
+ state: Exploration | None = None,
123
+ ) -> AsyncIterator[Event]:
124
+ """Events of one exploration, ending with `answer` (or `error`) and `done`."""
125
+ state = state or Exploration()
126
+ started = time.monotonic()
127
+ deadline = asyncio.get_running_loop().time() + budget.timeout_s
128
+ marker = secrets.token_hex(12)
129
+ ctx = ToolContext(
130
+ client=client,
131
+ domain=domain,
132
+ branch=branch,
133
+ settings=self._settings,
134
+ embedder=self._embedder,
135
+ )
136
+ messages = [
137
+ Message("system", prompts.system_prompt(marker)),
138
+ Message("user", prompts.question_prompt(domain, branch, question.replace(marker, ""))),
139
+ ]
140
+ yield Event(
141
+ "session",
142
+ {
143
+ "session_id": trace.session_id,
144
+ "domain": domain,
145
+ "branch": branch,
146
+ "model": getattr(self._chat, "model", None),
147
+ "max_steps": budget.max_steps,
148
+ },
149
+ )
150
+ final: dict[str, Any] | None = None
151
+ calls = 0
152
+ try:
153
+ while final is None:
154
+ last_turn = state.steps >= budget.max_steps or not self._fits_another_step(
155
+ messages, budget, state, calls
156
+ )
157
+ if last_turn:
158
+ messages.append(Message("user", prompts.FINAL_CALL))
159
+ turn = await self._turn(messages, budget, state, deadline, final=last_turn)
160
+ calls += 1
161
+ thought = str(turn.get("thought") or "").strip()
162
+ if thought:
163
+ yield Event("thought", {"step": state.steps, "text": thought})
164
+ await _bounded(trace.note(client, thought), deadline)
165
+ action = str(turn.get("action") or "")
166
+ if action == prompts.ANSWER or last_turn:
167
+ final = self._final(turn if action == prompts.ANSWER else {}, ctx)
168
+ break
169
+ state.steps += 1
170
+ raw_args = turn.get("args")
171
+ args: dict[str, Any] = raw_args if isinstance(raw_args, dict) else {}
172
+ yield Event("tool_call", {"step": state.steps, "tool": action, "args": args})
173
+ result = await self._tool(ctx, trace, action, args, deadline)
174
+ yield Event(
175
+ "tool_result",
176
+ {
177
+ "step": state.steps,
178
+ "tool": action,
179
+ "ok": result.ok,
180
+ "summary": result.summary,
181
+ "refs": result.refs[: self._settings.max_items],
182
+ },
183
+ )
184
+ messages.append(Message("assistant", json.dumps(turn, ensure_ascii=False)))
185
+ messages.append(
186
+ Message(
187
+ "user",
188
+ prompts.data_block(
189
+ marker,
190
+ action,
191
+ result.ok,
192
+ result.data,
193
+ self._settings.max_result_chars,
194
+ ),
195
+ )
196
+ )
197
+ except _StopError as stop:
198
+ state.outcome = stop.code
199
+ yield Event("error", {"code": stop.code, "message": stop.message})
200
+ final = {"text": prompts.NOT_FOUND, "citations": [], "found": False}
201
+ else:
202
+ state.outcome = "answered" if final["found"] else "not_found"
203
+ yield Event("answer", final)
204
+ yield Event(
205
+ "done",
206
+ {
207
+ "session_id": trace.session_id,
208
+ "steps": state.steps,
209
+ "outcome": state.outcome,
210
+ "tokens": {
211
+ "input": state.usage.input_tokens,
212
+ "output": state.usage.output_tokens,
213
+ },
214
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
215
+ },
216
+ )
217
+
218
+ def _fits_another_step(
219
+ self, messages: list[Message], budget: Budget, state: Exploration, calls: int
220
+ ) -> bool:
221
+ """Whether the token budget still covers a tool step and the answer after it.
222
+
223
+ A tool step costs this call (estimated input, plus the output of an average call so
224
+ far) and leaves a larger context for the next one: this input plus at most
225
+ `max_result_chars` of tool result. The answer after it may use up to
226
+ `max_tokens_per_call`. When that does not fit, this turn is the last one, so the
227
+ exploration answers from what it read instead of stopping on the budget with nothing.
228
+ """
229
+ estimate = math.ceil(_chars(messages) / state.chars_per_token)
230
+ output = (
231
+ math.ceil(state.usage.output_tokens / calls)
232
+ if calls
233
+ else budget.max_tokens_per_call // 4
234
+ )
235
+ result = math.ceil(self._settings.max_result_chars / state.chars_per_token)
236
+ needed = (estimate + output) + (estimate + output + result) + budget.max_tokens_per_call
237
+ return state.usage.total + needed <= budget.max_total_tokens
238
+
239
+ async def _turn(
240
+ self,
241
+ messages: list[Message],
242
+ budget: Budget,
243
+ state: Exploration,
244
+ deadline: float,
245
+ *,
246
+ final: bool,
247
+ ) -> dict[str, Any]:
248
+ schema = prompts.turn_schema()
249
+ chars = _chars(messages)
250
+ remaining = (
251
+ budget.max_total_tokens - state.usage.total - math.ceil(chars / state.chars_per_token)
252
+ )
253
+ if remaining <= 0:
254
+ raise _StopError("budget", "the token budget of this exploration is used up")
255
+ options = ChatOptions(
256
+ task=self._settings.task,
257
+ temperature=self._settings.temperature,
258
+ max_tokens=min(budget.max_tokens_per_call, remaining),
259
+ )
260
+ try:
261
+ with tracer.start_as_current_span("guide.model"):
262
+ result = await _bounded(
263
+ self._chat.complete_json(messages, schema, options), deadline
264
+ )
265
+ except InferenceError as exc:
266
+ # The calls of an unusable answer were made and billed: count them. The problems
267
+ # are schema paths and keywords (for example `/: json`), never answer text.
268
+ detail: dict[str, Any] = {"error": type(exc).__name__}
269
+ if isinstance(exc, InvalidOutputError):
270
+ state.usage.input_tokens += exc.usage.input_tokens
271
+ state.usage.output_tokens += exc.usage.output_tokens
272
+ detail.update(attempts=exc.attempts, problems=list(exc.problems[:5]))
273
+ logger.warning("model call failed", extra=detail)
274
+ raise _StopError("model", "the model could not produce a valid step") from None
275
+ state.usage.input_tokens += result.usage.input_tokens
276
+ state.usage.output_tokens += result.usage.output_tokens
277
+ if result.usage.input_tokens > 0:
278
+ state.chars_per_token = min(
279
+ max(chars / result.usage.input_tokens, _MIN_CHARS_PER_TOKEN), _CHARS_PER_TOKEN
280
+ )
281
+ TOKENS.add(result.usage.input_tokens, {"direction": "input"})
282
+ TOKENS.add(result.usage.output_tokens, {"direction": "output"})
283
+ # The answer of the final turn is kept even when the endpoint counted more input than
284
+ # estimated: its tokens are spent either way, and no further call follows.
285
+ if state.usage.total > budget.max_total_tokens and not final:
286
+ raise _StopError("budget", "the token budget of this exploration is used up")
287
+ data = result.data
288
+ return data if isinstance(data, dict) else {}
289
+
290
+ async def _tool(
291
+ self,
292
+ ctx: ToolContext,
293
+ trace: TraceSession,
294
+ action: str,
295
+ args: dict[str, Any],
296
+ deadline: float,
297
+ ) -> ToolResult:
298
+ with (
299
+ tracer.start_as_current_span("guide.tool") as span,
300
+ tool_scope(trace.tool_headers(action)),
301
+ ):
302
+ span.set_attribute("guide.tool", action)
303
+ try:
304
+ result = await _bounded(run_tool(ctx, action, args), deadline)
305
+ except LorebaseError as exc:
306
+ result = ToolResult(False, f"Keeper error {exc.status}", {"error": "keeper"})
307
+ STEPS.add(1, {"tool": action if action in TOOLS else "unknown", "ok": result.ok})
308
+ return result
309
+
310
+ def _final(self, turn: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
311
+ """The answer, citing only Records a tool returned; without such a citation, or when
312
+ the model says so, the answer is that the graph does not contain it."""
313
+ raw = turn.get("citations")
314
+ cited: list[Any] = raw if isinstance(raw, list) else []
315
+ citations: list[dict[str, str]] = []
316
+ for ref in dict.fromkeys(str(c) for c in cited):
317
+ if ref in ctx.seen:
318
+ kind, _, key = ref.partition("/")
319
+ citations.append({"ref": ref, "kind": kind, "key": key})
320
+ text = str(turn.get("answer") or "").strip()[: self._settings.max_answer_chars]
321
+ found = bool(turn.get("found", True)) and bool(citations) and bool(text)
322
+ if not found:
323
+ return {"text": prompts.NOT_FOUND, "citations": [], "found": False}
324
+ return {"text": text, "citations": citations, "found": True}
325
+
326
+
327
+ def _chars(messages: list[Message]) -> int:
328
+ """Characters of a model call with these messages and the turn schema."""
329
+ return len(json.dumps(prompts.turn_schema())) + sum(len(m.content) for m in messages)
330
+
331
+
332
+ async def _bounded[T](awaitable: Awaitable[T], deadline: float) -> T:
333
+ try:
334
+ async with asyncio.timeout_at(deadline):
335
+ return await awaitable
336
+ except TimeoutError:
337
+ raise _StopError("timeout", "the exploration took longer than allowed") from None