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.
- lorebase_guide/__init__.py +10 -0
- lorebase_guide/__main__.py +93 -0
- lorebase_guide/agent.py +337 -0
- lorebase_guide/app.py +451 -0
- lorebase_guide/auth.py +183 -0
- lorebase_guide/embed.py +74 -0
- lorebase_guide/keeper.py +97 -0
- lorebase_guide/limits.py +113 -0
- lorebase_guide/modes.py +156 -0
- lorebase_guide/prompts.py +89 -0
- lorebase_guide/settings.py +269 -0
- lorebase_guide/telemetry.py +42 -0
- lorebase_guide/tools.py +561 -0
- lorebase_guide/trace.py +68 -0
- lorebase_guide-0.2.3.dist-info/METADATA +15 -0
- lorebase_guide-0.2.3.dist-info/RECORD +18 -0
- lorebase_guide-0.2.3.dist-info/WHEEL +4 -0
- lorebase_guide-0.2.3.dist-info/entry_points.txt +2 -0
lorebase_guide/tools.py
ADDED
|
@@ -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})
|
lorebase_guide/trace.py
ADDED
|
@@ -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,,
|