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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- 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)
|