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,506 @@
1
+ """Agent trace API (docs/agent-trace.md): sessions, timelines and notes.
2
+
3
+ - `GET /trace/sessions`: sessions with a step the caller may see, most recent first;
4
+ - `GET /trace/sessions/{session_id}`: the timeline of one session, calls and notes in order;
5
+ - `POST /trace/sessions/{session_id}/notes`: attach a note (free text, such as an agent's
6
+ reasoning between two tool calls) to a session of the caller.
7
+
8
+ Every principal sees its own steps. Steps of other principals are visible in the Domains where
9
+ the caller holds trace.read through a Domain-wide grant (the `admin` role holds it); notes
10
+ not tied to a Domain and steps outside a Domain only to installation administrators. Refs in
11
+ steps of other principals are filtered to the Kinds the caller may read in that Domain. Notes
12
+ are opaque text: stored as sent (bounded), never interpreted.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from datetime import UTC, datetime
19
+ from typing import Annotated, Any, Literal, cast
20
+
21
+ from fastapi import APIRouter, Path, Query, Request
22
+ from pydantic import BaseModel, ConfigDict, Field
23
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
24
+
25
+ from lorebase_keeper.auth.audit import suppress
26
+ from lorebase_keeper.auth.deps import AuthDep, PrincipalDep, missing_permission
27
+ from lorebase_keeper.auth.permissions import Permission
28
+ from lorebase_keeper.auth.principals import Principal
29
+ from lorebase_keeper.auth.rbac import DOMAIN_SCOPE_PREFIX
30
+ from lorebase_keeper.auth.runtime import AuthRuntime
31
+ from lorebase_keeper.db import Database
32
+ from lorebase_keeper.deps import KeeperSettingsDep
33
+ from lorebase_keeper.errors import NOT_FOUND, UNAVAILABLE, VALIDATION_FAILED, Problem, ProblemError
34
+ from lorebase_keeper.pagination import PageParamsDep, encode_cursor
35
+ from lorebase_keeper.tracing.config import SESSION_ID
36
+ from lorebase_keeper.tracing.recorder import TraceRecorder
37
+ from lorebase_store.tracing import (
38
+ NewTraceStep,
39
+ TraceRepository,
40
+ TraceStepRecord,
41
+ TraceVisibility,
42
+ )
43
+
44
+ router = APIRouter(prefix="/trace", tags=["trace"])
45
+
46
+ _ERRORS: dict[int | str, dict[str, Any]] = {
47
+ 404: {"model": Problem, "description": "No visible step of that session."},
48
+ 503: {"model": Problem, "description": "No database is configured."},
49
+ }
50
+
51
+ SessionId = Annotated[
52
+ str,
53
+ Path(
54
+ min_length=1,
55
+ max_length=128,
56
+ pattern=SESSION_ID.pattern,
57
+ description="Session id as sent in the session header.",
58
+ ),
59
+ ]
60
+
61
+
62
+ class TraceSessionOut(BaseModel):
63
+ session_id: str
64
+ actor: str = Field(description="Principal that made the calls.")
65
+ started_at: datetime
66
+ last_at: datetime
67
+ calls: int
68
+ notes: int
69
+ domains: list[str]
70
+ tools: list[str]
71
+ proposals: list[str] = Field(description="Proposals the session opened or acted on.")
72
+ inferred: bool = Field(
73
+ default=False,
74
+ description="Grouped heuristically: the calls carried no session id and were grouped by "
75
+ "principal and client while they kept coming (keeper.trace.inferred_session_idle_s).",
76
+ )
77
+
78
+
79
+ class TraceSessionPage(BaseModel):
80
+ enabled: bool = Field(description="Whether new requests are traced (keeper.trace.enabled).")
81
+ items: list[TraceSessionOut]
82
+ next_cursor: str | None = None
83
+
84
+
85
+ class TraceRecordRef(BaseModel):
86
+ kind: str
87
+ key: str
88
+
89
+
90
+ class TraceLinkRef(BaseModel):
91
+ model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
92
+
93
+ relation: str
94
+ source: TraceRecordRef = Field(alias="from")
95
+ to: TraceRecordRef
96
+
97
+
98
+ class TraceRefs(BaseModel):
99
+ records: list[TraceRecordRef]
100
+ links: list[TraceLinkRef]
101
+ truncated: bool = Field(
102
+ default=False, description="More refs were touched than kept or than visible."
103
+ )
104
+
105
+
106
+ class TraceStepOut(BaseModel):
107
+ step: int = Field(description="Position in the timeline, from 1.")
108
+ seq: int
109
+ type: Literal["call", "note"]
110
+ at: datetime
111
+ actor: str
112
+ principal_type: str | None = None
113
+ domain_id: str | None = None
114
+ tool: str | None = None
115
+ method: str | None = None
116
+ route: str | None = Field(default=None, description="Route template, values unexpanded.")
117
+ status: int | None = None
118
+ latency_ms: float | None = None
119
+ cache: str | None = Field(default=None, description="Response cache: hit, miss or bypass.")
120
+ branch: str | None = None
121
+ query: dict[str, Any] | None = Field(
122
+ default=None,
123
+ description="The query as sent; filter values kept only on fields that are not "
124
+ "sensitive (keeper.trace.capture_filter_values).",
125
+ )
126
+ result: dict[str, Any] | None = Field(
127
+ default=None, description="Counts, search mode and corrections, budget; no values."
128
+ )
129
+ refs: TraceRefs | None = None
130
+ explain: dict[str, Any] | None = Field(
131
+ default=None, description="Query plan summaries (`X-Lorebase-Trace: debug`)."
132
+ )
133
+ note: str | None = Field(default=None, description="Text of a note; opaque, not markup.")
134
+ proposal_id: str | None = None
135
+ request_id: str | None = None
136
+ trace_id: str | None = Field(default=None, description="W3C trace id of the request.")
137
+ inferred: bool = Field(
138
+ default=False, description="The step had no session id; its session was inferred."
139
+ )
140
+ withheld: bool = Field(
141
+ default=False,
142
+ description="Another principal's step whose query, corrections and note text the "
143
+ "reader may not see (it lacks Domain-wide data.read or a sensitivity clearance).",
144
+ )
145
+
146
+
147
+ class TraceTimeline(BaseModel):
148
+ session_id: str
149
+ steps: list[TraceStepOut]
150
+ truncated: bool = Field(description="The session has more visible steps than returned.")
151
+
152
+
153
+ class NoteIn(BaseModel):
154
+ model_config = ConfigDict(extra="forbid")
155
+
156
+ text: str = Field(
157
+ min_length=1, description="Free text; bounded by keeper.trace.max_note_chars."
158
+ )
159
+ domain_id: str | None = Field(
160
+ default=None,
161
+ max_length=256,
162
+ description="Domain the note is about; principals holding trace.read there see it. "
163
+ "Needs data.read in that Domain.",
164
+ )
165
+ step: int | None = Field(
166
+ default=None, ge=0, description="The agent's own step counter, shown with the note."
167
+ )
168
+
169
+
170
+ class NoteAccepted(BaseModel):
171
+ recorded: bool = Field(description="False when tracing is disabled or the note was dropped.")
172
+
173
+
174
+ def _database(request: Request) -> Database:
175
+ database = cast(Database | None, request.app.state.database)
176
+ if database is None:
177
+ raise ProblemError(UNAVAILABLE, "Agent traces need a configured database.")
178
+ return database
179
+
180
+
181
+ def _sessions(request: Request) -> async_sessionmaker[AsyncSession]:
182
+ return _database(request).sessions
183
+
184
+
185
+ async def visibility(auth: AuthRuntime, principal: Principal) -> TraceVisibility:
186
+ """What `principal` may see of the traces (see the module doc)."""
187
+ domains = await auth.authorizer.domains_granting(principal, Permission.TRACE_READ)
188
+ domainless = principal.method == "none" or (
189
+ principal.installation_admin and not principal.key_scopes
190
+ )
191
+ owner_domains = (
192
+ frozenset(
193
+ s.removeprefix(DOMAIN_SCOPE_PREFIX)
194
+ for s in principal.key_scopes
195
+ if s.startswith(DOMAIN_SCOPE_PREFIX)
196
+ )
197
+ if principal.key_scopes
198
+ else None
199
+ )
200
+ return TraceVisibility(
201
+ owner=principal.id, domains=domains, domainless=domainless, owner_domains=owner_domains
202
+ )
203
+
204
+
205
+ @router.get(
206
+ "/sessions",
207
+ summary="List agent trace sessions",
208
+ response_model=TraceSessionPage,
209
+ responses=_ERRORS,
210
+ )
211
+ async def list_sessions(
212
+ request: Request,
213
+ principal: PrincipalDep,
214
+ auth: AuthDep,
215
+ keeper: KeeperSettingsDep,
216
+ page: PageParamsDep,
217
+ actor: Annotated[str | None, Query(max_length=256, description="Only this principal.")] = None,
218
+ domain_id: Annotated[
219
+ str | None, Query(max_length=256, description="Only steps in this Domain.")
220
+ ] = None,
221
+ ) -> TraceSessionPage:
222
+ """Sessions with a step the caller may see, most recent activity first."""
223
+ after = None
224
+ if page.position is not None:
225
+ try:
226
+ after = (
227
+ datetime.fromisoformat(str(page.position["at"])),
228
+ str(page.position["session"]),
229
+ str(page.position["actor"]),
230
+ )
231
+ except (KeyError, ValueError):
232
+ raise _invalid_cursor() from None
233
+ seen = await visibility(auth, principal)
234
+ async with _sessions(request)() as session:
235
+ found = await TraceRepository(session).sessions(
236
+ seen, limit=page.limit + 1, actor=actor, domain_id=domain_id, after=after
237
+ )
238
+ items = found[: page.limit]
239
+ next_cursor = None
240
+ if len(found) > page.limit:
241
+ last = items[-1]
242
+ next_cursor = encode_cursor(
243
+ {"at": last.last_at.isoformat(), "session": last.session_id, "actor": last.actor}
244
+ )
245
+ return TraceSessionPage(
246
+ enabled=keeper.trace.enabled,
247
+ items=[
248
+ TraceSessionOut(
249
+ session_id=s.session_id,
250
+ actor=s.actor,
251
+ started_at=s.started_at,
252
+ last_at=s.last_at,
253
+ calls=s.calls,
254
+ notes=s.notes,
255
+ domains=list(s.domains),
256
+ tools=list(s.tools),
257
+ proposals=list(s.proposals),
258
+ inferred=s.inferred,
259
+ )
260
+ for s in items
261
+ ],
262
+ next_cursor=next_cursor,
263
+ )
264
+
265
+
266
+ def _invalid_cursor() -> ProblemError:
267
+ return ProblemError(
268
+ VALIDATION_FAILED,
269
+ "One or more request members are invalid.",
270
+ extensions={
271
+ "errors": [{"pointer": "/query/cursor", "message": "is not valid", "code": "invalid"}]
272
+ },
273
+ )
274
+
275
+
276
+ CLEARANCES = (Permission.PII_READ, Permission.CONFIDENTIAL_READ, Permission.RESTRICTED_READ)
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class _View:
281
+ query: dict[str, Any] | None
282
+ result: dict[str, Any] | None
283
+ refs: TraceRefs | None
284
+ note: str | None
285
+ explain: dict[str, Any] | None
286
+ withheld: bool = False
287
+
288
+
289
+ class _RefFilter:
290
+ """Kinds a reader may read per Domain, looked up once per Domain."""
291
+
292
+ def __init__(self, auth: AuthRuntime, principal: Principal) -> None:
293
+ self._auth = auth
294
+ self._principal = principal
295
+ self._readable: dict[str, frozenset[str] | None] = {}
296
+ self._cleared: dict[str, bool] = {}
297
+ self._explains: dict[str, bool] = {}
298
+
299
+ async def kinds(self, domain_id: str) -> frozenset[str] | None:
300
+ """None: every Kind."""
301
+ if domain_id not in self._readable:
302
+ authorizer = self._auth.authorizer
303
+ if await authorizer.allows(self._principal, Permission.DATA_READ, domain_id):
304
+ self._readable[domain_id] = None
305
+ else:
306
+ self._readable[domain_id] = await authorizer.granted_kinds(
307
+ self._principal, Permission.DATA_READ, domain_id
308
+ )
309
+ return self._readable[domain_id]
310
+
311
+ async def cleared(self, domain_id: str) -> bool:
312
+ """Whether the reader may see what another principal typed in the Domain (queries,
313
+ search text and corrections, notes): Domain-wide data.read and every sensitivity
314
+ clearance, since such text may hold values the reader would see masked."""
315
+ if domain_id not in self._cleared:
316
+ authorizer = self._auth.authorizer
317
+ self._cleared[domain_id] = await self.kinds(domain_id) is None and all(
318
+ [
319
+ await authorizer.allows(self._principal, permission, domain_id)
320
+ for permission in CLEARANCES
321
+ ]
322
+ )
323
+ return self._cleared[domain_id]
324
+
325
+ async def explains(self, domain_id: str) -> bool:
326
+ if domain_id not in self._explains:
327
+ self._explains[domain_id] = await self._auth.authorizer.allows(
328
+ self._principal, Permission.TRACE_EXPLAIN, domain_id
329
+ )
330
+ return self._explains[domain_id]
331
+
332
+ async def view(self, step: TraceStepRecord) -> _View:
333
+ """A step as the reader may see it. Its own steps in full. Of another principal's
334
+ step: refs of the Kinds the reader may read; query, corrections and note text only for
335
+ a reader cleared in the Domain (`cleared`; outside a Domain, the installation
336
+ administrators who alone see such steps); query plans only with trace.explain."""
337
+ if step.actor == self._principal.id:
338
+ return _View(step.query, step.result, self.refs(step, None), step.note, step.explain)
339
+ if step.domain_id is None:
340
+ return _View(step.query, step.result, self.refs(step, None), step.note, step.explain)
341
+ allowed = await self.kinds(step.domain_id)
342
+ refs = self.refs(step, allowed)
343
+ explain = step.explain if await self.explains(step.domain_id) else None
344
+ if await self.cleared(step.domain_id):
345
+ return _View(step.query, step.result, refs, step.note, explain)
346
+ # A reader limited to Kinds that are invisible to it (design 13.3) learns nothing of
347
+ # the others: no counts, no query plans, no hint that refs were left out.
348
+ invisible = allowed is not None and self._auth.authorizer.invisible_kinds
349
+ kept = ("agent_step",) if invisible else ("counts", "agent_step")
350
+ result = step.result
351
+ if result is not None:
352
+ result = {k: v for k, v in result.items() if k in kept}
353
+ if invisible:
354
+ explain = None
355
+ note = None if step.note is None else ""
356
+ return _View(None, result, refs, note, explain, withheld=True)
357
+
358
+ def refs(self, step: TraceStepRecord, allowed: frozenset[str] | None) -> TraceRefs | None:
359
+ stored = step.refs
360
+ if not stored:
361
+ return None
362
+ records = [
363
+ TraceRecordRef(kind=r[0], key=r[1])
364
+ for r in stored.get("records") or ()
365
+ if allowed is None or r[0] in allowed
366
+ ]
367
+ links = [
368
+ TraceLinkRef(
369
+ relation=link[0],
370
+ source=TraceRecordRef(kind=link[1], key=link[2]),
371
+ to=TraceRecordRef(kind=link[3], key=link[4]),
372
+ )
373
+ for link in stored.get("links") or ()
374
+ if allowed is None or (link[1] in allowed and link[3] in allowed)
375
+ ]
376
+ hidden = len(records) + len(links) < len(stored.get("records") or ()) + len(
377
+ stored.get("links") or ()
378
+ )
379
+ truncated = bool(stored.get("truncated")) or hidden
380
+ if allowed is not None and self._auth.authorizer.invisible_kinds:
381
+ # Whether refs were left out, or cut by the recorder's cap (which counts every
382
+ # Kind), would tell about Kinds invisible to the reader.
383
+ truncated = False
384
+ return TraceRefs(records=records, links=links, truncated=truncated)
385
+
386
+
387
+ @router.get(
388
+ "/sessions/{session_id}",
389
+ summary="Timeline of an agent trace session",
390
+ response_model=TraceTimeline,
391
+ responses=_ERRORS,
392
+ )
393
+ async def get_session(
394
+ session_id: SessionId,
395
+ request: Request,
396
+ principal: PrincipalDep,
397
+ auth: AuthDep,
398
+ keeper: KeeperSettingsDep,
399
+ actor: Annotated[
400
+ str | None,
401
+ Query(
402
+ max_length=256,
403
+ description="Principal whose steps are read; default: the caller. A session id "
404
+ "names one session per principal.",
405
+ ),
406
+ ] = None,
407
+ ) -> TraceTimeline:
408
+ """Calls and notes of one principal's session in order, as far as the caller may see
409
+ them."""
410
+ actor = actor or principal.id
411
+ seen = await visibility(auth, principal)
412
+ limit = keeper.trace.max_steps_per_session
413
+ async with _sessions(request)() as session:
414
+ steps = await TraceRepository(session).steps(session_id, seen, limit=limit + 1, actor=actor)
415
+ if not steps:
416
+ raise ProblemError(NOT_FOUND, f"Agent trace session {session_id!r} not found.")
417
+ refs = _RefFilter(auth, principal)
418
+ out = []
419
+ for position, step in enumerate(steps[:limit], start=1):
420
+ view = await refs.view(step)
421
+ out.append(
422
+ TraceStepOut(
423
+ step=position,
424
+ seq=step.seq,
425
+ type=cast(Literal["call", "note"], step.type),
426
+ at=step.at,
427
+ actor=step.actor,
428
+ principal_type=step.principal_type,
429
+ domain_id=step.domain_id,
430
+ tool=step.tool,
431
+ method=step.method,
432
+ route=step.route,
433
+ status=step.status,
434
+ latency_ms=step.latency_ms,
435
+ cache=step.cache,
436
+ branch=step.branch,
437
+ query=view.query,
438
+ result=view.result,
439
+ refs=view.refs,
440
+ explain=view.explain,
441
+ note=view.note,
442
+ withheld=view.withheld,
443
+ proposal_id=step.proposal_id,
444
+ request_id=step.request_id,
445
+ trace_id=step.trace_id,
446
+ inferred=step.inferred,
447
+ )
448
+ )
449
+ return TraceTimeline(session_id=session_id, steps=out, truncated=len(steps) > limit)
450
+
451
+
452
+ @router.post(
453
+ "/sessions/{session_id}/notes",
454
+ status_code=202,
455
+ summary="Attach a note to an agent trace session",
456
+ response_model=NoteAccepted,
457
+ responses={
458
+ **_ERRORS,
459
+ 422: {"model": Problem, "description": "Empty or too long note, or bad session id."},
460
+ },
461
+ )
462
+ async def add_note(
463
+ session_id: SessionId,
464
+ body: NoteIn,
465
+ request: Request,
466
+ principal: PrincipalDep,
467
+ auth: AuthDep,
468
+ keeper: KeeperSettingsDep,
469
+ ) -> NoteAccepted:
470
+ """Queue a note of the caller's session (written off the request path, like calls)."""
471
+ suppress(request) # debugging data, not an action on the Domain: kept out of the audit log
472
+ settings = keeper.trace
473
+ if len(body.text) > settings.max_note_chars:
474
+ raise ProblemError(
475
+ VALIDATION_FAILED,
476
+ "One or more request members are invalid.",
477
+ extensions={
478
+ "errors": [
479
+ {
480
+ "pointer": "/body/text",
481
+ "message": f"must not exceed {settings.max_note_chars} characters",
482
+ "code": "too_long",
483
+ }
484
+ ]
485
+ },
486
+ )
487
+ if body.domain_id is not None and not await auth.authorizer.allows_anywhere(
488
+ principal, Permission.DATA_READ, body.domain_id
489
+ ):
490
+ raise missing_permission(Permission.DATA_READ, body.domain_id)
491
+ recorder = cast(TraceRecorder | None, request.app.state.trace)
492
+ if recorder is None:
493
+ return NoteAccepted(recorded=False)
494
+ recorded = recorder.offer(
495
+ NewTraceStep(
496
+ session_id=session_id,
497
+ actor=principal.id,
498
+ type="note",
499
+ at=datetime.now(UTC),
500
+ principal_type=principal.type.value,
501
+ domain_id=body.domain_id,
502
+ note=body.text,
503
+ result={"agent_step": body.step} if body.step is not None else None,
504
+ )
505
+ )
506
+ return NoteAccepted(recorded=recorded)