agentfootprint 9.47.0 → 9.48.0
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.
- package/CLAUDE.md +11 -3
- package/dist/core/Agent.js +14 -1
- package/dist/core/Agent.js.map +1 -1
- package/dist/core/agent/AgentBuilder.js +147 -3
- package/dist/core/agent/AgentBuilder.js.map +1 -1
- package/dist/core/agent/runManifest.js +7 -0
- package/dist/core/agent/runManifest.js.map +1 -1
- package/dist/doors/recipes.js +55 -0
- package/dist/doors/recipes.js.map +1 -0
- package/dist/esm/core/Agent.d.ts +9 -1
- package/dist/esm/core/Agent.js +14 -1
- package/dist/esm/core/Agent.js.map +1 -1
- package/dist/esm/core/agent/AgentBuilder.d.ts +68 -0
- package/dist/esm/core/agent/AgentBuilder.js +147 -3
- package/dist/esm/core/agent/AgentBuilder.js.map +1 -1
- package/dist/esm/core/agent/runManifest.d.ts +18 -0
- package/dist/esm/core/agent/runManifest.js +7 -0
- package/dist/esm/core/agent/runManifest.js.map +1 -1
- package/dist/esm/doors/recipes.d.ts +38 -0
- package/dist/esm/doors/recipes.js +39 -0
- package/dist/esm/doors/recipes.js.map +1 -0
- package/dist/esm/events/payloads.d.ts +29 -0
- package/dist/esm/observe.d.ts +2 -0
- package/dist/esm/observe.js +12 -0
- package/dist/esm/observe.js.map +1 -1
- package/dist/esm/recipes/apply.d.ts +67 -0
- package/dist/esm/recipes/apply.js +117 -0
- package/dist/esm/recipes/apply.js.map +1 -0
- package/dist/esm/recipes/defineAgentRecipe.d.ts +68 -0
- package/dist/esm/recipes/defineAgentRecipe.js +112 -0
- package/dist/esm/recipes/defineAgentRecipe.js.map +1 -0
- package/dist/esm/recipes/identifier.d.ts +40 -0
- package/dist/esm/recipes/identifier.js +95 -0
- package/dist/esm/recipes/identifier.js.map +1 -0
- package/dist/esm/recipes/index.d.ts +19 -0
- package/dist/esm/recipes/index.js +19 -0
- package/dist/esm/recipes/index.js.map +1 -0
- package/dist/esm/recipes/provenance.d.ts +57 -0
- package/dist/esm/recipes/provenance.js +53 -0
- package/dist/esm/recipes/provenance.js.map +1 -0
- package/dist/esm/recipes/types.d.ts +134 -0
- package/dist/esm/recipes/types.js +63 -0
- package/dist/esm/recipes/types.js.map +1 -0
- package/dist/esm/recipes/version.d.ts +38 -0
- package/dist/esm/recipes/version.js +84 -0
- package/dist/esm/recipes/version.js.map +1 -0
- package/dist/esm/recorders/observability/fileRecordingSink.d.ts +104 -0
- package/dist/esm/recorders/observability/fileRecordingSink.js +195 -0
- package/dist/esm/recorders/observability/fileRecordingSink.js.map +1 -0
- package/dist/esm/recorders/observability/recordingEnvelope.d.ts +292 -0
- package/dist/esm/recorders/observability/recordingEnvelope.js +375 -0
- package/dist/esm/recorders/observability/recordingEnvelope.js.map +1 -0
- package/dist/observe.js +23 -1
- package/dist/observe.js.map +1 -1
- package/dist/recipes/apply.js +125 -0
- package/dist/recipes/apply.js.map +1 -0
- package/dist/recipes/defineAgentRecipe.js +118 -0
- package/dist/recipes/defineAgentRecipe.js.map +1 -0
- package/dist/recipes/identifier.js +100 -0
- package/dist/recipes/identifier.js.map +1 -0
- package/dist/recipes/index.js +24 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/recipes/provenance.js +57 -0
- package/dist/recipes/provenance.js.map +1 -0
- package/dist/recipes/types.js +64 -0
- package/dist/recipes/types.js.map +1 -0
- package/dist/recipes/version.js +89 -0
- package/dist/recipes/version.js.map +1 -0
- package/dist/recorders/observability/fileRecordingSink.js +201 -0
- package/dist/recorders/observability/fileRecordingSink.js.map +1 -0
- package/dist/recorders/observability/recordingEnvelope.js +382 -0
- package/dist/recorders/observability/recordingEnvelope.js.map +1 -0
- package/dist/types/core/Agent.d.ts +9 -1
- package/dist/types/core/Agent.d.ts.map +1 -1
- package/dist/types/core/agent/AgentBuilder.d.ts +68 -0
- package/dist/types/core/agent/AgentBuilder.d.ts.map +1 -1
- package/dist/types/core/agent/runManifest.d.ts +18 -0
- package/dist/types/core/agent/runManifest.d.ts.map +1 -1
- package/dist/types/doors/recipes.d.ts +39 -0
- package/dist/types/doors/recipes.d.ts.map +1 -0
- package/dist/types/events/payloads.d.ts +29 -0
- package/dist/types/events/payloads.d.ts.map +1 -1
- package/dist/types/observe.d.ts +2 -0
- package/dist/types/observe.d.ts.map +1 -1
- package/dist/types/recipes/apply.d.ts +68 -0
- package/dist/types/recipes/apply.d.ts.map +1 -0
- package/dist/types/recipes/defineAgentRecipe.d.ts +69 -0
- package/dist/types/recipes/defineAgentRecipe.d.ts.map +1 -0
- package/dist/types/recipes/identifier.d.ts +41 -0
- package/dist/types/recipes/identifier.d.ts.map +1 -0
- package/dist/types/recipes/index.d.ts +20 -0
- package/dist/types/recipes/index.d.ts.map +1 -0
- package/dist/types/recipes/provenance.d.ts +58 -0
- package/dist/types/recipes/provenance.d.ts.map +1 -0
- package/dist/types/recipes/types.d.ts +135 -0
- package/dist/types/recipes/types.d.ts.map +1 -0
- package/dist/types/recipes/version.d.ts +39 -0
- package/dist/types/recipes/version.d.ts.map +1 -0
- package/dist/types/recorders/observability/fileRecordingSink.d.ts +105 -0
- package/dist/types/recorders/observability/fileRecordingSink.d.ts.map +1 -0
- package/dist/types/recorders/observability/recordingEnvelope.d.ts +293 -0
- package/dist/types/recorders/observability/recordingEnvelope.d.ts.map +1 -0
- package/package.json +14 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recordingEnvelope — the versioned, serialized contract around a recording.
|
|
3
|
+
*
|
|
4
|
+
* `recordRun` already freezes a run into `{ events, snapshot, structure }`, and
|
|
5
|
+
* that shape is exactly right for handing to a viewer in the same process. What
|
|
6
|
+
* it is NOT is a thing you can put on disk and read back next quarter: it
|
|
7
|
+
* carries no format marker, no producer version, no statement of which run it
|
|
8
|
+
* is, and no statement of whether it is the WHOLE run. So every consumer that
|
|
9
|
+
* wanted to archive one, attach one to a bug report, or feed one to an analysis
|
|
10
|
+
* tool invented its own wrapper — and each wrapper made a different guess about
|
|
11
|
+
* the same missing facts.
|
|
12
|
+
*
|
|
13
|
+
* This is the producer-owned answer: one envelope, one format string, and every
|
|
14
|
+
* field either a fact the library can prove or a fact the caller stated.
|
|
15
|
+
*
|
|
16
|
+
* import { persistRecording, fileRecordingSink } from 'agentfootprint/observe';
|
|
17
|
+
*
|
|
18
|
+
* const recorder = recordRun(agent);
|
|
19
|
+
* await agent.run({ message });
|
|
20
|
+
* await persistRecording(recorder, {
|
|
21
|
+
* sink: fileRecordingSink({ directory: './run-archive' }),
|
|
22
|
+
* run: { complete: true },
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* ## The rule this file exists to keep: never stamp a fact you had to guess
|
|
26
|
+
*
|
|
27
|
+
* An envelope is read by people and tools that were not there when the run
|
|
28
|
+
* happened. That makes every field a claim, and a claim that turns out to be a
|
|
29
|
+
* guess is worse than a missing field — a missing field sends the reader to
|
|
30
|
+
* look, a wrong field stops them looking. So each one has a stated source:
|
|
31
|
+
*
|
|
32
|
+
* runId, sessionId, derived from the EVENT META, which is the only
|
|
33
|
+
* principal, tenant place these are recorded, or stated by the caller.
|
|
34
|
+
* Never synthesized. See "identity" below.
|
|
35
|
+
* startedAt / endedAt derived from event wall clocks, but only where the
|
|
36
|
+
* stream can honestly supply them (see below).
|
|
37
|
+
* complete CALLER INPUT, always. Nothing in a finished
|
|
38
|
+
* recording says whether the run reached its end.
|
|
39
|
+
* droppedEvents read off the live recorder, which counts them; a
|
|
40
|
+
* bare recording carries no count, so it is asked for
|
|
41
|
+
* rather than assumed to be 0.
|
|
42
|
+
* configuration lifted from the run's own `run_configured` event —
|
|
43
|
+
* the manifest, which is names-and-ids only by law.
|
|
44
|
+
* producer read from the package manifests at runtime.
|
|
45
|
+
*
|
|
46
|
+
* Where a fact is neither derivable nor supplied, this REFUSES
|
|
47
|
+
* ({@link IndeterminateRunFactError}) instead of filling in a plausible value.
|
|
48
|
+
*
|
|
49
|
+
* ## Identity is never invented
|
|
50
|
+
*
|
|
51
|
+
* `principal` and `tenant` come from `EventMeta`, and `EventMeta`'s own law
|
|
52
|
+
* (see src/events/types.ts) is that they are stamped ONLY from an explicit
|
|
53
|
+
* `run(input, { identity })` — never from the run's internal identity, and
|
|
54
|
+
* never from a session id, because "a conversation id is not an actor". By
|
|
55
|
+
* sourcing them from the meta and nowhere else, this envelope inherits that
|
|
56
|
+
* guarantee whole: an anonymous run produces an envelope with no `principal`
|
|
57
|
+
* key at all, not a placeholder and not a session id wearing an actor's name.
|
|
58
|
+
*
|
|
59
|
+
* ## `runId` has two namespaces, and this one is deliberate
|
|
60
|
+
*
|
|
61
|
+
* A footprintjs snapshot carries its OWN engine-level `runId`
|
|
62
|
+
* (`<timestamp>-<padded counter>`, minted per `executor.run()`), while the
|
|
63
|
+
* agentfootprint events carry `run-<timestamp>-<seq>` from `makeRunId()`. They
|
|
64
|
+
* are different ids for different layers and they do not match. This envelope
|
|
65
|
+
* uses the EVENT-meta one exclusively, because that is the id `sessionId`,
|
|
66
|
+
* `principal` and `tenant` ride beside and the one every typed-event consumer
|
|
67
|
+
* correlates on. When the events cannot supply it, this refuses rather than
|
|
68
|
+
* falling back to the snapshot's — quietly substituting an id from another
|
|
69
|
+
* namespace would make two envelopes look comparable when they are not.
|
|
70
|
+
*/
|
|
71
|
+
import type { RunManifestLike } from '../../lib/context-bisect/arms/types.js';
|
|
72
|
+
import type { Recording, RunRecorder } from './recordRun.js';
|
|
73
|
+
/**
|
|
74
|
+
* The format marker. Bumped only for a change an older reader could not
|
|
75
|
+
* survive — a reader that does not recognise the string must refuse the file,
|
|
76
|
+
* never half-read it.
|
|
77
|
+
*/
|
|
78
|
+
export declare const RECORDING_ENVELOPE_FORMAT = "agentfootprint.recording.v1";
|
|
79
|
+
/**
|
|
80
|
+
* The privacy policy id a `'full'` envelope carries when the caller names none.
|
|
81
|
+
* A stable string so a retention rule can match on it rather than on prose.
|
|
82
|
+
*/
|
|
83
|
+
export declare const FULL_PRIVACY_POLICY_ID = "agentfootprint.privacy.full.v1";
|
|
84
|
+
/**
|
|
85
|
+
* What a caller may ASK for. Only `'full'` is implemented in v1; the other two
|
|
86
|
+
* are named here so the refusal can name them back, and so a consumer writing
|
|
87
|
+
* against a future version gets a compile-time hint rather than a typo.
|
|
88
|
+
*/
|
|
89
|
+
export type RecordingPrivacyMode = 'full' | 'structure-only' | 'redacted';
|
|
90
|
+
/** Versions of the two libraries that produced the bytes. */
|
|
91
|
+
export interface RecordingProducer {
|
|
92
|
+
/** This library's version, or `'unknown'` when the manifest is unreadable. */
|
|
93
|
+
readonly agentfootprintVersion: string;
|
|
94
|
+
/** The engine underneath it, or `'unknown'`. */
|
|
95
|
+
readonly footprintjsVersion: string;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* WHICH run this is, and how much of it this is.
|
|
99
|
+
*
|
|
100
|
+
* Every field is either derived from the recording's own events or stated by
|
|
101
|
+
* the caller — see the module header for the per-field source.
|
|
102
|
+
*/
|
|
103
|
+
export interface RecordingRun {
|
|
104
|
+
/** From `EventMeta.runId` — the agentfootprint run id, not the engine's. */
|
|
105
|
+
readonly runId: string;
|
|
106
|
+
/** Present only when the run was session-bound. Never invented. */
|
|
107
|
+
readonly sessionId?: string;
|
|
108
|
+
/** The actor the caller NAMED. Absent for an anonymous run. */
|
|
109
|
+
readonly principal?: string;
|
|
110
|
+
/** The tenant boundary the caller NAMED. Absent for a single-tenant run. */
|
|
111
|
+
readonly tenant?: string;
|
|
112
|
+
/** ISO 8601. The first recorded event's wall clock, unless the caller said. */
|
|
113
|
+
readonly startedAt: string;
|
|
114
|
+
/**
|
|
115
|
+
* ISO 8601. Absent when the recording is not `complete` and the caller named
|
|
116
|
+
* no end — a run that had not finished has no end time to report.
|
|
117
|
+
*/
|
|
118
|
+
readonly endedAt?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Did this recording capture the run through to its end?
|
|
121
|
+
*
|
|
122
|
+
* ALWAYS the caller's statement. A frozen recording looks identical whether
|
|
123
|
+
* it was taken after the run or from a crash handler mid-run, and there is no
|
|
124
|
+
* run-terminal event in the registry to check, so the library cannot know.
|
|
125
|
+
* Defaulting it to `true` would make every crash dump claim to be whole.
|
|
126
|
+
*/
|
|
127
|
+
readonly complete: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Events discarded to stay under the recorder's `maxEvents` cap. `0` means
|
|
130
|
+
* "none were dropped", proven — never "we did not look".
|
|
131
|
+
*/
|
|
132
|
+
readonly droppedEvents: number;
|
|
133
|
+
}
|
|
134
|
+
/** What the agent was configured as — names and ids only, no values. */
|
|
135
|
+
export interface RecordingConfiguration {
|
|
136
|
+
readonly agentId?: string;
|
|
137
|
+
/**
|
|
138
|
+
* The run manifest, as the run itself declared it in
|
|
139
|
+
* `agentfootprint.agent.run_configured`.
|
|
140
|
+
*
|
|
141
|
+
* Safe to archive by construction: the manifest's own law is NAMES AND IDS
|
|
142
|
+
* ONLY — no endpoints, directories, connection strings or keys — precisely
|
|
143
|
+
* because it was built to ride into recordings and shared traces.
|
|
144
|
+
*/
|
|
145
|
+
readonly manifest?: RunManifestLike;
|
|
146
|
+
}
|
|
147
|
+
/** What was done to the bytes before they were stored. */
|
|
148
|
+
export interface RecordingPrivacy {
|
|
149
|
+
/**
|
|
150
|
+
* `'full'` — the recording is exactly what `recordRun` captured, unredacted.
|
|
151
|
+
*
|
|
152
|
+
* A v1 envelope can say nothing else: the redacting modes are not built, and
|
|
153
|
+
* this refuses to produce a label it cannot honour.
|
|
154
|
+
*/
|
|
155
|
+
readonly mode: 'full';
|
|
156
|
+
/** Names the policy the producer applied. See {@link FULL_PRIVACY_POLICY_ID}. */
|
|
157
|
+
readonly policyId: string;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* One archived run: the recording, plus everything a reader needs to know what
|
|
161
|
+
* they are holding.
|
|
162
|
+
*
|
|
163
|
+
* Plain JSON by construction — no Dates, no Maps, no live handles — so
|
|
164
|
+
* `JSON.parse(JSON.stringify(envelope))` is the same envelope. Absent optional
|
|
165
|
+
* fields are absent KEYS rather than keys with `undefined`, which is what makes
|
|
166
|
+
* that round trip exact.
|
|
167
|
+
*/
|
|
168
|
+
export interface RecordingEnvelope {
|
|
169
|
+
readonly format: typeof RECORDING_ENVELOPE_FORMAT;
|
|
170
|
+
readonly producer: RecordingProducer;
|
|
171
|
+
readonly run: RecordingRun;
|
|
172
|
+
readonly configuration?: RecordingConfiguration;
|
|
173
|
+
readonly privacy: RecordingPrivacy;
|
|
174
|
+
/** The recording itself, unmodified — `{ snapshot, events, structure }`. */
|
|
175
|
+
readonly recording: Recording;
|
|
176
|
+
}
|
|
177
|
+
/** A timestamp a caller may state, in any of the three obvious spellings. */
|
|
178
|
+
export type RecordingTimestamp = string | number | Date;
|
|
179
|
+
/** The run facts a caller states — the half the library cannot derive. */
|
|
180
|
+
export interface RecordingRunFacts {
|
|
181
|
+
/**
|
|
182
|
+
* Did the recording capture the run through to its end?
|
|
183
|
+
*
|
|
184
|
+
* Required, and deliberately so: this is the one field with no derivation and
|
|
185
|
+
* no safe default, so the API asks rather than guesses. Say `false` for a
|
|
186
|
+
* recording frozen from a crash handler, a timeout, or mid-stream.
|
|
187
|
+
*/
|
|
188
|
+
readonly complete: boolean;
|
|
189
|
+
/** Override the event-derived run id, or supply it when events cannot. */
|
|
190
|
+
readonly runId?: string;
|
|
191
|
+
readonly sessionId?: string;
|
|
192
|
+
readonly principal?: string;
|
|
193
|
+
readonly tenant?: string;
|
|
194
|
+
readonly startedAt?: RecordingTimestamp;
|
|
195
|
+
readonly endedAt?: RecordingTimestamp;
|
|
196
|
+
/**
|
|
197
|
+
* Events lost to the recorder's cap. Read off a live `recordRun` handle
|
|
198
|
+
* automatically; state it when persisting a bare `Recording`, whose shape
|
|
199
|
+
* carries no count.
|
|
200
|
+
*/
|
|
201
|
+
readonly droppedEvents?: number;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Where an envelope goes. One method, so a destination is a few lines: a
|
|
205
|
+
* directory, an object store, a table, an HTTP endpoint.
|
|
206
|
+
*/
|
|
207
|
+
export interface RecordingSink {
|
|
208
|
+
/**
|
|
209
|
+
* Store one envelope.
|
|
210
|
+
*
|
|
211
|
+
* @returns `id` the sink's own handle for what it just stored.
|
|
212
|
+
* `uri` where it landed, when the sink has a meaningful address.
|
|
213
|
+
*/
|
|
214
|
+
write(envelope: RecordingEnvelope): Promise<{
|
|
215
|
+
id: string;
|
|
216
|
+
uri?: string;
|
|
217
|
+
}>;
|
|
218
|
+
}
|
|
219
|
+
/** Options for {@link persistRecording}. */
|
|
220
|
+
export interface PersistRecordingOptions {
|
|
221
|
+
readonly sink: RecordingSink;
|
|
222
|
+
/**
|
|
223
|
+
* The run facts. Required because {@link RecordingRunFacts.complete} is:
|
|
224
|
+
* an envelope that did not state whether it holds a whole run is an envelope
|
|
225
|
+
* whose reader has to guess.
|
|
226
|
+
*/
|
|
227
|
+
readonly run: RecordingRunFacts;
|
|
228
|
+
/** Override the manifest lifted from the run's own `run_configured` event. */
|
|
229
|
+
readonly configuration?: RecordingConfiguration;
|
|
230
|
+
readonly privacy?: {
|
|
231
|
+
readonly mode?: RecordingPrivacyMode;
|
|
232
|
+
readonly policyId?: string;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Options for {@link buildRecordingEnvelope}. */
|
|
236
|
+
export type BuildRecordingEnvelopeOptions = Omit<PersistRecordingOptions, 'sink'>;
|
|
237
|
+
/** A recording to envelope, or the live handle that is still collecting one. */
|
|
238
|
+
export type RecordingSource = Recording | RunRecorder;
|
|
239
|
+
/**
|
|
240
|
+
* Raised when a privacy mode is asked for that this version cannot honour.
|
|
241
|
+
*
|
|
242
|
+
* Its own class because the answer is never "retry": a caller catching this has
|
|
243
|
+
* to change what they store or how they store it.
|
|
244
|
+
*/
|
|
245
|
+
export declare class UnsupportedPrivacyModeError extends Error {
|
|
246
|
+
readonly code: "ERR_UNSUPPORTED_PRIVACY_MODE";
|
|
247
|
+
readonly mode: string;
|
|
248
|
+
constructor(mode: string);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Raised when a required run fact is neither derivable from the recording nor
|
|
252
|
+
* stated by the caller.
|
|
253
|
+
*
|
|
254
|
+
* Carries the `field` so a caller can catch it and supply exactly that one.
|
|
255
|
+
*/
|
|
256
|
+
export declare class IndeterminateRunFactError extends Error {
|
|
257
|
+
readonly code: "ERR_INDETERMINATE_RUN_FACT";
|
|
258
|
+
readonly field: string;
|
|
259
|
+
constructor(field: string, detail: string);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Freeze a recording into an archivable envelope, without storing it.
|
|
263
|
+
*
|
|
264
|
+
* The half of {@link persistRecording} that has no destination — useful when
|
|
265
|
+
* the envelope goes somewhere this library should not know about (a request
|
|
266
|
+
* body, a queue), and the unit under test for the contract itself.
|
|
267
|
+
*/
|
|
268
|
+
export declare function buildRecordingEnvelope(source: RecordingSource, options: BuildRecordingEnvelopeOptions): RecordingEnvelope;
|
|
269
|
+
/**
|
|
270
|
+
* Build the envelope and hand it to a sink.
|
|
271
|
+
*
|
|
272
|
+
* @param source the handle from `recordRun(agent)`, or the recording it made.
|
|
273
|
+
* Prefer the handle: it is the only thing that knows how many
|
|
274
|
+
* events the cap discarded.
|
|
275
|
+
* @param options the destination, the run facts, and the privacy statement.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* const recorder = recordRun(agent);
|
|
280
|
+
* await agent.run({ message: 'Weather in San Francisco?' });
|
|
281
|
+
*
|
|
282
|
+
* const { uri } = await persistRecording(recorder, {
|
|
283
|
+
* sink: fileRecordingSink({ directory: './run-archive' }),
|
|
284
|
+
* run: { complete: true },
|
|
285
|
+
* });
|
|
286
|
+
* recorder.stop();
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
export declare function persistRecording(source: RecordingSource, options: PersistRecordingOptions): Promise<{
|
|
290
|
+
id: string;
|
|
291
|
+
uri?: string;
|
|
292
|
+
}>;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recordingEnvelope — the versioned, serialized contract around a recording.
|
|
3
|
+
*
|
|
4
|
+
* `recordRun` already freezes a run into `{ events, snapshot, structure }`, and
|
|
5
|
+
* that shape is exactly right for handing to a viewer in the same process. What
|
|
6
|
+
* it is NOT is a thing you can put on disk and read back next quarter: it
|
|
7
|
+
* carries no format marker, no producer version, no statement of which run it
|
|
8
|
+
* is, and no statement of whether it is the WHOLE run. So every consumer that
|
|
9
|
+
* wanted to archive one, attach one to a bug report, or feed one to an analysis
|
|
10
|
+
* tool invented its own wrapper — and each wrapper made a different guess about
|
|
11
|
+
* the same missing facts.
|
|
12
|
+
*
|
|
13
|
+
* This is the producer-owned answer: one envelope, one format string, and every
|
|
14
|
+
* field either a fact the library can prove or a fact the caller stated.
|
|
15
|
+
*
|
|
16
|
+
* import { persistRecording, fileRecordingSink } from 'agentfootprint/observe';
|
|
17
|
+
*
|
|
18
|
+
* const recorder = recordRun(agent);
|
|
19
|
+
* await agent.run({ message });
|
|
20
|
+
* await persistRecording(recorder, {
|
|
21
|
+
* sink: fileRecordingSink({ directory: './run-archive' }),
|
|
22
|
+
* run: { complete: true },
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* ## The rule this file exists to keep: never stamp a fact you had to guess
|
|
26
|
+
*
|
|
27
|
+
* An envelope is read by people and tools that were not there when the run
|
|
28
|
+
* happened. That makes every field a claim, and a claim that turns out to be a
|
|
29
|
+
* guess is worse than a missing field — a missing field sends the reader to
|
|
30
|
+
* look, a wrong field stops them looking. So each one has a stated source:
|
|
31
|
+
*
|
|
32
|
+
* runId, sessionId, derived from the EVENT META, which is the only
|
|
33
|
+
* principal, tenant place these are recorded, or stated by the caller.
|
|
34
|
+
* Never synthesized. See "identity" below.
|
|
35
|
+
* startedAt / endedAt derived from event wall clocks, but only where the
|
|
36
|
+
* stream can honestly supply them (see below).
|
|
37
|
+
* complete CALLER INPUT, always. Nothing in a finished
|
|
38
|
+
* recording says whether the run reached its end.
|
|
39
|
+
* droppedEvents read off the live recorder, which counts them; a
|
|
40
|
+
* bare recording carries no count, so it is asked for
|
|
41
|
+
* rather than assumed to be 0.
|
|
42
|
+
* configuration lifted from the run's own `run_configured` event —
|
|
43
|
+
* the manifest, which is names-and-ids only by law.
|
|
44
|
+
* producer read from the package manifests at runtime.
|
|
45
|
+
*
|
|
46
|
+
* Where a fact is neither derivable nor supplied, this REFUSES
|
|
47
|
+
* ({@link IndeterminateRunFactError}) instead of filling in a plausible value.
|
|
48
|
+
*
|
|
49
|
+
* ## Identity is never invented
|
|
50
|
+
*
|
|
51
|
+
* `principal` and `tenant` come from `EventMeta`, and `EventMeta`'s own law
|
|
52
|
+
* (see src/events/types.ts) is that they are stamped ONLY from an explicit
|
|
53
|
+
* `run(input, { identity })` — never from the run's internal identity, and
|
|
54
|
+
* never from a session id, because "a conversation id is not an actor". By
|
|
55
|
+
* sourcing them from the meta and nowhere else, this envelope inherits that
|
|
56
|
+
* guarantee whole: an anonymous run produces an envelope with no `principal`
|
|
57
|
+
* key at all, not a placeholder and not a session id wearing an actor's name.
|
|
58
|
+
*
|
|
59
|
+
* ## `runId` has two namespaces, and this one is deliberate
|
|
60
|
+
*
|
|
61
|
+
* A footprintjs snapshot carries its OWN engine-level `runId`
|
|
62
|
+
* (`<timestamp>-<padded counter>`, minted per `executor.run()`), while the
|
|
63
|
+
* agentfootprint events carry `run-<timestamp>-<seq>` from `makeRunId()`. They
|
|
64
|
+
* are different ids for different layers and they do not match. This envelope
|
|
65
|
+
* uses the EVENT-meta one exclusively, because that is the id `sessionId`,
|
|
66
|
+
* `principal` and `tenant` ride beside and the one every typed-event consumer
|
|
67
|
+
* correlates on. When the events cannot supply it, this refuses rather than
|
|
68
|
+
* falling back to the snapshot's — quietly substituting an id from another
|
|
69
|
+
* namespace would make two envelopes look comparable when they are not.
|
|
70
|
+
*/
|
|
71
|
+
import { manifestFromEvents } from '../../lib/context-bisect/arms/manifest.js';
|
|
72
|
+
import { engineVersion, libraryVersion } from '../../lib/libraryVersion.js';
|
|
73
|
+
/**
|
|
74
|
+
* The format marker. Bumped only for a change an older reader could not
|
|
75
|
+
* survive — a reader that does not recognise the string must refuse the file,
|
|
76
|
+
* never half-read it.
|
|
77
|
+
*/
|
|
78
|
+
export const RECORDING_ENVELOPE_FORMAT = 'agentfootprint.recording.v1';
|
|
79
|
+
/**
|
|
80
|
+
* The privacy policy id a `'full'` envelope carries when the caller names none.
|
|
81
|
+
* A stable string so a retention rule can match on it rather than on prose.
|
|
82
|
+
*/
|
|
83
|
+
export const FULL_PRIVACY_POLICY_ID = 'agentfootprint.privacy.full.v1';
|
|
84
|
+
// ─── Refusals ────────────────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* Raised when a privacy mode is asked for that this version cannot honour.
|
|
87
|
+
*
|
|
88
|
+
* Its own class because the answer is never "retry": a caller catching this has
|
|
89
|
+
* to change what they store or how they store it.
|
|
90
|
+
*/
|
|
91
|
+
export class UnsupportedPrivacyModeError extends Error {
|
|
92
|
+
code = 'ERR_UNSUPPORTED_PRIVACY_MODE';
|
|
93
|
+
mode;
|
|
94
|
+
constructor(mode) {
|
|
95
|
+
super(`[recording-envelope] privacy mode '${mode}' is not implemented. ` +
|
|
96
|
+
`${RECORDING_ENVELOPE_FORMAT} can produce 'full' envelopes only — the bytes exactly ` +
|
|
97
|
+
`as recordRun captured them.\n\n` +
|
|
98
|
+
`This refuses rather than approximating because the label is what downstream readers ` +
|
|
99
|
+
`act on: an archive browser decides what to show, a retention rule decides how long to ` +
|
|
100
|
+
`keep it, and a triage tool decides who may open it — all from this field. An envelope ` +
|
|
101
|
+
`stamped '${mode}' over un-redacted bytes would be handled with LESS care than one ` +
|
|
102
|
+
`that admits it is raw, so silently storing the raw bytes under that label is the ` +
|
|
103
|
+
`worse outcome, not the convenient one.\n\n` +
|
|
104
|
+
`Either persist with privacy: { mode: 'full' } and hold the result under the rules raw ` +
|
|
105
|
+
`run data needs, or redact BEFORE persisting — recordRun(agent, { boundaryDetail: ` +
|
|
106
|
+
`'lean' }) captures no payloads in the first place, and serializeTrace/redactContent ` +
|
|
107
|
+
`redact at the serialize boundary.`);
|
|
108
|
+
this.name = 'UnsupportedPrivacyModeError';
|
|
109
|
+
this.mode = mode;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Raised when a required run fact is neither derivable from the recording nor
|
|
114
|
+
* stated by the caller.
|
|
115
|
+
*
|
|
116
|
+
* Carries the `field` so a caller can catch it and supply exactly that one.
|
|
117
|
+
*/
|
|
118
|
+
export class IndeterminateRunFactError extends Error {
|
|
119
|
+
code = 'ERR_INDETERMINATE_RUN_FACT';
|
|
120
|
+
field;
|
|
121
|
+
constructor(field, detail) {
|
|
122
|
+
super(`[recording-envelope] cannot determine run.${field}: ${detail}`);
|
|
123
|
+
this.name = 'IndeterminateRunFactError';
|
|
124
|
+
this.field = field;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// ─── Reading the recording ───────────────────────────────────────────
|
|
128
|
+
const isRecord = (value) => typeof value === 'object' && value !== null;
|
|
129
|
+
const isFn = (value) => typeof value === 'function';
|
|
130
|
+
/** A live `recordRun` handle is the one that can also report its drop count. */
|
|
131
|
+
function asRunRecorder(source) {
|
|
132
|
+
return isRecord(source) && isFn(source.toRecording)
|
|
133
|
+
? source
|
|
134
|
+
: undefined;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Every DISTINCT defined value of one `meta` field across the stream.
|
|
138
|
+
*
|
|
139
|
+
* Distinct rather than first-wins on purpose. One recording is meant to be one
|
|
140
|
+
* run, but a recorder left attached across `run()` and then `resume()` sees two
|
|
141
|
+
* run ids, and a stream carrying two principals is two actors. Collapsing
|
|
142
|
+
* either to "the first one" is how an archive ends up labelled with the wrong
|
|
143
|
+
* actor — so the caller is made to say which, rather than being told a guess.
|
|
144
|
+
*/
|
|
145
|
+
function distinctMetaValues(events, field) {
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
for (const event of events) {
|
|
148
|
+
const meta = isRecord(event) ? event.meta : undefined;
|
|
149
|
+
if (!isRecord(meta))
|
|
150
|
+
continue;
|
|
151
|
+
const value = meta[field];
|
|
152
|
+
if (typeof value === 'string' && value !== '')
|
|
153
|
+
seen.add(value);
|
|
154
|
+
}
|
|
155
|
+
return [...seen];
|
|
156
|
+
}
|
|
157
|
+
/** The wall clock of the first / last event that carries one, in epoch ms. */
|
|
158
|
+
function edgeWallClock(events, edge) {
|
|
159
|
+
const ordered = edge === 'first' ? events : [...events].reverse();
|
|
160
|
+
for (const event of ordered) {
|
|
161
|
+
const meta = isRecord(event) ? event.meta : undefined;
|
|
162
|
+
if (!isRecord(meta))
|
|
163
|
+
continue;
|
|
164
|
+
const ms = meta.wallClockMs;
|
|
165
|
+
if (typeof ms === 'number' && Number.isFinite(ms))
|
|
166
|
+
return ms;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* One optional identity field, derived under the never-invent rule.
|
|
172
|
+
*
|
|
173
|
+
* @returns the single value, or `undefined` meaning ABSENT — which is a real
|
|
174
|
+
* answer here ("this run named no tenant"), not a missing one.
|
|
175
|
+
*/
|
|
176
|
+
function soleIdentity(events, field, stated) {
|
|
177
|
+
if (stated !== undefined)
|
|
178
|
+
return stated;
|
|
179
|
+
const found = distinctMetaValues(events, field);
|
|
180
|
+
if (found.length === 0)
|
|
181
|
+
return undefined;
|
|
182
|
+
if (found.length === 1)
|
|
183
|
+
return found[0];
|
|
184
|
+
throw new IndeterminateRunFactError(field, `the recording's events carry ${String(found.length)} different values ` +
|
|
185
|
+
`(${found.map((v) => JSON.stringify(v)).join(', ')}), so there is no single one to ` +
|
|
186
|
+
`stamp. Naming one of them here would put a value in the envelope that is wrong for ` +
|
|
187
|
+
`some of the events inside it. Split the recording per ${field}, or state the intended ` +
|
|
188
|
+
`one explicitly as run.${field}.`);
|
|
189
|
+
}
|
|
190
|
+
/** Normalize a caller timestamp to ISO 8601, refusing anything unreadable. */
|
|
191
|
+
function toIso(value, field) {
|
|
192
|
+
const date = value instanceof Date ? value : typeof value === 'number' ? new Date(value) : new Date(value);
|
|
193
|
+
const ms = date.getTime();
|
|
194
|
+
if (Number.isNaN(ms)) {
|
|
195
|
+
throw new IndeterminateRunFactError(field, `${JSON.stringify(String(value))} is not a readable date. Pass an ISO 8601 string, epoch ` +
|
|
196
|
+
`milliseconds, or a Date.`);
|
|
197
|
+
}
|
|
198
|
+
return date.toISOString();
|
|
199
|
+
}
|
|
200
|
+
// ─── Building ────────────────────────────────────────────────────────
|
|
201
|
+
/** `'full'`, or a named refusal. Never a silent downgrade. */
|
|
202
|
+
function resolvePrivacy(privacy) {
|
|
203
|
+
const mode = privacy?.mode ?? 'full';
|
|
204
|
+
if (mode !== 'full')
|
|
205
|
+
throw new UnsupportedPrivacyModeError(String(mode));
|
|
206
|
+
return { mode: 'full', policyId: privacy?.policyId ?? FULL_PRIVACY_POLICY_ID };
|
|
207
|
+
}
|
|
208
|
+
/** How many events the cap discarded — proven, or asked for. */
|
|
209
|
+
function resolveDroppedEvents(source, stated) {
|
|
210
|
+
if (stated !== undefined) {
|
|
211
|
+
if (!Number.isInteger(stated) || stated < 0) {
|
|
212
|
+
throw new IndeterminateRunFactError('droppedEvents', `${JSON.stringify(stated)} is not a count. Pass a non-negative integer.`);
|
|
213
|
+
}
|
|
214
|
+
return stated;
|
|
215
|
+
}
|
|
216
|
+
const recorder = asRunRecorder(source);
|
|
217
|
+
if (recorder !== undefined)
|
|
218
|
+
return recorder.droppedEvents;
|
|
219
|
+
throw new IndeterminateRunFactError('droppedEvents', `a bare Recording carries no drop count — only the live recordRun handle counts what the ` +
|
|
220
|
+
`maxEvents cap discarded, and that handle is gone by the time a recording is a plain ` +
|
|
221
|
+
`object. Reporting 0 here would turn "we did not look" into "none were dropped", which ` +
|
|
222
|
+
`is the difference between a timeline that starts at the run's beginning and one that ` +
|
|
223
|
+
`merely appears to. Pass the handle from recordRun(agent) instead of its recording, or ` +
|
|
224
|
+
`state run.droppedEvents.`);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Freeze a recording into an archivable envelope, without storing it.
|
|
228
|
+
*
|
|
229
|
+
* The half of {@link persistRecording} that has no destination — useful when
|
|
230
|
+
* the envelope goes somewhere this library should not know about (a request
|
|
231
|
+
* body, a queue), and the unit under test for the contract itself.
|
|
232
|
+
*/
|
|
233
|
+
export function buildRecordingEnvelope(source, options) {
|
|
234
|
+
if (!isRecord(source)) {
|
|
235
|
+
throw new TypeError(`[recording-envelope] expected a Recording ({ snapshot, events, structure }) or the ` +
|
|
236
|
+
`handle from recordRun(agent); received ${typeof source}.`);
|
|
237
|
+
}
|
|
238
|
+
const facts = options.run;
|
|
239
|
+
if (!isRecord(facts) || typeof facts.complete !== 'boolean') {
|
|
240
|
+
throw new TypeError(`[recording-envelope] run.complete must be stated as true or false. Nothing in a frozen ` +
|
|
241
|
+
`recording says whether it reached the run's end — a crash-handler snapshot and a ` +
|
|
242
|
+
`finished run look identical — so this is asked for rather than guessed. Defaulting it ` +
|
|
243
|
+
`would make every partial recording claim to be whole.`);
|
|
244
|
+
}
|
|
245
|
+
// Resolve privacy FIRST: an unsupported mode must refuse before any work is
|
|
246
|
+
// done that a caller could mistake for progress toward a stored file.
|
|
247
|
+
const privacy = resolvePrivacy(options.privacy);
|
|
248
|
+
const recorder = asRunRecorder(source);
|
|
249
|
+
const recording = recorder ? recorder.toRecording() : source;
|
|
250
|
+
const events = Array.isArray(recording.events)
|
|
251
|
+
? recording.events
|
|
252
|
+
: [];
|
|
253
|
+
const droppedEvents = resolveDroppedEvents(source, facts.droppedEvents);
|
|
254
|
+
// ── runId ──
|
|
255
|
+
let runId;
|
|
256
|
+
if (facts.runId !== undefined) {
|
|
257
|
+
runId = facts.runId;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
const found = distinctMetaValues(events, 'runId');
|
|
261
|
+
if (found.length === 1) {
|
|
262
|
+
runId = found[0];
|
|
263
|
+
}
|
|
264
|
+
else if (found.length === 0) {
|
|
265
|
+
throw new IndeterminateRunFactError('runId', `no event in this recording carries meta.runId${events.length === 0 ? ' (the recording holds no events at all)' : ''}. The snapshot's own runId is deliberately NOT used as a fallback: it is the ` +
|
|
266
|
+
`footprintjs engine's id for the run, a different namespace from the ` +
|
|
267
|
+
`agentfootprint run id the events carry, so substituting it would make two ` +
|
|
268
|
+
`envelopes look comparable when they are not. Record with recordRun(agent) BEFORE ` +
|
|
269
|
+
`run(), or state run.runId.`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
throw new IndeterminateRunFactError('runId', `this recording spans ${String(found.length)} runs (${found
|
|
273
|
+
.map((v) => JSON.stringify(v))
|
|
274
|
+
.join(', ')}). A fresh run id is minted per run() AND per resume(), so a recorder ` +
|
|
275
|
+
`left attached across both sees two. An envelope names ONE run — split the ` +
|
|
276
|
+
`recording, or state which run.runId this archive is filed under.`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const sessionId = soleIdentity(events, 'sessionId', facts.sessionId);
|
|
280
|
+
const principal = soleIdentity(events, 'principal', facts.principal);
|
|
281
|
+
const tenant = soleIdentity(events, 'tenant', facts.tenant);
|
|
282
|
+
// ── startedAt ──
|
|
283
|
+
let startedAt;
|
|
284
|
+
if (facts.startedAt !== undefined) {
|
|
285
|
+
startedAt = toIso(facts.startedAt, 'startedAt');
|
|
286
|
+
}
|
|
287
|
+
else if (droppedEvents > 0) {
|
|
288
|
+
// The cap drops the OLDEST events, so the earliest event still held is not
|
|
289
|
+
// the one the run started with. Reading a start time off it would be off by
|
|
290
|
+
// however much of the run was discarded, silently.
|
|
291
|
+
throw new IndeterminateRunFactError('startedAt', `${String(droppedEvents)} event(s) were dropped from the head of this stream, so the ` +
|
|
292
|
+
`earliest retained event is NOT the run's first — deriving a start time from it would ` +
|
|
293
|
+
`report the moment recording overflowed, not the moment the run began. State ` +
|
|
294
|
+
`run.startedAt, or record with a larger maxEvents.`);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const first = edgeWallClock(events, 'first');
|
|
298
|
+
if (first === undefined) {
|
|
299
|
+
throw new IndeterminateRunFactError('startedAt', `no event in this recording carries a wall clock to read a start time from. State ` +
|
|
300
|
+
`run.startedAt.`);
|
|
301
|
+
}
|
|
302
|
+
startedAt = new Date(first).toISOString();
|
|
303
|
+
}
|
|
304
|
+
// ── endedAt ──
|
|
305
|
+
// Only a recording the caller calls COMPLETE gets an end time derived for it.
|
|
306
|
+
// In an incomplete recording the last retained event is just where watching
|
|
307
|
+
// stopped; the run has no end yet, and absent is the honest answer.
|
|
308
|
+
let endedAt;
|
|
309
|
+
if (facts.endedAt !== undefined) {
|
|
310
|
+
endedAt = toIso(facts.endedAt, 'endedAt');
|
|
311
|
+
}
|
|
312
|
+
else if (facts.complete) {
|
|
313
|
+
const last = edgeWallClock(events, 'last');
|
|
314
|
+
if (last !== undefined)
|
|
315
|
+
endedAt = new Date(last).toISOString();
|
|
316
|
+
}
|
|
317
|
+
// ── configuration ──
|
|
318
|
+
const manifest = options.configuration?.manifest ??
|
|
319
|
+
manifestFromEvents(events);
|
|
320
|
+
const agentId = options.configuration?.agentId ?? manifest?.agentId;
|
|
321
|
+
const configuration = agentId === undefined && manifest === undefined
|
|
322
|
+
? undefined
|
|
323
|
+
: { ...(agentId !== undefined && { agentId }), ...(manifest !== undefined && { manifest }) };
|
|
324
|
+
// Conditional spreads, not `undefined` values: an absent optional must be an
|
|
325
|
+
// absent KEY, or JSON.stringify drops it and the round trip stops being exact.
|
|
326
|
+
return {
|
|
327
|
+
format: RECORDING_ENVELOPE_FORMAT,
|
|
328
|
+
producer: {
|
|
329
|
+
agentfootprintVersion: libraryVersion(),
|
|
330
|
+
footprintjsVersion: engineVersion(),
|
|
331
|
+
},
|
|
332
|
+
run: {
|
|
333
|
+
runId,
|
|
334
|
+
...(sessionId !== undefined && { sessionId }),
|
|
335
|
+
...(principal !== undefined && { principal }),
|
|
336
|
+
...(tenant !== undefined && { tenant }),
|
|
337
|
+
startedAt,
|
|
338
|
+
...(endedAt !== undefined && { endedAt }),
|
|
339
|
+
complete: facts.complete,
|
|
340
|
+
droppedEvents,
|
|
341
|
+
},
|
|
342
|
+
...(configuration !== undefined && { configuration }),
|
|
343
|
+
privacy,
|
|
344
|
+
recording,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Build the envelope and hand it to a sink.
|
|
349
|
+
*
|
|
350
|
+
* @param source the handle from `recordRun(agent)`, or the recording it made.
|
|
351
|
+
* Prefer the handle: it is the only thing that knows how many
|
|
352
|
+
* events the cap discarded.
|
|
353
|
+
* @param options the destination, the run facts, and the privacy statement.
|
|
354
|
+
*
|
|
355
|
+
* @example
|
|
356
|
+
* ```ts
|
|
357
|
+
* const recorder = recordRun(agent);
|
|
358
|
+
* await agent.run({ message: 'Weather in San Francisco?' });
|
|
359
|
+
*
|
|
360
|
+
* const { uri } = await persistRecording(recorder, {
|
|
361
|
+
* sink: fileRecordingSink({ directory: './run-archive' }),
|
|
362
|
+
* run: { complete: true },
|
|
363
|
+
* });
|
|
364
|
+
* recorder.stop();
|
|
365
|
+
* ```
|
|
366
|
+
*/
|
|
367
|
+
export async function persistRecording(source, options) {
|
|
368
|
+
const sink = options.sink;
|
|
369
|
+
if (!isRecord(sink) || !isFn(sink.write)) {
|
|
370
|
+
throw new TypeError(`[recording-envelope] persistRecording needs a sink with a write(envelope) method — ` +
|
|
371
|
+
`e.g. fileRecordingSink({ directory: './run-archive' }).`);
|
|
372
|
+
}
|
|
373
|
+
return sink.write(buildRecordingEnvelope(source, options));
|
|
374
|
+
}
|
|
375
|
+
//# sourceMappingURL=recordingEnvelope.js.map
|