lorebase-keeper 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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,403 @@
1
+ """Context packs: request, response shape and the JSON rendering of their sections.
2
+
3
+ A pack has six sections, rendered in this order: `seeds` (the Records asked for, with every
4
+ value), `neighbors` (Records around them up to the requested depth), `links` (grouped by the
5
+ forward api name of their Relation), `schema` (only the Kinds and Relations present),
6
+ `recent_changes` and `lineage`. The `budget` block accounts for them (see `agents.budget`).
7
+ Values stay in canonical form (design 6.1).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from collections.abc import Iterable, Mapping
14
+ from typing import Any, Literal, Self
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
17
+
18
+ from lorebase_core.model import Attribute, Kind, LinkDirection, Relation, Schema
19
+ from lorebase_core.query import Query
20
+ from lorebase_core.types import AttributeType, TypeSpec
21
+ from lorebase_keeper.agents.budget import (
22
+ BudgetResult,
23
+ ChangeItem,
24
+ Draft,
25
+ LineageItem,
26
+ LinkItem,
27
+ NeighborItem,
28
+ SeedItem,
29
+ estimate_tokens,
30
+ )
31
+
32
+ SECTIONS = ("seeds", "neighbors", "links", "schema", "recent_changes", "lineage")
33
+ PackFormat = Literal["json", "markdown"]
34
+
35
+
36
+ class ContextRequest(BaseModel):
37
+ """What an agent needs to reason about: seed Records and how much around them."""
38
+
39
+ model_config = ConfigDict(extra="forbid", frozen=True)
40
+
41
+ branch: str | None = Field(default=None, description="Default: the Domain's default branch.")
42
+ at: str | None = Field(
43
+ default=None,
44
+ description="Revision id or tag to read at (time travel, served from a snapshot).",
45
+ )
46
+ refs: list[str] | None = Field(
47
+ default=None, min_length=1, description="Seed Records as `Kind/key` references."
48
+ )
49
+ query: Query | None = Field(default=None, description="DSL query selecting the seeds.")
50
+ search: str | None = Field(
51
+ default=None, min_length=1, description="Lexical search text selecting the seeds."
52
+ )
53
+ budget_tokens: int | None = Field(
54
+ default=None, ge=1, description="Estimated token budget; default from settings."
55
+ )
56
+ depth: int | None = Field(default=None, ge=0, description="Neighborhood depth (0: none).")
57
+ relations: list[str] | None = Field(
58
+ default=None,
59
+ description="Relation api names to follow (forward or inverse); default: all.",
60
+ )
61
+ include_schema: bool = True
62
+ include_recent_changes: bool = True
63
+ include_lineage: bool = True
64
+ since: str | None = Field(
65
+ default=None,
66
+ description="Revision id or tag: recent changes after it; default: the latest changes.",
67
+ )
68
+ format: PackFormat = Field(
69
+ default="json", description="`markdown` answers text/markdown to paste into a prompt."
70
+ )
71
+
72
+ @model_validator(mode="after")
73
+ def _one_selector(self) -> Self:
74
+ given = [name for name in ("refs", "query", "search") if getattr(self, name) is not None]
75
+ if len(given) != 1:
76
+ raise ValueError("give exactly one of refs, query or search")
77
+ return self
78
+
79
+
80
+ # Response shape (documentation and tests; bodies are built as plain JSON) -------------------
81
+
82
+
83
+ class _Out(BaseModel):
84
+ model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
85
+
86
+
87
+ class PackRecordOut(_Out):
88
+ ref: str
89
+ title: str | None = None
90
+ values: dict[str, JsonValue]
91
+ updated_revision: str | None = None
92
+
93
+
94
+ class PackNeighborOut(_Out):
95
+ ref: str
96
+ title: str | None = None
97
+ depth: int
98
+ values: dict[str, JsonValue] | None = Field(default=None, description="Absent when trimmed.")
99
+
100
+
101
+ class PackLinkOut(_Out):
102
+ source: str = Field(alias="from")
103
+ to: str
104
+ values: dict[str, JsonValue] | None = None
105
+
106
+
107
+ class SchemaAttributeOut(_Out):
108
+ name: str
109
+ type: str
110
+ description: str | None = None
111
+ required: bool | None = None
112
+ derived: bool | None = None
113
+
114
+
115
+ class SchemaKindOut(_Out):
116
+ name: str
117
+ description: str | None = None
118
+ key: list[str]
119
+ title: str | None = None
120
+ attributes: list[SchemaAttributeOut]
121
+
122
+
123
+ class SchemaRelationOut(_Out):
124
+ name: str
125
+ inverse: str
126
+ source: str = Field(alias="from")
127
+ to: str
128
+ cardinality: str
129
+ description: str | None = None
130
+ attributes: list[SchemaAttributeOut] | None = None
131
+
132
+
133
+ class SchemaFragmentOut(_Out):
134
+ kinds: list[SchemaKindOut]
135
+ relations: list[SchemaRelationOut]
136
+
137
+
138
+ class PackChangeOut(_Out):
139
+ revision: str
140
+ at: str
141
+ ref: str
142
+ change: Literal["added", "modified", "removed"]
143
+ attributes: list[str] | None = None
144
+ actor: str
145
+ origin: str
146
+ message: str
147
+
148
+
149
+ class PackLineageOut(_Out):
150
+ ref: str
151
+ attributes: list[str]
152
+ origin: str | None = None
153
+ actor: str | None = None
154
+ at: str | None = None
155
+
156
+
157
+ class TrimOut(_Out):
158
+ step: str
159
+ removed: int
160
+
161
+
162
+ class BudgetOut(_Out):
163
+ limit_tokens: int
164
+ used_tokens: int = Field(description="Estimated tokens of the sections.")
165
+ chars_per_token: float
166
+ sections: dict[str, int] = Field(description="Estimated tokens per section.")
167
+ trimmed: list[TrimOut]
168
+ omitted: dict[str, int] = Field(description="Entries removed to fit, per section.")
169
+ over_budget: bool = Field(description="True when even the seed Records exceed the budget.")
170
+ capped: bool = Field(description="True when graph caps cut the neighborhood.")
171
+
172
+
173
+ class ContextPackOut(_Out):
174
+ """A context pack in JSON (`format: json`)."""
175
+
176
+ domain: str
177
+ branch: str
178
+ revision: str
179
+ etag: str
180
+ seeds: list[PackRecordOut]
181
+ neighbors: list[PackNeighborOut]
182
+ links: dict[str, list[PackLinkOut]]
183
+ schema_: SchemaFragmentOut | None = Field(default=None, alias="schema")
184
+ recent_changes: list[PackChangeOut] | None = None
185
+ lineage: list[PackLineageOut] | None = None
186
+ since: str | None = None
187
+ budget: BudgetOut
188
+
189
+
190
+ # Rendering ----------------------------------------------------------------------------------
191
+
192
+
193
+ def dumps(value: Any) -> str:
194
+ """Compact JSON, as sent and as measured."""
195
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
196
+
197
+
198
+ def type_label(spec: TypeSpec, schema: Schema) -> str:
199
+ """Short type name: `decimal(10,2)`, `enum(a|b)`, `array<string>`, `ref(Kind)`."""
200
+ if spec.name is AttributeType.ENUM:
201
+ return "enum(" + "|".join(spec.enum_values or ()) + ")"
202
+ if spec.name is AttributeType.DECIMAL:
203
+ return f"decimal({spec.precision},{spec.scale})"
204
+ if spec.name is AttributeType.REF:
205
+ kind = schema.kinds.get(spec.kind_ref or "")
206
+ return f"ref({kind.api_name if kind is not None else spec.kind_ref})"
207
+ if spec.name is AttributeType.ARRAY and spec.element is not None:
208
+ return f"array<{type_label(spec.element, schema)}>"
209
+ return str(spec.name)
210
+
211
+
212
+ def ordered_attributes(owner: Kind | Relation) -> list[Attribute]:
213
+ """Attributes in a stable reading order: key Attributes, the title Attribute, then the rest
214
+ by api name (compiled schemas do not keep the authoring order)."""
215
+ attributes = owner.attributes
216
+ first: list[str] = []
217
+ if isinstance(owner, Kind):
218
+ first = [*owner.primary_key, *([owner.title_attribute] if owner.title_attribute else [])]
219
+ leading = [attributes[a] for a in dict.fromkeys(first) if a in attributes]
220
+ rest = sorted((a for a in attributes.values() if a.id not in first), key=lambda a: a.api_name)
221
+ return [*leading, *rest]
222
+
223
+
224
+ def ordered_values(owner: Kind | Relation | None, values: Mapping[str, Any]) -> dict[str, Any]:
225
+ """`values` (keyed by api name) in `ordered_attributes` order."""
226
+ if owner is None:
227
+ return dict(values)
228
+ return {
229
+ a.api_name: values[a.api_name] for a in ordered_attributes(owner) if a.api_name in values
230
+ }
231
+
232
+
233
+ def _attribute(attribute: Attribute, schema: Schema, described: bool) -> dict[str, Any]:
234
+ out: dict[str, Any] = {"name": attribute.api_name, "type": type_label(attribute.type, schema)}
235
+ if described and attribute.description:
236
+ out["description"] = attribute.description
237
+ if attribute.required:
238
+ out["required"] = True
239
+ if attribute.is_derived:
240
+ out["derived"] = True
241
+ return out
242
+
243
+
244
+ def kind_fragment(kind: Kind, schema: Schema, described: bool = True) -> dict[str, Any]:
245
+ names = {a.id: a.api_name for a in kind.attributes.values()}
246
+ out: dict[str, Any] = {"name": kind.api_name}
247
+ if described and kind.description:
248
+ out["description"] = kind.description
249
+ out["key"] = [names.get(a, a) for a in kind.primary_key] or ["key"]
250
+ if kind.title_attribute is not None:
251
+ out["title"] = names.get(kind.title_attribute, kind.title_attribute)
252
+ out["attributes"] = [_attribute(a, schema, described) for a in ordered_attributes(kind)]
253
+ return out
254
+
255
+
256
+ def relation_fragment(relation: Relation, schema: Schema, described: bool = True) -> dict[str, Any]:
257
+ def name(kind_id: str) -> str:
258
+ kind = schema.kinds.get(kind_id)
259
+ return kind.api_name if kind is not None else kind_id
260
+
261
+ out: dict[str, Any] = {
262
+ "name": relation.api_name,
263
+ "inverse": relation.inverse_api_name,
264
+ "from": name(relation.from_kind),
265
+ "to": name(relation.to_kind),
266
+ "cardinality": str(relation.cardinality),
267
+ }
268
+ if described and relation.description:
269
+ out["description"] = relation.description
270
+ if relation.attributes:
271
+ out["attributes"] = [_attribute(a, schema, described) for a in ordered_attributes(relation)]
272
+ return out
273
+
274
+
275
+ def present_kinds(draft: Draft) -> list[str]:
276
+ """Api names of the Kinds of the Records in the draft, sorted."""
277
+ return sorted({s.kind for s in draft.seeds} | {n.kind for n in draft.neighbors})
278
+
279
+
280
+ def present_relations(draft: Draft) -> list[str]:
281
+ """Forward api names of the Relations of the Links in the draft, sorted."""
282
+ return sorted({link.relation for link in draft.links})
283
+
284
+
285
+ def schema_fragment(draft: Draft, schema: Schema) -> dict[str, Any]:
286
+ kinds = [schema.kind_by_api_name(name) for name in present_kinds(draft)]
287
+ relations = [schema.relation_by_api_name(name) for name in present_relations(draft)]
288
+ return {
289
+ "kinds": [kind_fragment(k, schema, draft.schema_descriptions) for k in kinds if k],
290
+ "relations": [
291
+ relation_fragment(found[0], schema, draft.schema_descriptions)
292
+ for found in relations
293
+ if found is not None and found[1] is LinkDirection.FORWARD
294
+ ],
295
+ }
296
+
297
+
298
+ def seed_json(item: SeedItem) -> dict[str, Any]:
299
+ out: dict[str, Any] = {"ref": item.ref}
300
+ if item.title is not None:
301
+ out["title"] = item.title
302
+ out["values"] = dict(item.values)
303
+ if item.updated_revision is not None:
304
+ out["updated_revision"] = item.updated_revision
305
+ return out
306
+
307
+
308
+ def neighbor_json(item: NeighborItem) -> dict[str, Any]:
309
+ out: dict[str, Any] = {"ref": item.ref}
310
+ if item.title is not None:
311
+ out["title"] = item.title
312
+ out["depth"] = item.depth
313
+ if item.values is not None:
314
+ out["values"] = dict(item.values)
315
+ return out
316
+
317
+
318
+ def links_json(links: Iterable[LinkItem]) -> dict[str, list[dict[str, Any]]]:
319
+ grouped: dict[str, list[dict[str, Any]]] = {}
320
+ for link in links:
321
+ entry: dict[str, Any] = {"from": link.source, "to": link.target}
322
+ if link.values:
323
+ entry["values"] = dict(link.values)
324
+ grouped.setdefault(link.relation, []).append(entry)
325
+ return grouped
326
+
327
+
328
+ def change_json(item: ChangeItem) -> dict[str, Any]:
329
+ out: dict[str, Any] = {
330
+ "revision": item.revision,
331
+ "at": item.at,
332
+ "ref": item.ref,
333
+ "change": item.change,
334
+ }
335
+ if item.attributes:
336
+ out["attributes"] = list(item.attributes)
337
+ out.update(actor=item.actor, origin=item.origin, message=item.message)
338
+ return out
339
+
340
+
341
+ def lineage_json(item: LineageItem) -> dict[str, Any]:
342
+ out: dict[str, Any] = {"ref": item.ref, "attributes": list(item.attributes)}
343
+ for name in ("origin", "actor", "at"):
344
+ value = getattr(item, name)
345
+ if value is not None:
346
+ out[name] = value
347
+ return out
348
+
349
+
350
+ def json_sections(draft: Draft, schema: Schema) -> dict[str, Any]:
351
+ """Value of each present section, in output order."""
352
+ out: dict[str, Any] = {
353
+ "seeds": [seed_json(s) for s in draft.seeds],
354
+ "neighbors": [neighbor_json(n) for n in draft.neighbors],
355
+ "links": links_json(draft.links),
356
+ }
357
+ if draft.schema:
358
+ out["schema"] = schema_fragment(draft, schema)
359
+ if draft.changes is not None:
360
+ out["recent_changes"] = [change_json(c) for c in draft.changes]
361
+ if draft.lineage is not None:
362
+ out["lineage"] = [lineage_json(item) for item in draft.lineage]
363
+ return out
364
+
365
+
366
+ def json_measure(schema: Schema) -> Any:
367
+ """Characters of each section in compact JSON, a `budget.Measure`."""
368
+
369
+ def measure(draft: Draft) -> Mapping[str, int]:
370
+ return {name: len(dumps(value)) for name, value in json_sections(draft, schema).items()}
371
+
372
+ return measure
373
+
374
+
375
+ def budget_json(
376
+ result: BudgetResult,
377
+ original: Draft,
378
+ *,
379
+ budget_tokens: int,
380
+ chars_per_token: float,
381
+ capped: bool,
382
+ ) -> dict[str, Any]:
383
+ final = result.draft
384
+ omitted = {
385
+ "neighbors": len(original.neighbors) - len(final.neighbors),
386
+ "neighbor_values": sum(1 for n in final.neighbors if n.values is None)
387
+ - sum(1 for n in original.neighbors if n.values is None),
388
+ "links": len(original.links) - len(final.links),
389
+ "recent_changes": len(original.changes or ()) - len(final.changes or ()),
390
+ "lineage": len(original.lineage or ()) - len(final.lineage or ()),
391
+ }
392
+ return {
393
+ "limit_tokens": budget_tokens,
394
+ "used_tokens": estimate_tokens(result.chars, chars_per_token),
395
+ "chars_per_token": chars_per_token,
396
+ "sections": {
397
+ name: estimate_tokens(chars, chars_per_token) for name, chars in result.sections.items()
398
+ },
399
+ "trimmed": [{"step": str(t.step), "removed": t.removed} for t in result.trims],
400
+ "omitted": {name: count for name, count in omitted.items() if count},
401
+ "over_budget": result.over_budget,
402
+ "capped": capped,
403
+ }
@@ -0,0 +1,111 @@
1
+ """The `agents` settings section: context packs, Record explanations and the change feed.
2
+
3
+ Registered with the platform like `read` and `search`; see `config/lorebase.example.yaml`.
4
+ Graph caps of context packs are further bounded by `read.traverse`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Self
10
+
11
+ from pydantic import Field, model_validator
12
+
13
+ from lorebase_keeper.agents.budget import DEFAULT_TRIM_ORDER, TrimStep
14
+ from lorebase_keeper.reading.settings import Bounds
15
+ from lorebase_platform.settings import SectionModel, Settings, register_section
16
+
17
+ SECTION = "agents"
18
+
19
+
20
+ class ContextSettings(SectionModel):
21
+ """`POST /context`: token budget, graph caps and trimming order of context packs."""
22
+
23
+ default_budget_tokens: int = Field(default=4000, ge=1, description="Budget when omitted.")
24
+ max_budget_tokens: int = Field(default=32000, ge=1, description="Largest budget accepted.")
25
+ chars_per_token: float = Field(
26
+ default=4.0, gt=0, description="Characters per estimated token (tokens = chars / this)."
27
+ )
28
+ default_depth: int = Field(default=1, ge=0, description="Neighborhood depth when omitted.")
29
+ max_depth: int = Field(default=2, ge=0, description="Deepest neighborhood accepted.")
30
+ max_seeds: int = Field(default=20, ge=1, description="Seed Records of one pack.")
31
+ max_neighbors: int = Field(default=200, ge=0, description="Records around the seeds.")
32
+ max_links: int = Field(default=500, ge=0, description="Links of one pack.")
33
+ recent_changes_max: int = Field(
34
+ default=50, ge=1, description="Change entries read before trimming, newest first."
35
+ )
36
+ trim_order: tuple[TrimStep, ...] = Field(
37
+ default=DEFAULT_TRIM_ORDER,
38
+ description="Order in which sections are trimmed to fit the budget; steps left out are "
39
+ "never applied. Seed Records are never trimmed.",
40
+ )
41
+
42
+ @model_validator(mode="after")
43
+ def _consistent(self) -> Self:
44
+ if self.default_budget_tokens > self.max_budget_tokens:
45
+ raise ValueError("default_budget_tokens cannot exceed max_budget_tokens")
46
+ if self.default_depth > self.max_depth:
47
+ raise ValueError("default_depth cannot exceed max_depth")
48
+ if len(set(self.trim_order)) != len(self.trim_order):
49
+ raise ValueError("trim_order lists a step twice")
50
+ return self
51
+
52
+
53
+ class ExplainSettings(SectionModel):
54
+ """`GET .../records/{key}/explain`."""
55
+
56
+ max_links: int = Field(default=100, ge=0, description="Links listed with their origin.")
57
+ max_proposals: int = Field(default=20, ge=0, description="Proposals listed.")
58
+
59
+
60
+ class FeedSettings(SectionModel):
61
+ """Change feed of a branch: Server-Sent Events and the polling fallback."""
62
+
63
+ poll_interval_s: float = Field(
64
+ default=1.0, gt=0, description="How often an open stream looks for new events."
65
+ )
66
+ heartbeat_s: float = Field(
67
+ default=15.0, gt=0, description="A comment line is sent after this much silence."
68
+ )
69
+ max_connection_s: float = Field(
70
+ default=300.0,
71
+ gt=0,
72
+ description="A stream is closed after this long; clients reconnect with Last-Event-ID.",
73
+ )
74
+ reauthorize_interval_s: float = Field(
75
+ default=60.0,
76
+ gt=0,
77
+ description="An open stream authenticates the caller and checks its grants again this "
78
+ "often, through the same caches as a request, and closes once that fails: a revoked "
79
+ "grant ends it within this interval plus `keeper.auth.authz.grants_cache_ttl_s`.",
80
+ )
81
+ retry_ms: int = Field(default=3000, ge=0, description="Reconnection delay advised to clients.")
82
+ batch_events: int = Field(default=100, ge=1, description="Events read per poll.")
83
+ changed_keys_max: int = Field(
84
+ default=100, ge=0, description="Changed Record keys listed per event; more are cut."
85
+ )
86
+ changes: Bounds = Field(
87
+ default_factory=lambda: Bounds(default_limit=50, max_limit=500),
88
+ description="Revisions per page of `GET .../changes`.",
89
+ )
90
+ max_scan: int = Field(
91
+ default=10_000,
92
+ ge=1,
93
+ description="First-parent Revisions walked back from the head to find `since`; an "
94
+ "older `since` is refused (409: read the current state, then follow from the head).",
95
+ )
96
+
97
+
98
+ class AgentSettings(SectionModel):
99
+ """Agent-facing reads."""
100
+
101
+ context: ContextSettings = Field(default_factory=ContextSettings)
102
+ explain: ExplainSettings = Field(default_factory=ExplainSettings)
103
+ feed: FeedSettings = Field(default_factory=FeedSettings)
104
+
105
+
106
+ register_section(SECTION, AgentSettings)
107
+
108
+
109
+ def agent_settings(settings: Settings) -> AgentSettings:
110
+ """The validated `agents` section."""
111
+ return settings.section(SECTION, AgentSettings)