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,561 @@
1
+ """The read-only tools of the exploration agent, each one Keeper read over the client.
2
+
3
+ There is no write tool and no tool that reaches anything but Keeper (no URL fetching): the
4
+ catalogue is closed and every argument is validated before a request is made. Results are
5
+ reduced to compact JSON (bounded items and value lengths) and every Record ref they contain is
6
+ remembered, so that the final answer may cite only Records the caller was actually shown.
7
+ RBAC, masking and audit are Keeper's: each call carries the caller's own credential.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
13
+ from dataclasses import dataclass, field
14
+ from typing import Annotated, Any, Literal
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
17
+
18
+ from lorebase_client import AsyncLorebaseClient
19
+ from lorebase_client import models as m
20
+ from lorebase_client.errors import LorebaseError
21
+ from lorebase_guide.embed import Embedder
22
+ from lorebase_guide.settings import AgentSettings
23
+ from lorebase_inference import InferenceError
24
+
25
+ JsonDict = dict[str, Any]
26
+ _TEXT_LIMIT = 500
27
+ _MAX_ATTRIBUTES = 40
28
+ _MAX_PATH = 4
29
+ _MAX_SEEDS = 5
30
+
31
+ # The query DSL as Keeper reads it (lorebase_core.query.dsl), told to the model in the `query`
32
+ # and `traverse` descriptions; tests check the example against the DSL models.
33
+ PREDICATE_FORMS = (
34
+ '{"attr": name, "op": eq|ne|lt|lte|gt|gte|in|between|contains|prefix|exists, "value": v}, '
35
+ '{"derived": name, "op": op, "value": v} for a derived Attribute, '
36
+ '{"via": relation, "where"?: predicate} (a Link exists), '
37
+ '{"count": relation, "op": op, "value": n}, {"match": "text"}, '
38
+ '{"and": [predicate, ...]}, {"or": [predicate, ...]}, {"not": predicate}'
39
+ )
40
+ QUERY_EXAMPLE = (
41
+ '{"kind": "Part", "where": {"and": [{"attr": "category", "op": "eq", "value": "bearing"}, '
42
+ '{"via": "supplied_by", "where": {"attr": "country", "op": "in", "value": ["IT", "DE"]}}]}, '
43
+ '"order_by": [{"attr": "key", "dir": "desc"}], "limit": 20}'
44
+ )
45
+
46
+
47
+ class ToolArgs(BaseModel):
48
+ model_config = ConfigDict(extra="forbid", frozen=True)
49
+
50
+
51
+ RefText = Annotated[str, Field(min_length=3, max_length=300, description="Record ref `Kind/key`.")]
52
+
53
+
54
+ class SchemaArgs(ToolArgs):
55
+ kinds: list[str] | None = Field(default=None, max_length=20)
56
+
57
+
58
+ class SearchArgs(ToolArgs):
59
+ q: str = Field(min_length=1, max_length=_TEXT_LIMIT)
60
+ kinds: list[str] | None = Field(default=None, max_length=10)
61
+ mode: Literal["lexical", "semantic", "hybrid"] = "hybrid"
62
+ limit: int = Field(default=10, ge=1, le=50)
63
+
64
+
65
+ class QueryArgs(ToolArgs):
66
+ kind: str = Field(min_length=1, max_length=128)
67
+ where: dict[str, Any] | None = None
68
+ select: list[str] | None = Field(default=None, max_length=40)
69
+ order_by: list[dict[str, str]] | None = Field(default=None, max_length=5)
70
+ aggregate: dict[str, Any] | None = None
71
+ limit: int = Field(default=10, ge=1, le=50)
72
+
73
+
74
+ class RefArgs(ToolArgs):
75
+ ref: RefText
76
+
77
+
78
+ class LinksArgs(ToolArgs):
79
+ ref: RefText
80
+ relation: str = Field(min_length=1, max_length=128)
81
+ limit: int = Field(default=20, ge=1, le=50)
82
+
83
+
84
+ class NeighborhoodArgs(ToolArgs):
85
+ refs: list[str] = Field(min_length=1, max_length=_MAX_SEEDS)
86
+ depth: int = Field(default=1, ge=1, le=5)
87
+ relations: list[str] | None = Field(default=None, max_length=20)
88
+ max_nodes: int = Field(default=30, ge=1, le=500)
89
+ values: bool = True
90
+
91
+
92
+ class TraverseHopArgs(ToolArgs):
93
+ relation: str = Field(min_length=1, max_length=128)
94
+ where: dict[str, Any] | None = None
95
+ limit: int | None = Field(default=None, ge=1, le=50)
96
+
97
+
98
+ class TraverseArgs(ToolArgs):
99
+ start: RefText
100
+ path: list[TraverseHopArgs] = Field(min_length=1, max_length=_MAX_PATH)
101
+
102
+
103
+ class HistoryArgs(ToolArgs):
104
+ ref: RefText
105
+ limit: int = Field(default=5, ge=1, le=20)
106
+
107
+
108
+ class ContextArgs(ToolArgs):
109
+ refs: list[str] | None = Field(default=None, max_length=_MAX_SEEDS)
110
+ search: str | None = Field(default=None, min_length=1, max_length=_TEXT_LIMIT)
111
+ budget_tokens: int = Field(default=1_500, ge=200, le=20_000)
112
+ depth: int = Field(default=1, ge=1, le=3)
113
+
114
+
115
+ @dataclass
116
+ class ToolResult:
117
+ """What a tool shows the model (`data`), the user (`summary`) and the refs it showed."""
118
+
119
+ ok: bool
120
+ summary: str
121
+ data: Any
122
+ refs: list[str] = field(default_factory=list)
123
+
124
+
125
+ @dataclass
126
+ class ToolContext:
127
+ """Everything a tool call may use; bound to one exploration."""
128
+
129
+ client: AsyncLorebaseClient
130
+ domain: str
131
+ branch: str | None
132
+ settings: AgentSettings
133
+ embedder: Embedder | None
134
+ seen: set[str] = field(default_factory=set)
135
+ _search_info: m.SchemaSearch | None = None
136
+
137
+ def clip(self, value: Any) -> Any:
138
+ """A JSON value with long strings cut and long lists shortened."""
139
+ limit = self.settings.max_value_chars
140
+ if isinstance(value, str):
141
+ return value if len(value) <= limit else value[:limit] + "..."
142
+ if isinstance(value, list):
143
+ items = [self.clip(v) for v in value[: self.settings.max_items]]
144
+ if len(value) > len(items):
145
+ items.append(f"... {len(value) - len(items)} more")
146
+ return items
147
+ if isinstance(value, dict):
148
+ return {str(k): self.clip(v) for k, v in list(value.items())[:_MAX_ATTRIBUTES]}
149
+ return value
150
+
151
+ def record(self, item: m.RecordItem, *, with_values: bool = True) -> JsonDict:
152
+ ref = f"{item.kind}/{item.key}"
153
+ self.seen.add(ref)
154
+ out: JsonDict = {"ref": ref}
155
+ if item.title:
156
+ out["title"] = self.clip(item.title)
157
+ if with_values:
158
+ out["values"] = self.clip(dict(item.values))
159
+ if item.links:
160
+ out["links"] = {link.relation: link.count for link in item.links}
161
+ return out
162
+
163
+ def ref_of(self, kind: str, key: str) -> str:
164
+ ref = f"{kind}/{key}"
165
+ self.seen.add(ref)
166
+ return ref
167
+
168
+ async def search_info(self) -> m.SchemaSearch:
169
+ if self._search_info is None:
170
+ document = await self.client.schema.get(self.domain, branch=self.branch)
171
+ self._search_info = document.search
172
+ return self._search_info
173
+
174
+
175
+ class ToolInputError(ValueError):
176
+ """Arguments the tool cannot use; reported to the model, never sent to Keeper."""
177
+
178
+
179
+ def split_ref(ref: str) -> tuple[str, str]:
180
+ kind, sep, key = ref.partition("/")
181
+ if not sep or kind in (".", "..") or key in ("", ".", "..") or not kind:
182
+ raise ToolInputError(f"ref must be `Kind/key`, got {ref[:80]!r}")
183
+ return kind, key
184
+
185
+
186
+ def _refs(items: Sequence[str]) -> list[str]:
187
+ return [item for item in items if item]
188
+
189
+
190
+ async def _schema(ctx: ToolContext, args: SchemaArgs) -> ToolResult:
191
+ document = await ctx.client.schema.get(ctx.domain, branch=ctx.branch)
192
+ content: Mapping[str, Any] = document.schema_
193
+ names = {
194
+ element_id: element.get("api_name", element_id)
195
+ for section in ("kinds", "relations")
196
+ for element_id, element in (content.get(section) or {}).items()
197
+ }
198
+ wanted = set(args.kinds or ())
199
+ kinds: list[JsonDict] = []
200
+ for kind in (content.get("kinds") or {}).values():
201
+ if wanted and kind.get("api_name") not in wanted:
202
+ continue
203
+ attributes = {
204
+ a.get("api_name", a_id): str((a.get("type") or {}).get("name", "?"))
205
+ for a_id, a in (kind.get("attributes") or {}).items()
206
+ }
207
+ kinds.append({"kind": kind.get("api_name"), "attributes": ctx.clip(attributes)})
208
+ relations = [
209
+ {
210
+ "relation": r.get("api_name"),
211
+ "inverse": r.get("inverse_api_name"),
212
+ "from": names.get(r.get("from_kind"), r.get("from_kind")),
213
+ "to": names.get(r.get("to_kind"), r.get("to_kind")),
214
+ }
215
+ for r in (content.get("relations") or {}).values()
216
+ if not wanted
217
+ or names.get(r.get("from_kind")) in wanted
218
+ or names.get(r.get("to_kind")) in wanted
219
+ ]
220
+ data = {
221
+ "kinds": kinds[: ctx.settings.max_items],
222
+ "relations": relations[: ctx.settings.max_items],
223
+ "search_modes": list(document.search.modes),
224
+ }
225
+ ctx._search_info = document.search
226
+ return ToolResult(True, f"{len(kinds)} Kinds, {len(relations)} Relations", data)
227
+
228
+
229
+ async def _search(ctx: ToolContext, args: SearchArgs) -> ToolResult:
230
+ mode = args.mode
231
+ vector: list[float] | None = None
232
+ note = ""
233
+ if mode != "lexical":
234
+ info = await ctx.search_info()
235
+ if mode not in info.modes or info.embedding is None or ctx.embedder is None:
236
+ mode, note = "lexical", "semantic search unavailable here; used lexical"
237
+ else:
238
+ try:
239
+ embedded = await ctx.embedder.embed(args.q, "query")
240
+ except InferenceError:
241
+ mode, note = "lexical", "query embedding failed; used lexical"
242
+ else:
243
+ if embedded.model != info.embedding.model or embedded.dims != info.embedding.dims:
244
+ mode, note = "lexical", "embedding model differs from Keeper's; used lexical"
245
+ else:
246
+ vector = embedded.vector
247
+ request = m.SearchRequest(
248
+ branch=ctx.branch,
249
+ q=args.q,
250
+ kinds=args.kinds,
251
+ mode=mode,
252
+ query_vector=vector,
253
+ limit=min(args.limit, ctx.settings.max_items),
254
+ )
255
+ found = await ctx.client.records.search(ctx.domain, request)
256
+ hits = [
257
+ {
258
+ "ref": ctx.ref_of(hit.kind, hit.key),
259
+ "title": ctx.clip(hit.title),
260
+ "snippet": ctx.clip(hit.snippet),
261
+ "score": round(hit.score, 4),
262
+ }
263
+ for hit in found.items
264
+ ]
265
+ data: JsonDict = {"mode": found.mode, "hits": hits}
266
+ if found.truncated_candidates:
267
+ data["truncated_candidates"] = True
268
+ note = "; ".join(
269
+ filter(None, [note, "very common terms: only a sample was ranked, narrow q"])
270
+ )
271
+ if note:
272
+ data["note"] = note
273
+ summary = f"{len(hits)} hits ({found.mode})"
274
+ return ToolResult(True, summary, data, [h["ref"] for h in hits])
275
+
276
+
277
+ async def _query(ctx: ToolContext, args: QueryArgs) -> ToolResult:
278
+ request = m.QueryRequest(
279
+ kind=args.kind,
280
+ where=args.where,
281
+ select=args.select,
282
+ order_by=args.order_by,
283
+ aggregate=args.aggregate,
284
+ limit=min(args.limit, ctx.settings.max_items),
285
+ branch=ctx.branch,
286
+ )
287
+ found = await ctx.client.records.query(ctx.domain, request)
288
+ records = [ctx.record(item) for item in found.items]
289
+ data: JsonDict = {"records": records, "more": found.next_cursor is not None}
290
+ if found.aggregate is not None:
291
+ data["aggregate"] = ctx.clip(found.aggregate.model_dump(mode="json"))
292
+ return ToolResult(True, f"{len(records)} Records", data, [r["ref"] for r in records])
293
+
294
+
295
+ async def _get(ctx: ToolContext, args: RefArgs) -> ToolResult:
296
+ kind, key = split_ref(args.ref)
297
+ found = await ctx.client.records.get(ctx.domain, kind, key, branch=ctx.branch, links=True)
298
+ record = ctx.record(found.record)
299
+ return ToolResult(True, f"Record {record['ref']}", record, [record["ref"]])
300
+
301
+
302
+ async def _links(ctx: ToolContext, args: LinksArgs) -> ToolResult:
303
+ kind, key = split_ref(args.ref)
304
+ page = await ctx.client.records.links(
305
+ ctx.domain,
306
+ kind,
307
+ key,
308
+ args.relation,
309
+ branch=ctx.branch,
310
+ limit=min(args.limit, ctx.settings.max_items),
311
+ )
312
+ if page.items:
313
+ ctx.seen.add(args.ref)
314
+ links: list[JsonDict] = []
315
+ for item in page.items:
316
+ entry: JsonDict = {"relation": item.relation, "direction": item.direction}
317
+ if item.target is not None:
318
+ entry["target"] = ctx.ref_of(item.target.kind, item.target.key)
319
+ else:
320
+ entry["to_key"] = item.to_key
321
+ if item.values:
322
+ entry["values"] = ctx.clip(dict(item.values))
323
+ links.append(entry)
324
+ refs = _refs([str(link.get("target", "")) for link in links])
325
+ data = {"links": links, "more": page.next_cursor is not None}
326
+ return ToolResult(True, f"{len(links)} links of {args.ref}", data, refs)
327
+
328
+
329
+ async def _neighborhood(ctx: ToolContext, args: NeighborhoodArgs) -> ToolResult:
330
+ seeds = [m.RecordAddress(kind=k, key=v) for k, v in (split_ref(r) for r in args.refs)]
331
+ request = m.NeighborhoodRequest(
332
+ branch=ctx.branch,
333
+ seeds=seeds,
334
+ depth=min(args.depth, ctx.settings.max_depth),
335
+ relations=args.relations,
336
+ max_nodes=min(args.max_nodes, ctx.settings.max_nodes),
337
+ values=args.values,
338
+ )
339
+ found = await ctx.client.records.neighborhood(ctx.domain, request)
340
+ nodes = [ctx.record(item) for item in found.nodes[: ctx.settings.max_items]]
341
+ edges = [_edge(ctx, e) for e in found.edges[: ctx.settings.max_items * 2]]
342
+ data = {"nodes": nodes, "edges": edges, "truncated": found.truncated}
343
+ summary = f"{len(found.nodes)} nodes, {len(found.edges)} edges"
344
+ return ToolResult(True, summary, data, [n["ref"] for n in nodes])
345
+
346
+
347
+ def _edge(ctx: ToolContext, edge: m.Edge) -> JsonDict:
348
+ """A Link between two shown Records, with its Link Attributes when it has any."""
349
+ out: JsonDict = {
350
+ "relation": edge.relation,
351
+ "from": ctx.ref_of(edge.source.kind, edge.source.key),
352
+ "to": ctx.ref_of(edge.to.kind, edge.to.key),
353
+ }
354
+ if edge.values:
355
+ out["values"] = ctx.clip(dict(edge.values))
356
+ return out
357
+
358
+
359
+ async def _traverse(ctx: ToolContext, args: TraverseArgs) -> ToolResult:
360
+ kind, key = split_ref(args.start)
361
+ request = m.TraverseRequest(
362
+ branch=ctx.branch,
363
+ start=m.RecordAddress(kind=kind, key=key),
364
+ path=[
365
+ m.TraverseStep(
366
+ relation=hop.relation,
367
+ where=hop.where,
368
+ limit=min(hop.limit or ctx.settings.max_items, ctx.settings.max_items),
369
+ )
370
+ for hop in args.path
371
+ ],
372
+ )
373
+ found = await ctx.client.records.traverse(ctx.domain, request)
374
+ start = ctx.record(found.start)
375
+ hops: list[JsonDict] = []
376
+ refs = [str(start["ref"])]
377
+ for hop in found.hops:
378
+ records = [ctx.record(r) for r in hop.records[: ctx.settings.max_items]]
379
+ refs.extend(str(r["ref"]) for r in records)
380
+ entry: JsonDict = {"relation": hop.relation, "records": records}
381
+ # Link Attributes (a stocked quantity, a lead time) live on the edges only
382
+ valued = [_edge(ctx, e) for e in hop.edges[: ctx.settings.max_items] if e.values]
383
+ if valued:
384
+ entry["links"] = valued
385
+ entry["truncated"] = hop.truncated
386
+ hops.append(entry)
387
+ summary = f"{len(hops)} hops, {len(refs) - 1} Records"
388
+ return ToolResult(True, summary, {"start": start, "hops": hops}, refs)
389
+
390
+
391
+ async def _history(ctx: ToolContext, args: HistoryArgs) -> ToolResult:
392
+ kind, key = split_ref(args.ref)
393
+ page = await ctx.client.records.history(
394
+ ctx.domain, kind, key, branch=ctx.branch, values=True, limit=args.limit
395
+ )
396
+ ref = ctx.ref_of(kind, key) if page.items else f"{kind}/{key}"
397
+ changes = [
398
+ {
399
+ "at": change.at.isoformat(),
400
+ "change": change.change,
401
+ "author": change.author,
402
+ "message": ctx.clip(change.message),
403
+ "attributes": ctx.clip([a.model_dump(mode="json") for a in change.attributes]),
404
+ }
405
+ for change in page.items
406
+ ]
407
+ refs = [ref] if changes else []
408
+ return ToolResult(True, f"{len(changes)} changes of {ref}", {"changes": changes}, refs)
409
+
410
+
411
+ async def _lineage(ctx: ToolContext, args: RefArgs) -> ToolResult:
412
+ kind, key = split_ref(args.ref)
413
+ found = await ctx.client.records.lineage(ctx.domain, kind, key, branch=ctx.branch)
414
+ shown = found.created is not None or bool(found.attributes)
415
+ ref = ctx.ref_of(kind, key) if shown else f"{kind}/{key}"
416
+ data = {
417
+ "ref": ref,
418
+ "changes": found.changes,
419
+ "sources": ctx.clip(list(found.sources)),
420
+ "attributes": ctx.clip([a.model_dump(mode="json") for a in found.attributes]),
421
+ }
422
+ return ToolResult(True, f"lineage of {ref}", data, [ref] if shown else [])
423
+
424
+
425
+ async def _context(ctx: ToolContext, args: ContextArgs) -> ToolResult:
426
+ if not args.refs and not args.search:
427
+ raise ToolInputError("give refs or search")
428
+ for ref in args.refs or ():
429
+ split_ref(ref)
430
+ request = m.ContextRequest(
431
+ branch=ctx.branch,
432
+ refs=args.refs,
433
+ search=args.search,
434
+ budget_tokens=min(args.budget_tokens, ctx.settings.max_context_tokens),
435
+ depth=min(args.depth, ctx.settings.max_depth),
436
+ format="json",
437
+ )
438
+ pack = await ctx.client.agents.context(ctx.domain, request)
439
+ refs: list[str] = []
440
+ for entry in [*pack.seeds, *pack.neighbors]:
441
+ kind, key = entry.get("kind"), entry.get("key")
442
+ if isinstance(kind, str) and isinstance(key, str):
443
+ refs.append(ctx.ref_of(kind, key))
444
+ data = {"seeds": ctx.clip(pack.seeds), "neighbors": ctx.clip(pack.neighbors)}
445
+ summary = f"{len(pack.seeds)} seeds, {len(pack.neighbors)} neighbors"
446
+ return ToolResult(True, summary, data, refs)
447
+
448
+
449
+ Runner = Callable[[ToolContext, Any], Awaitable[ToolResult]]
450
+
451
+
452
+ @dataclass(frozen=True)
453
+ class Tool:
454
+ name: str
455
+ description: str
456
+ args: type[ToolArgs]
457
+ run: Runner
458
+
459
+
460
+ TOOLS: dict[str, Tool] = {
461
+ tool.name: tool
462
+ for tool in (
463
+ Tool(
464
+ "schema",
465
+ "Kinds with their attributes and the Relations between them. args: {kinds?: [Kind]}",
466
+ SchemaArgs,
467
+ _schema,
468
+ ),
469
+ Tool(
470
+ "search",
471
+ "Find Records by text. args: {q: text, kinds?: [Kind], mode?: lexical|semantic|"
472
+ "hybrid, limit?: 1-50}",
473
+ SearchArgs,
474
+ _search,
475
+ ),
476
+ Tool(
477
+ "query",
478
+ "Filter Records of one Kind with the query DSL. args: {kind, where?: predicate, "
479
+ "select?: [attr], order_by?: [{attr, dir?: asc|desc}], aggregate?: {group_by?: "
480
+ "attr}, limit?: 1-50}. A predicate is one of: "
481
+ + PREDICATE_FORMS
482
+ + ". Example: "
483
+ + QUERY_EXAMPLE,
484
+ QueryArgs,
485
+ _query,
486
+ ),
487
+ Tool("get", "One Record with link counts. args: {ref: Kind/key}", RefArgs, _get),
488
+ Tool(
489
+ "links",
490
+ "Links of a Record along one Relation. args: {ref, relation, limit?}",
491
+ LinksArgs,
492
+ _links,
493
+ ),
494
+ Tool(
495
+ "neighborhood",
496
+ f"Records around seed Records. args: {{refs: [Kind/key] (1-{_MAX_SEEDS}), depth?: "
497
+ "1-2, relations?: [relation], max_nodes?, values?: false for headers only}",
498
+ NeighborhoodArgs,
499
+ _neighborhood,
500
+ ),
501
+ Tool(
502
+ "traverse",
503
+ "Follow Relations from a Record. args: {start: Kind/key, path: [{relation, where?: "
504
+ f"predicate as in query, limit?}}] (1-{_MAX_PATH} hops)}}",
505
+ TraverseArgs,
506
+ _traverse,
507
+ ),
508
+ Tool(
509
+ "history",
510
+ "Recent changes of a Record. args: {ref, limit?: 1-20}",
511
+ HistoryArgs,
512
+ _history,
513
+ ),
514
+ Tool("lineage", "Where a Record's values came from. args: {ref}", RefArgs, _lineage),
515
+ Tool(
516
+ "context",
517
+ "A context pack around refs or a search. args: {refs?: [Kind/key], search?: text, "
518
+ "budget_tokens?, depth?}",
519
+ ContextArgs,
520
+ _context,
521
+ ),
522
+ )
523
+ }
524
+
525
+
526
+ def _keeper_error(exc: LorebaseError) -> str:
527
+ if exc.status == 403:
528
+ return "permission denied: this caller may not read that"
529
+ if exc.status == 404:
530
+ return "not found (or not visible to this caller)"
531
+ if exc.status in (400, 422):
532
+ problems = "; ".join(
533
+ f"{e.get('pointer', '')}: {e.get('message', '')}" for e in exc.errors[:5]
534
+ )
535
+ return f"invalid request: {problems or exc.detail or exc.title or 'rejected'}"[:400]
536
+ if exc.status == 429:
537
+ return "rate limited by Keeper"
538
+ return f"Keeper error {exc.status or 'unreachable'}"
539
+
540
+
541
+ async def run_tool(ctx: ToolContext, name: str, raw_args: Any) -> ToolResult:
542
+ """Validate and run one tool call; failures become results the model can read."""
543
+ tool = TOOLS.get(name)
544
+ if tool is None:
545
+ return ToolResult(False, f"unknown tool {name[:40]!r}", {"error": "unknown tool"})
546
+ try:
547
+ args = tool.args.model_validate(raw_args if isinstance(raw_args, dict) else {})
548
+ except ValidationError as exc:
549
+ problems = [
550
+ f"{'.'.join(str(p) for p in e['loc']) or 'args'}: {e['msg']}" for e in exc.errors()
551
+ ][:5]
552
+ return ToolResult(
553
+ False, "invalid arguments", {"error": "invalid arguments", "problems": problems}
554
+ )
555
+ try:
556
+ return await tool.run(ctx, args)
557
+ except ValueError as exc: # ToolInputError, and path segments the client refuses
558
+ return ToolResult(False, "invalid arguments", {"error": str(exc)[:200]})
559
+ except LorebaseError as exc:
560
+ message = _keeper_error(exc)
561
+ return ToolResult(False, message, {"error": message})
@@ -0,0 +1,68 @@
1
+ """Agent trace of an exploration (docs/agent-trace.md).
2
+
3
+ Each exploration is one trace session: every Keeper read carries the session header, the
4
+ opt-in header and the name of the tool that made it, so Keeper records the timeline of the
5
+ calls; the model's reasoning between calls is posted as session notes. Notes are best effort:
6
+ a Keeper without the notes route (or with tracing off) never fails an exploration, and after
7
+ the first refusal no further note of the session is sent.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import uuid
14
+
15
+ from lorebase_client import AsyncLorebaseClient
16
+ from lorebase_client.core import Call, parse_json
17
+ from lorebase_client.errors import LorebaseError
18
+ from lorebase_guide.settings import TraceSettings
19
+
20
+ logger = logging.getLogger("lorebase.guide.trace")
21
+
22
+ SESSION_PREFIX = "guide-"
23
+
24
+
25
+ def new_session_id() -> str:
26
+ """A session id in the shape Keeper accepts (`[A-Za-z0-9._:-]{1,128}`)."""
27
+ return SESSION_PREFIX + uuid.uuid4().hex
28
+
29
+
30
+ class TraceSession:
31
+ """Headers and notes of one exploration's trace session."""
32
+
33
+ def __init__(self, settings: TraceSettings, session_id: str, domain: str) -> None:
34
+ self.settings = settings
35
+ self.session_id = session_id
36
+ self.domain = domain
37
+ self._notes_enabled = settings.enabled and settings.notes
38
+
39
+ def headers(self) -> dict[str, str]:
40
+ """Headers sent with every Keeper request of the exploration."""
41
+ if not self.settings.enabled:
42
+ return {}
43
+ headers = {self.settings.session_header: self.session_id}
44
+ if self.settings.opt_in_value:
45
+ headers[self.settings.opt_in_header] = self.settings.opt_in_value
46
+ return headers
47
+
48
+ def tool_headers(self, tool: str) -> dict[str, str]:
49
+ """Header naming the tool of one Keeper request."""
50
+ return {self.settings.tool_header: f"guide.{tool}"} if self.settings.enabled else {}
51
+
52
+ async def note(self, client: AsyncLorebaseClient, text: str) -> bool:
53
+ """Post `text` as a note of the session; False when it was not accepted."""
54
+ text = text.strip()
55
+ if not self._notes_enabled or not text:
56
+ return False
57
+ path = self.settings.notes_path.replace("{session}", self.session_id)
58
+ body = {"text": text[: self.settings.max_note_chars], "domain_id": self.domain}
59
+ try:
60
+ await client.send(Call("POST", path, parse_json, body=body))
61
+ except LorebaseError as exc:
62
+ self._notes_enabled = False
63
+ logger.info(
64
+ "trace notes disabled for this session",
65
+ extra={"status": exc.status, "session": self.session_id},
66
+ )
67
+ return False
68
+ return True
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.5
2
+ Name: lorebase-guide
3
+ Version: 0.2.3
4
+ Summary: Lorebase Guide: optional exploration assistant and query embedding over Keeper
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: fastapi>=0.115
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: lorebase-client<0.3,>=0.2
9
+ Requires-Dist: lorebase-inference<0.3,>=0.2
10
+ Requires-Dist: lorebase-platform<0.3,>=0.2
11
+ Requires-Dist: opentelemetry-api>=1.25
12
+ Requires-Dist: pydantic>=2.7
13
+ Requires-Dist: uvicorn>=0.30
14
+ Provides-Extra: fastembed
15
+ Requires-Dist: lorebase-inference[fastembed]<0.3,>=0.2; extra == 'fastembed'
@@ -0,0 +1,18 @@
1
+ lorebase_guide/__init__.py,sha256=KZT2HbNkt2TxhBTQjsgJEvKaAnig6YDK5f3urf0Gvto,313
2
+ lorebase_guide/__main__.py,sha256=WPZUA53Zshz0yCBU0NmGURFrYt_DGrOZwCAgedSQ6XM,3198
3
+ lorebase_guide/agent.py,sha256=9z06tJ_2Ljpw5X7k-eej7yGazNDhn86b88ffKb7j96o,13712
4
+ lorebase_guide/app.py,sha256=wqinXvtR6wVMXiAfQFVKM5T2Sjo6VwjM1_UBKadDCD0,17199
5
+ lorebase_guide/auth.py,sha256=iuGa54NNOzRiZF9kvp82R197yKOQMPqisjwIubjSOyo,7812
6
+ lorebase_guide/embed.py,sha256=m8JS7AAY_benJ8n6mR-070NM_1XQzxijhEve2Qj_grg,2656
7
+ lorebase_guide/keeper.py,sha256=zyEgloxbqhI5R_WVdlMGWyRxF85dw0Q_68dw4kFL2nY,3669
8
+ lorebase_guide/limits.py,sha256=o6vlGIM9u-VWfJ3vBsnZuyZbK0BX1SQGRpFYw381AqA,3465
9
+ lorebase_guide/modes.py,sha256=h1tMSqsTmEf7XIjuc0UDvBiy73M7K6pFgnLLckOleW4,5906
10
+ lorebase_guide/prompts.py,sha256=M0_IoNgf2w99EVF7TePGxKf_UJdUjKGoK1Ak5gtnF_U,4173
11
+ lorebase_guide/settings.py,sha256=C1Znzy_LFc8q9R9YRkKxZc6FbdA5OkEOMcWeXuubrjI,11647
12
+ lorebase_guide/telemetry.py,sha256=j3nKduuuXe_Pk6BXl1mBEb3Rhh-Ae8Hd2kHgh5I_7VE,1552
13
+ lorebase_guide/tools.py,sha256=Lz4rxSml0-W3ZbOncHq7ckm41e4osyjQhW3xNCcYUwg,21159
14
+ lorebase_guide/trace.py,sha256=yI10h327ZxN0kBZMRRoYXT2t_nPP3CheSl1N4gg7fhk,2717
15
+ lorebase_guide-0.2.3.dist-info/METADATA,sha256=taPsw1VezxrwcuA_0FFuRalBshXQI9s7kSZf3xVc9so,555
16
+ lorebase_guide-0.2.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
17
+ lorebase_guide-0.2.3.dist-info/entry_points.txt,sha256=wIOYEQWxC-cLndg3zCNGRpnSed6QeIqV20ocUuag118,64
18
+ lorebase_guide-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lorebase-guide = lorebase_guide.__main__:main