@intentius/chant-lexicon-aws 0.44.10 → 0.44.13

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.
@@ -0,0 +1,754 @@
1
+ /**
2
+ * `awsAgentCoreFetchTrace` — pull a Bedrock AgentCore session history and
3
+ * render it as dogwood replay-trace text (#1685, follow-on from #1682).
4
+ *
5
+ * Contributed the way the fly lexicon contributes `flyApply` and the cedar
6
+ * lexicon contributes `dogwoodReplay`: a plain exported async function taking
7
+ * one args object, re-exported from `src/op/activities/index.ts`, resolved **by
8
+ * name** by core's activity registry when a project lists the `aws` lexicon.
9
+ * No Temporal import beneath it, so the local executor runs it unchanged and a
10
+ * Temporal worker registers the same function. Transport is injectable through
11
+ * the same `AwsReadHttp` seam `src/api/read-client.ts` already uses, so tests
12
+ * never touch the network and `endpoint` retargets the whole thing.
13
+ *
14
+ * The output is text. The cedar lexicon's `PolicyReplayOp` reads a trace from
15
+ * `tracePath`, so `outPath` here is the handoff — and it is the *only* handoff.
16
+ * Neither package imports the other; the decoupling contract is the grammar.
17
+ *
18
+ * ## Which AgentCore surface actually carries decision history
19
+ *
20
+ * Investigated before building, because the answer changes what can be shipped.
21
+ * Verified against the live API reference and devguide on 2026-08-10:
22
+ *
23
+ * - **There is no `GetTrace`, no `GetSession`, and no `ListRuntimeSessions`.**
24
+ * The `bedrock-agentcore` data plane (API version 2024-02-28) publishes
25
+ * `StopRuntimeSession` with no read counterpart — a runtime session can be
26
+ * killed but not enumerated. `bedrock-agentcore-control` (2023-06-05) is
27
+ * resource CRUD only.
28
+ * <https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_Operations.html>
29
+ * - **AgentCore Memory is the one fetchable session history**, and it is what
30
+ * this module reads: `ListSessions`
31
+ * (`POST /memories/{memoryId}/actor/{actorId}/sessions`) and `ListEvents`
32
+ * (`POST /memories/{memoryId}/actor/{actorId}/sessions/{sessionId}`), both
33
+ * paginated, with a `payload[]` of `conversational` (roles `ASSISTANT`,
34
+ * `USER`, `TOOL`, `OTHER`) or `blob` members.
35
+ * <https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_ListEvents.html>
36
+ * <https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_PayloadType.html>
37
+ * The caveat is load-bearing and is repeated in {@link AgentCoreMemorySource}:
38
+ * Memory holds what the agent *wrote* via `CreateEvent`. It is a store, not a
39
+ * service-side audit. An agent that never writes leaves nothing to replay.
40
+ * - **The service's own per-tool-call and per-decision records are CloudWatch
41
+ * Logs, not an API.** Gateway `tools/call` bodies land in
42
+ * `/aws/vendedlogs/bedrock-agentcore/gateway/APPLICATION_LOGS/{gateway_id}`;
43
+ * the allow/deny itself lives in `aws/spans` span attributes
44
+ * (`aws.agentcore.policy.authorization_decision` = `ALLOW`|`DENY`,
45
+ * `…determining_policies`, `…effects`, `…temporal.event_timestamp_ns`).
46
+ * <https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-gateway-metrics.html>
47
+ * <https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-policy-metrics.html>
48
+ * Both are reachable only through CloudWatch Logs Insights / X-Ray
49
+ * Transaction Search, and the gateway record's `requestBody` is a Java map
50
+ * `toString` (`{id=1, jsonrpc=2.0, method=tools/call, params={…}}`) rather
51
+ * than JSON. Those two sources are therefore *named and refused* here rather
52
+ * than guessed at — see {@link AgentCoreTraceUnavailableError}. An honest
53
+ * partial beats an invented API.
54
+ * - **No emulator serves any of it.** Floci's service index lists no
55
+ * `bedrock-agentcore` at all (its only Bedrock entry is a `bedrock-runtime`
56
+ * stub), and MiniStack implements twelve control-plane runtime/endpoint
57
+ * operations and states that Memory, Gateway, Identity and the rest are not
58
+ * implemented. A control-plane emulator is structurally incapable of serving
59
+ * decision history anyway, since every per-call record lives in CloudWatch.
60
+ * So the testable path today is the injected transport, and the mocked
61
+ * fixtures in `./trace-fetch.test.ts` are the contract.
62
+ *
63
+ * ## Signing
64
+ *
65
+ * SigV4 through `read-client.ts`'s `requestHeaders`, which is the same seam its
66
+ * own CloudFormation and Cloud Control calls use (#1686). Signed when
67
+ * credentials resolve and the target is real AWS; scope-only against an
68
+ * endpoint override, since an emulator does not verify signatures and requiring
69
+ * credentials to read a local lane would be a tax with nothing behind it.
70
+ * `signEndpointOverride: true` opts back in for an override that *is* real AWS.
71
+ */
72
+
73
+ import { mkdir, writeFile } from "node:fs/promises";
74
+ import { dirname, resolve } from "node:path";
75
+ import {
76
+ AwsReadError,
77
+ requestHeaders,
78
+ serviceUrl,
79
+ type AwsCredentialSource,
80
+ type AwsReadHttp,
81
+ } from "../api/read-client";
82
+ import {
83
+ agentCoreDecimal,
84
+ renderAgentCoreTrace,
85
+ AgentCoreTraceError,
86
+ type AgentCoreFields,
87
+ type AgentCoreSessionEvent,
88
+ type AgentCoreTimeOrigin,
89
+ type AgentCoreTraceIssue,
90
+ type AgentCoreTraceIssueKind,
91
+ type AgentCoreTraceValue,
92
+ } from "./trace-render";
93
+
94
+ const SERVICE = "bedrock-agentcore";
95
+ const DEFAULT_MAX_EVENTS = 1_000;
96
+ /** `ListEvents` bounds `maxResults` at 100. */
97
+ const PAGE_SIZE = 100;
98
+
99
+ /* ── Sources ──────────────────────────────────────────────────────────────── */
100
+
101
+ /**
102
+ * Where a history is read from.
103
+ *
104
+ * Only `memory` is implemented, and the reason the other two are named rather
105
+ * than omitted is that "which surface carries this" is the question #1685 asked
106
+ * first. A caller who reaches for `spans` gets the finding, not a 404.
107
+ */
108
+ export type AgentCoreTraceSource =
109
+ /** AgentCore Memory `ListEvents`. The one fetchable session history. */
110
+ | "memory"
111
+ /** Gateway vended `tools/call` logs. CloudWatch-only, and not JSON. */
112
+ | "gateway-logs"
113
+ /** `aws/spans` policy-decision span attributes. CloudWatch/X-Ray-only. */
114
+ | "spans";
115
+
116
+ /** Marker type for the documented Memory caveat. See the module header. */
117
+ export type AgentCoreMemorySource = Extract<AgentCoreTraceSource, "memory">;
118
+
119
+ /**
120
+ * A source that exists in AWS but is not reachable the way this module reads.
121
+ *
122
+ * The message names the finding and the surface, so a caller who hits it knows
123
+ * whether they are blocked on chant or on AWS. Both current cases are the
124
+ * latter: the record is real, it is in CloudWatch Logs, and getting at it is a
125
+ * Logs Insights integration rather than an AgentCore API call.
126
+ */
127
+ export class AgentCoreTraceUnavailableError extends Error {
128
+ constructor(
129
+ readonly source: AgentCoreTraceSource,
130
+ message: string,
131
+ ) {
132
+ super(message);
133
+ this.name = "AgentCoreTraceUnavailableError";
134
+ }
135
+ }
136
+
137
+ const UNAVAILABLE: Record<Exclude<AgentCoreTraceSource, "memory">, string> = {
138
+ "gateway-logs":
139
+ "AgentCore Gateway does not publish an API that returns per-invocation tool calls. " +
140
+ "The record exists, vended to CloudWatch Logs at " +
141
+ "/aws/vendedlogs/bedrock-agentcore/gateway/APPLICATION_LOGS/{gateway_id}, but its requestBody " +
142
+ "field is a Java map toString ({id=1, jsonrpc=2.0, method=tools/call, params={…}}) rather than " +
143
+ "JSON, so reading it is a CloudWatch Logs Insights integration with its own parser, not a fetch. " +
144
+ 'Use source: "memory" (AgentCore Memory ListEvents), or file the Logs Insights source as its own issue. ' +
145
+ "See https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-gateway-metrics.html",
146
+ spans:
147
+ "AgentCore policy decisions are not returned by any published API. AuthorizeAction and " +
148
+ "PartiallyAuthorizeActions appear only as a CloudWatch metric dimension; the per-call allow/deny " +
149
+ "lives in aws.agentcore.policy.authorization_decision on spans in the aws/spans log group, " +
150
+ "readable through CloudWatch Logs Insights or xray:StartTraceRetrieval and requiring Transaction " +
151
+ 'Search. Use source: "memory" for the tool-call history a replay actually needs — a replay ' +
152
+ "recomputes the verdict from the policy set, so the recorded decision is a comparison input, not " +
153
+ "a trace input. See https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-policy-metrics.html",
154
+ };
155
+
156
+ /* ── The Memory wire shapes ───────────────────────────────────────────────── */
157
+
158
+ /** One `payload[]` member, as `ListEvents` returns it. */
159
+ export interface MemoryPayloadMember {
160
+ readonly conversational?: { readonly role?: string; readonly content?: unknown };
161
+ readonly blob?: unknown;
162
+ }
163
+
164
+ /** One event, as `ListEvents` returns it. Fields this module does not read are ignored. */
165
+ export interface MemoryEvent {
166
+ readonly eventId?: string;
167
+ readonly sessionId?: string;
168
+ readonly actorId?: string;
169
+ readonly memoryId?: string;
170
+ /** restJson1 timestamp: epoch seconds (possibly fractional) or an ISO-8601 string. */
171
+ readonly eventTimestamp?: number | string;
172
+ readonly metadata?: unknown;
173
+ readonly payload?: readonly MemoryPayloadMember[];
174
+ readonly branch?: { readonly name?: string; readonly rootEventId?: string };
175
+ }
176
+
177
+ function isRecord(value: unknown): value is Record<string, unknown> {
178
+ return typeof value === "object" && value !== null && !Array.isArray(value);
179
+ }
180
+
181
+ /* ── Time ─────────────────────────────────────────────────────────────────── */
182
+
183
+ /**
184
+ * Epoch milliseconds from whatever the wire carried.
185
+ *
186
+ * restJson1 renders a `timestamp` shape as epoch **seconds**, possibly
187
+ * fractional, but the docs' examples and several SDK paths show ISO-8601, so
188
+ * both are accepted. A bare number is disambiguated by magnitude: anything at
189
+ * or beyond 1e12 is already milliseconds (1e12 ms is 2001; 1e12 s is the year
190
+ * 33658), so the heuristic has no realistic collision. Anything else throws
191
+ * rather than being coerced — a guessed timestamp puts every temporal window in
192
+ * the replay in the wrong place.
193
+ */
194
+ export function toEpochMs(value: unknown, what: string): number {
195
+ if (typeof value === "number" && Number.isFinite(value)) {
196
+ return value >= 1e12 ? Math.round(value) : Math.round(value * 1000);
197
+ }
198
+ if (typeof value === "string" && value.length > 0) {
199
+ const parsed = Date.parse(value);
200
+ if (Number.isFinite(parsed)) return parsed;
201
+ }
202
+ throw new AgentCoreTraceError(
203
+ `agentcore trace: ${what} is not a usable timestamp (${JSON.stringify(value)}) — the history is malformed, and a guessed timepoint moves every temporal window in the replay`,
204
+ );
205
+ }
206
+
207
+ /* ── JSON → trace values ──────────────────────────────────────────────────── */
208
+
209
+ /** What to do with a payload key the trace grammar cannot spell. */
210
+ export type NonIdentifierFieldPolicy = "rename" | "fail";
211
+
212
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
213
+
214
+ /** `tool-name` → `tool_name`, `2fa` → `f_2fa`. Deterministic, so goldens hold. */
215
+ export function identifierFor(key: string): string {
216
+ const cleaned = key.replace(/[^A-Za-z0-9_]/g, "_");
217
+ return IDENT.test(cleaned) ? cleaned : `f_${cleaned}`;
218
+ }
219
+
220
+ /** A key the coercion had to rewrite, reported so a rename is never silent. */
221
+ export interface FieldRename {
222
+ readonly path: string;
223
+ readonly from: string;
224
+ readonly to: string;
225
+ }
226
+
227
+ /**
228
+ * A non-integer JS number as a decimal literal the Cedar surface accepts.
229
+ *
230
+ * `String(1e-7)` is `"1e-7"`, which is not a decimal literal, so a legitimate
231
+ * small payload number would otherwise abort the whole fetch. `toFixed(20)` is
232
+ * the widest non-exponential rendering JS offers, and the trailing zeros it
233
+ * pads with come back off.
234
+ *
235
+ * A value too small for even that rounds to `0.0`, and a trace that says a
236
+ * policy compared against zero when the agent saw something else is the whole
237
+ * failure class this module exists to prevent — so it throws instead, naming
238
+ * the field.
239
+ */
240
+ export function decimalText(value: number, context?: string): string {
241
+ const plain = String(value);
242
+ if (!/e/i.test(plain)) return plain;
243
+
244
+ const fixed = value.toFixed(20).replace(/(\.\d*?)0+$/, "$1");
245
+ const text = fixed.endsWith(".") ? `${fixed}0` : fixed;
246
+ if (Number(text) !== value) {
247
+ const where = context ? ` at ${context}` : "";
248
+ throw new AgentCoreTraceError(
249
+ `agentcore trace: ${plain}${where} has no exact decimal spelling in the trace grammar — it would render as ${text}, and a policy comparing against that would be comparing against a number the agent never saw. Carry it as a string.`,
250
+ );
251
+ }
252
+ return text;
253
+ }
254
+
255
+ /**
256
+ * Coerce arbitrary JSON into trace values.
257
+ *
258
+ * A non-integer number becomes a Cedar decimal rather than being rounded,
259
+ * because the scale is the thing a policy compares against. `null` and
260
+ * `undefined` are dropped from *records*, because Cedar has no null and an
261
+ * invented sentinel would be matched by a predicate that meant something else.
262
+ * And a key the grammar cannot spell is renamed — every rename is returned, so
263
+ * a caller can see it, and `onNonIdentifierField: "fail"` refuses instead.
264
+ *
265
+ * Two things throw rather than being smoothed over, and both are the same
266
+ * failure this module refuses everywhere else — data that changes meaning
267
+ * without saying so. A `null` *inside an array* would shift every later index,
268
+ * so a predicate on `args[1]` would start matching `args[0]`'s value. And two
269
+ * payload keys that rewrite to the same identifier (`tool-name` and `tool.name`
270
+ * both become `tool_name`) would silently drop one of them.
271
+ */
272
+ export function coerceFields(
273
+ value: unknown,
274
+ options: { onNonIdentifierField?: NonIdentifierFieldPolicy } = {},
275
+ path = "",
276
+ renames: FieldRename[] = [],
277
+ ): { fields: AgentCoreFields; renames: FieldRename[] } {
278
+ const fields: Record<string, AgentCoreTraceValue> = {};
279
+ if (!isRecord(value)) return { fields, renames };
280
+
281
+ const sources = new Map<string, string>();
282
+ for (const [key, raw] of Object.entries(value)) {
283
+ const here = path ? `${path}.${key}` : key;
284
+ const coerced = coerceValue(raw, options, here, renames);
285
+ if (coerced === undefined) continue;
286
+ let name = key;
287
+ if (!IDENT.test(key)) {
288
+ if (options.onNonIdentifierField === "fail") {
289
+ throw new AgentCoreTraceError(
290
+ `agentcore trace: the payload field "${here}" is not an identifier and the dogwood trace grammar cannot spell it — rename it at the source, or accept the rewrite by leaving onNonIdentifierField unset`,
291
+ );
292
+ }
293
+ name = identifierFor(key);
294
+ renames.push({ path: here, from: key, to: name });
295
+ }
296
+ const taken = sources.get(name);
297
+ if (taken !== undefined) {
298
+ throw new AgentCoreTraceError(
299
+ `agentcore trace: the payload fields "${taken}" and "${key}" both spell "${name}" in the trace grammar, so one would overwrite the other and a predicate on it would read the wrong value — rename one at the source`,
300
+ );
301
+ }
302
+ sources.set(name, key);
303
+ fields[name] = coerced;
304
+ }
305
+ return { fields, renames };
306
+ }
307
+
308
+ function coerceValue(
309
+ value: unknown,
310
+ options: { onNonIdentifierField?: NonIdentifierFieldPolicy },
311
+ path: string,
312
+ renames: FieldRename[],
313
+ ): AgentCoreTraceValue | undefined {
314
+ if (value === null || value === undefined) return undefined;
315
+ if (typeof value === "string" || typeof value === "boolean") return value;
316
+ if (typeof value === "number") {
317
+ if (!Number.isFinite(value)) return undefined;
318
+ return Number.isInteger(value) ? value : agentCoreDecimal(decimalText(value, path), path);
319
+ }
320
+ if (Array.isArray(value)) {
321
+ return value.map((item, index) => {
322
+ const here = `${path}[${index}]`;
323
+ const coerced = coerceValue(item, options, here, renames);
324
+ if (coerced === undefined) {
325
+ throw new AgentCoreTraceError(
326
+ `agentcore trace: ${here} has no value the trace grammar can carry (${JSON.stringify(item) ?? "undefined"}), and dropping it would shift every later element — so a predicate written against a position in this array would read its neighbour's value. Fix it at the source.`,
327
+ );
328
+ }
329
+ return coerced;
330
+ });
331
+ }
332
+ if (isRecord(value)) return coerceFields(value, options, path, renames).fields;
333
+ return undefined;
334
+ }
335
+
336
+ /**
337
+ * One payload group from a `blob` member.
338
+ *
339
+ * A blob is arbitrary JSON, so `input` is not necessarily a record —
340
+ * `input: "the prompt text"` is a perfectly ordinary thing for an agent to
341
+ * write. A non-record is wrapped under `value` the same way a non-record
342
+ * `conversational.content` is, rather than being dropped for not being the
343
+ * shape this module hoped for.
344
+ */
345
+ function coerceGroup(
346
+ value: unknown,
347
+ options: { onNonIdentifierField?: NonIdentifierFieldPolicy },
348
+ path: string,
349
+ renames: FieldRename[],
350
+ ): AgentCoreFields {
351
+ if (value === undefined || value === null) return {};
352
+ return coerceFields(isRecord(value) ? value : { value }, options, path, renames).fields;
353
+ }
354
+
355
+ /* ── Memory → normalized events ───────────────────────────────────────────── */
356
+
357
+ /**
358
+ * How a `conversational` role becomes an action and an event kind.
359
+ *
360
+ * The default is a convention, not a contract AWS publishes — `Conversational`
361
+ * carries a role and content and nothing else, so the tool name, if there is
362
+ * one, is inside the content the agent wrote. Override it per project.
363
+ */
364
+ export interface RoleMapping {
365
+ readonly action: string;
366
+ readonly kind: string;
367
+ }
368
+
369
+ /** Default role mapping. `TOOL` is a decision-kind `request`: it is the call being authorized. */
370
+ export const DEFAULT_ROLE_MAPPING: Readonly<Record<string, RoleMapping>> = {
371
+ USER: { action: "Prompt", kind: "request" },
372
+ ASSISTANT: { action: "Respond", kind: "response" },
373
+ TOOL: { action: "InvokeTool", kind: "request" },
374
+ OTHER: { action: "Event", kind: "request" },
375
+ };
376
+
377
+ /**
378
+ * The `blob` convention this module reads.
379
+ *
380
+ * A `blob` payload is arbitrary JSON, so there is nothing to parse until
381
+ * someone agrees on a shape. This is that shape, and it is the one worth
382
+ * writing from `CreateEvent`: `action` and `kind` decide the trace's grammar
383
+ * slots, `input`/`output`/`error` become the payload groups that land in both
384
+ * bags, and everything else becomes `attributes`. Every key is optional; an
385
+ * absent `action` falls back to the role mapping's `OTHER`.
386
+ */
387
+ export const BLOB_KEYS = ["action", "kind", "input", "output", "error"] as const;
388
+
389
+ /** Options shared by the normalizer and the activity. */
390
+ export interface AgentCoreNormalizeOptions {
391
+ /** Overrides merged over {@link DEFAULT_ROLE_MAPPING}. */
392
+ readonly roles?: Readonly<Record<string, RoleMapping>>;
393
+ /** Cedar resource id for every event. Default: the event's `memoryId`. */
394
+ readonly target?: string;
395
+ /** Default `"rename"`. */
396
+ readonly onNonIdentifierField?: NonIdentifierFieldPolicy;
397
+ }
398
+
399
+ /** A normalized history plus whatever the coercion had to rewrite. */
400
+ export interface NormalizedHistory {
401
+ readonly events: readonly AgentCoreSessionEvent[];
402
+ readonly renames: readonly FieldRename[];
403
+ }
404
+
405
+ /**
406
+ * `ListEvents` output → the renderer's normalized events. Pure: no transport.
407
+ *
408
+ * One event with N payload members becomes N normalized events sharing the
409
+ * event's timestamp, with `#0`, `#1`, … appended to the id — the trace's
410
+ * `requestId` has to identify a decision point, and a two-member event is two
411
+ * decision points. A member that is neither `conversational` nor `blob` is a
412
+ * shape this module does not know, and it throws rather than being skipped:
413
+ * a silently dropped tool call is a temporal predicate that silently misses.
414
+ */
415
+ export function normalizeMemoryEvents(
416
+ events: readonly MemoryEvent[],
417
+ options: AgentCoreNormalizeOptions = {},
418
+ ): NormalizedHistory {
419
+ const roles = { ...DEFAULT_ROLE_MAPPING, ...(options.roles ?? {}) };
420
+ const renames: FieldRename[] = [];
421
+ const out: AgentCoreSessionEvent[] = [];
422
+
423
+ events.forEach((event, index) => {
424
+ const where = `event ${index}${event.eventId ? ` (${event.eventId})` : ""}`;
425
+ const timeMs = toEpochMs(event.eventTimestamp, `${where}'s eventTimestamp`);
426
+ const members = event.payload ?? [];
427
+ if (members.length === 0) {
428
+ throw new AgentCoreTraceError(
429
+ `agentcore trace: ${where} has an empty payload — either includePayloads was false or the history is truncated, and a payload-less trace replays green while proving nothing`,
430
+ index,
431
+ );
432
+ }
433
+
434
+ members.forEach((member, slot) => {
435
+ const suffix = members.length > 1 ? `#${slot}` : "";
436
+ const base = {
437
+ timeMs,
438
+ sessionId: event.sessionId ?? "",
439
+ eventId: `${event.eventId ?? ""}${suffix}`,
440
+ actor: event.actorId ?? "",
441
+ target: options.target ?? event.memoryId ?? "",
442
+ };
443
+
444
+ if (member.conversational) {
445
+ const role = member.conversational.role ?? "OTHER";
446
+ const mapping = roles[role] ?? roles.OTHER!;
447
+ const content = coerceGroup(
448
+ isRecord(member.conversational.content)
449
+ ? member.conversational.content
450
+ : { text: member.conversational.content },
451
+ options,
452
+ `${where}.payload[${slot}].conversational.content`,
453
+ renames,
454
+ );
455
+ out.push({
456
+ ...base,
457
+ action: mapping.action,
458
+ kind: mapping.kind,
459
+ ...(Object.keys(content).length > 0 ? { input: content } : {}),
460
+ attributes: { role },
461
+ });
462
+ return;
463
+ }
464
+
465
+ if (member.blob !== undefined) {
466
+ const blob = isRecord(member.blob) ? member.blob : { value: member.blob };
467
+ const prefix = `${where}.payload[${slot}].blob`;
468
+ const rest: Record<string, unknown> = {};
469
+ for (const [key, value] of Object.entries(blob)) {
470
+ if (!(BLOB_KEYS as readonly string[]).includes(key)) rest[key] = value;
471
+ }
472
+ const groups = {
473
+ input: coerceGroup(blob.input, options, `${prefix}.input`, renames),
474
+ output: coerceGroup(blob.output, options, `${prefix}.output`, renames),
475
+ error: coerceGroup(blob.error, options, `${prefix}.error`, renames),
476
+ attributes: coerceFields(rest, options, prefix, renames).fields,
477
+ };
478
+ out.push({
479
+ ...base,
480
+ action: typeof blob.action === "string" && blob.action.length > 0 ? blob.action : roles.OTHER!.action,
481
+ kind: typeof blob.kind === "string" && blob.kind.length > 0 ? blob.kind : roles.OTHER!.kind,
482
+ ...(Object.keys(groups.input).length > 0 ? { input: groups.input } : {}),
483
+ ...(Object.keys(groups.output).length > 0 ? { output: groups.output } : {}),
484
+ ...(Object.keys(groups.error).length > 0 ? { error: groups.error } : {}),
485
+ ...(Object.keys(groups.attributes).length > 0 ? { attributes: groups.attributes } : {}),
486
+ });
487
+ return;
488
+ }
489
+
490
+ throw new AgentCoreTraceError(
491
+ `agentcore trace: ${where}.payload[${slot}] is neither conversational nor blob — PayloadType is a union of exactly those two, so this is a shape change or a corrupt record, and skipping it would drop a decision point from the replay`,
492
+ index,
493
+ );
494
+ });
495
+ });
496
+
497
+ return { events: out, renames };
498
+ }
499
+
500
+ /* ── Transport ────────────────────────────────────────────────────────────── */
501
+
502
+ const defaultHttp: AwsReadHttp = async (url, init, signal) => {
503
+ const res = await fetch(url, { method: "POST", headers: init.headers, body: init.body, signal });
504
+ return { status: res.status, text: await res.text() };
505
+ };
506
+
507
+ /** Where to read from, how to reach it, and what to sign with. */
508
+ export interface AgentCoreReadOptions {
509
+ /** Endpoint override. Omit for the real regional host. */
510
+ readonly endpoint?: string;
511
+ /** Default `us-east-1`. */
512
+ readonly region?: string;
513
+ /**
514
+ * What to sign with: literal credentials, or a resolver that decides.
515
+ * Omitted, the environment answers; with nothing there, the request goes out
516
+ * carrying the credential scope and no signature.
517
+ */
518
+ readonly credentials?: AwsCredentialSource;
519
+ /** Environment the credential fallback reads. Defaults to `process.env`; injectable for tests. */
520
+ readonly env?: Record<string, string | undefined>;
521
+ /** Sign even against an endpoint override — for an override that is real AWS. */
522
+ readonly signEndpointOverride?: boolean;
523
+ /** Signing clock. Injected by tests so a signature is reproducible. */
524
+ readonly now?: Date;
525
+ }
526
+
527
+ async function agentCorePost(
528
+ path: string,
529
+ body: Record<string, unknown>,
530
+ options: AgentCoreReadOptions,
531
+ http: AwsReadHttp,
532
+ signal?: AbortSignal,
533
+ ): Promise<Record<string, unknown>> {
534
+ const url = `${serviceUrl(SERVICE, options.endpoint, options.region)}${path.replace(/^\//, "")}`;
535
+ const wire = JSON.stringify(body);
536
+ const res = await http(
537
+ url,
538
+ {
539
+ headers: requestHeaders(SERVICE, url, wire, { "content-type": "application/json" }, options),
540
+ body: wire,
541
+ },
542
+ signal,
543
+ );
544
+
545
+ let parsed: unknown;
546
+ try {
547
+ parsed = JSON.parse(res.text);
548
+ } catch {
549
+ throw new AwsReadError(`unparseable ${SERVICE} response for ${path}`, res.status);
550
+ }
551
+ const payload = isRecord(parsed) ? parsed : {};
552
+ const type = typeof payload.__type === "string" ? payload.__type.split("#").pop() : undefined;
553
+ if (type || res.status >= 400) {
554
+ const message = typeof payload.message === "string" ? payload.message : `${path} failed with HTTP ${res.status}`;
555
+ throw new AwsReadError(message, res.status, type);
556
+ }
557
+ return payload;
558
+ }
559
+
560
+ /** Path-segment encoding for the Memory REST routes. */
561
+ function segment(value: string, what: string): string {
562
+ if (typeof value !== "string" || value.length === 0) {
563
+ throw new AgentCoreTraceError(`agentcore trace: ${what} is required to read a session history`);
564
+ }
565
+ return encodeURIComponent(value);
566
+ }
567
+
568
+ /** What {@link listMemoryEvents} takes. */
569
+ export interface ListMemoryEventsArgs extends AgentCoreReadOptions {
570
+ readonly memoryId: string;
571
+ readonly actorId: string;
572
+ readonly sessionId: string;
573
+ /** Stop after this many events. Default 1000 — a runaway session is not a trace. */
574
+ readonly maxEvents?: number;
575
+ }
576
+
577
+ /**
578
+ * `ListEvents` for one session, paginated to exhaustion or to `maxEvents`.
579
+ *
580
+ * `includePayloads` is always true: without it the response carries event ids
581
+ * and timestamps and nothing to match a predicate against, which is a trace
582
+ * that replays green and proves nothing.
583
+ */
584
+ export async function listMemoryEvents(
585
+ args: ListMemoryEventsArgs,
586
+ signal?: AbortSignal,
587
+ http: AwsReadHttp = defaultHttp,
588
+ ): Promise<MemoryEvent[]> {
589
+ const path =
590
+ `memories/${segment(args.memoryId, "memoryId")}` +
591
+ `/actor/${segment(args.actorId, "actorId")}` +
592
+ `/sessions/${segment(args.sessionId, "sessionId")}`;
593
+ const cap = args.maxEvents ?? DEFAULT_MAX_EVENTS;
594
+ // `ListEvents` bounds maxResults at 1..100, so a cap of 0 would go out as a
595
+ // request the service rejects rather than as an empty result.
596
+ if (!Number.isInteger(cap) || cap < 1) {
597
+ throw new AgentCoreTraceError(`agentcore trace: maxEvents must be a positive integer — got ${String(cap)}`);
598
+ }
599
+
600
+ const out: MemoryEvent[] = [];
601
+ let nextToken: string | undefined;
602
+ do {
603
+ const body = await agentCorePost(
604
+ path,
605
+ {
606
+ includePayloads: true,
607
+ maxResults: Math.min(PAGE_SIZE, cap - out.length),
608
+ ...(nextToken ? { nextToken } : {}),
609
+ },
610
+ args,
611
+ http,
612
+ signal,
613
+ );
614
+ const events = Array.isArray(body.events) ? (body.events as MemoryEvent[]) : [];
615
+ out.push(...events);
616
+ nextToken = typeof body.nextToken === "string" && body.nextToken.length > 0 ? body.nextToken : undefined;
617
+ if (events.length === 0) break;
618
+ } while (nextToken && out.length < cap);
619
+
620
+ return out.slice(0, cap);
621
+ }
622
+
623
+ /* ── The activity ─────────────────────────────────────────────────────────── */
624
+
625
+ /** What {@link awsAgentCoreFetchTrace} takes. */
626
+ export interface AwsAgentCoreFetchTraceArgs extends AgentCoreReadOptions, AgentCoreNormalizeOptions {
627
+ /** Default `"memory"`. The other two are named and refused — see the module header. */
628
+ readonly source?: AgentCoreTraceSource;
629
+ /** AgentCore Memory resource id. */
630
+ readonly memoryId: string;
631
+ /** Actor whose sessions are being read. */
632
+ readonly actorId: string;
633
+ /** The session to replay. */
634
+ readonly sessionId: string;
635
+ /**
636
+ * Window start, inclusive. Read by {@link toEpochMs}, the same function the
637
+ * event timestamps go through — so epoch seconds, epoch milliseconds, or
638
+ * anything `Date.parse` reads, and a bound is never on a different scale
639
+ * from the events it is being compared against.
640
+ */
641
+ readonly since?: number | string;
642
+ /** Window end, inclusive. Read the same way as {@link since}. */
643
+ readonly until?: number | string;
644
+ /** Cap on events fetched before the window filter. Default 1000. */
645
+ readonly maxEvents?: number;
646
+
647
+ /** Cedar namespace for actions and entity types. Default `"AgentCore"`. */
648
+ readonly namespace?: string;
649
+ /** Entity type for the actor. Default `"Actor"`. */
650
+ readonly principalType?: string;
651
+ /** Entity type for the target. Default `"Runtime"`. */
652
+ readonly resourceType?: string;
653
+ /** Default `"epoch-seconds"`. */
654
+ readonly origin?: AgentCoreTimeOrigin;
655
+ /** Kinds that build a Cedar request. Default `["request"]`. */
656
+ readonly decisionKinds?: readonly string[];
657
+ /** Weakenings to tolerate. Empty by default. */
658
+ readonly allow?: readonly AgentCoreTraceIssueKind[];
659
+
660
+ /** Write the rendered trace here — what `PolicyReplayOp`'s `tracePath` reads. */
661
+ readonly outPath?: string;
662
+ /** Throw when the window produced no events. Default `true`. */
663
+ readonly requireEvents?: boolean;
664
+ }
665
+
666
+ /** What {@link awsAgentCoreFetchTrace} returns. */
667
+ export interface AwsAgentCoreFetchTraceResult {
668
+ readonly source: AgentCoreTraceSource;
669
+ readonly sessionId: string;
670
+ /** The rendered trace, one event per line, newline-terminated. */
671
+ readonly text: string;
672
+ /** Lines in the trace — decision points plus history-only events. */
673
+ readonly lineCount: number;
674
+ /** Events `ListEvents` returned before the window filter. */
675
+ readonly fetched: number;
676
+ /** Absolute path written, when `outPath` was given. */
677
+ readonly outPath?: string;
678
+ /** Payload keys the trace grammar could not spell, rewritten and reported. */
679
+ readonly renames: readonly FieldRename[];
680
+ /** Weakenings the caller allowed. Empty unless `allow` named something. */
681
+ readonly issues: readonly AgentCoreTraceIssue[];
682
+ }
683
+
684
+ function windowBound(value: number | string | undefined, what: string): number | undefined {
685
+ if (value === undefined) return undefined;
686
+ return toEpochMs(value, what);
687
+ }
688
+
689
+ /**
690
+ * Fetch an AgentCore session history and render it as dogwood trace text.
691
+ *
692
+ * The window is applied client-side because `ListEvents`' `filter` takes a
693
+ * branch and event metadata and has no time predicate, so a server-side window
694
+ * is not on offer. `maxEvents` bounds the fetch; `since`/`until` bound what is
695
+ * normalized and rendered.
696
+ */
697
+ export async function awsAgentCoreFetchTrace(
698
+ args: AwsAgentCoreFetchTraceArgs,
699
+ signal?: AbortSignal,
700
+ http: AwsReadHttp = defaultHttp,
701
+ ): Promise<AwsAgentCoreFetchTraceResult> {
702
+ const source = args.source ?? "memory";
703
+ if (source !== "memory") {
704
+ const detail = UNAVAILABLE[source];
705
+ if (!detail) {
706
+ throw new AgentCoreTraceUnavailableError(source, `agentcore trace: unknown source "${String(source)}"`);
707
+ }
708
+ throw new AgentCoreTraceUnavailableError(source, `agentcore trace: ${detail}`);
709
+ }
710
+
711
+ const raw = await listMemoryEvents(args, signal, http);
712
+ const since = windowBound(args.since, "the window's `since`");
713
+ const until = windowBound(args.until, "the window's `until`");
714
+
715
+ // The window is applied to the *fetched* events, before normalizing, so an
716
+ // event the caller deliberately excluded cannot fail the run. Normalizing
717
+ // first would let one payload-less record anywhere in the fetched range abort
718
+ // a narrowed window with no way to recover short of shrinking maxEvents.
719
+ const inWindow = raw.filter((event, index) => {
720
+ const at = toEpochMs(event.eventTimestamp, `event ${index}${event.eventId ? ` (${event.eventId})` : ""}'s eventTimestamp`);
721
+ return (since === undefined || at >= since) && (until === undefined || at <= until);
722
+ });
723
+
724
+ const { events: windowed, renames } = normalizeMemoryEvents(inWindow, args);
725
+
726
+ if (windowed.length === 0 && (args.requireEvents ?? true)) {
727
+ const window = since !== undefined || until !== undefined ? " in the requested window" : "";
728
+ throw new AgentCoreTraceError(
729
+ `agentcore trace: session ${args.sessionId} has no events${window} (${raw.length} fetched). ` +
730
+ "AgentCore Memory holds what the agent wrote through CreateEvent, not a service-side audit, so an " +
731
+ "agent that never writes leaves nothing to replay. Pass requireEvents: false to accept an empty trace.",
732
+ );
733
+ }
734
+
735
+ const { text, lines, issues } = renderAgentCoreTrace(windowed, args);
736
+
737
+ let written: string | undefined;
738
+ if (args.outPath) {
739
+ written = resolve(args.outPath);
740
+ await mkdir(dirname(written), { recursive: true });
741
+ await writeFile(written, text, "utf8");
742
+ }
743
+
744
+ return {
745
+ source,
746
+ sessionId: args.sessionId,
747
+ text,
748
+ lineCount: lines.length,
749
+ fetched: raw.length,
750
+ ...(written ? { outPath: written } : {}),
751
+ renames,
752
+ issues,
753
+ };
754
+ }