retrace-sdk 0.11.7 → 0.13.1

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/README.md CHANGED
@@ -88,11 +88,56 @@ When you fork at any span in the dashboard, the SDK re-executes the entire funct
88
88
  ## Error Handling
89
89
 
90
90
  ```typescript
91
- import { RetraceError, RetraceAuthError, RetraceCreditsExhaustedError, RetraceRateLimitError } from "retrace-sdk";
91
+ import { RetraceError, RetraceAuthError, RetraceCreditsExhaustedError, RetraceRateLimitError, RetraceEnforcementError } from "retrace-sdk";
92
92
  ```
93
93
 
94
94
  Typed errors for auth failures, credit exhaustion, and rate limiting.
95
95
 
96
+ ## Enforcement (Circuit Breakers)
97
+
98
+ Hard ceilings that stop a runaway agent before the next call. Local limits are enforced offline (zero network); `serverEnforcement: true` also consults centrally-managed server policies.
99
+
100
+ ```typescript
101
+ import { configure, RetraceEnforcementError } from "retrace-sdk";
102
+
103
+ configure({
104
+ apiKey: "rt_live_...",
105
+ maxStepsPerRun: 50,
106
+ maxUsdPerRun: 2.0,
107
+ serverEnforcement: true, // optional: also consult server policies
108
+ });
109
+
110
+ try {
111
+ await runAgent("...");
112
+ } catch (e) {
113
+ if (e instanceof RetraceEnforcementError) console.log(e.verdict, e.reason);
114
+ }
115
+ ```
116
+
117
+ Precedence: explicit arg > env var (`RETRACE_MAX_STEPS_PER_RUN`, `RETRACE_MAX_TOKENS_PER_RUN`, `RETRACE_MAX_USD_PER_RUN`, `RETRACE_SERVER_ENFORCEMENT`) > unset. If the server check is unreachable, local limits still apply.
118
+
119
+ ## Multi-Agent Context
120
+
121
+ Tag spans with an agent id/role so the dashboard can draw the agent topology and run inter-agent detectors:
122
+
123
+ ```typescript
124
+ import { withAgent } from "retrace-sdk";
125
+
126
+ await withAgent({ id: "planner", role: "planner" }, async () => {
127
+ await callPlanner(prompt);
128
+ });
129
+ ```
130
+
131
+ ## Golden Cassettes (CI Regression Gates)
132
+
133
+ Record a run as a golden cassette and gate on it offline in CI with `retrace ci replay`:
134
+
135
+ ```typescript
136
+ import { writeGoldenCassette } from "retrace-sdk";
137
+
138
+ writeGoldenCassette("golden.json", { recorder });
139
+ ```
140
+
96
141
  ## Sampling
97
142
 
98
143
  ```typescript
@@ -101,7 +146,12 @@ configure({ apiKey: "rt_live_...", sampleRate: 0.1 }); // Record 10% of traces
101
146
 
102
147
  ## Changelog
103
148
 
104
- ### 0.3.0
149
+ ### 0.13.0
150
+
151
+ - **Multi-agent context** — `withAgent({ id, role })` tags spans for topology + inter-agent detectors
152
+ - **Golden cassettes** — `writeGoldenCassette(path, { recorder })` records a run as a CI regression fixture
153
+ - **Pre-call enforcement gate** — local step/token/USD-per-run ceilings enforced offline; `RetraceEnforcementError` thrown instead of silently skipping the call
154
+
105
155
 
106
156
  - **Sessions** — `sessionId` option in `TraceRecorder` and `trace()` to group multi-turn conversations
107
157
  - **Multi-Agent** — `setAgentId()` on `SpanBuilder` for cross-agent tracing
@@ -0,0 +1,16 @@
1
+ export interface AgentContext {
2
+ agentId: string;
3
+ agentRole?: string;
4
+ parentAgentId?: string;
5
+ }
6
+ /** The agent context spans are currently tagged with, or undefined outside any `withAgent` scope. */
7
+ export declare function currentAgent(): AgentContext | undefined;
8
+ /**
9
+ * Scope every span created inside `fn` to `agent.id`. `parent` defaults to the enclosing
10
+ * `withAgent` scope's id (the handoff edge).
11
+ */
12
+ export declare function withAgent<T>(agent: {
13
+ id: string;
14
+ role?: string;
15
+ parent?: string;
16
+ }, fn: () => T): T;
package/dist/agents.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Multi-agent topology helpers — tag spans with the agent that produced them.
3
+ *
4
+ * Framework-agnostic: run a sub-agent's work inside `withAgent(...)` and every span recorded there
5
+ * (including auto-instrumented LLM calls) inherits the agent id / role / parent, so the trace page
6
+ * can draw the agent topology. Uses AsyncLocalStorage (the same isolation primitive `trace()` uses)
7
+ * so concurrent agents in one process never clobber each other's context. Nested `withAgent` calls
8
+ * set `parentAgentId` automatically — nesting expresses the delegation graph.
9
+ *
10
+ * Framework mapping (explicit — deep auto-detection would couple the SDK to each framework):
11
+ * - LangGraph: use the node name as the agent id.
12
+ * - CrewAI: use the agent's role as id + role.
13
+ */
14
+ import { AsyncLocalStorage } from "async_hooks";
15
+ const agentStore = new AsyncLocalStorage();
16
+ /** The agent context spans are currently tagged with, or undefined outside any `withAgent` scope. */
17
+ export function currentAgent() {
18
+ return agentStore.getStore();
19
+ }
20
+ /**
21
+ * Scope every span created inside `fn` to `agent.id`. `parent` defaults to the enclosing
22
+ * `withAgent` scope's id (the handoff edge).
23
+ */
24
+ export function withAgent(agent, fn) {
25
+ const prev = agentStore.getStore();
26
+ const ctx = {
27
+ agentId: agent.id,
28
+ agentRole: agent.role,
29
+ parentAgentId: agent.parent ?? prev?.agentId,
30
+ };
31
+ return agentStore.run(ctx, fn);
32
+ }
@@ -0,0 +1,15 @@
1
+ import type { TraceRecorder } from "./recorder.js";
2
+ export interface CassetteTolerance {
3
+ default?: "exact" | "ignore" | "semantic" | "judge";
4
+ steps?: Record<string, "exact" | "ignore" | "semantic" | "judge">;
5
+ semantic_threshold?: number;
6
+ }
7
+ /**
8
+ * Write the active (or given) recorder's run to `path` as a golden cassette. Returns the cassette.
9
+ * Throws if no recorder is active — call it inside/after your traced function, before the process
10
+ * exits. `tolerance` is written through for CI divergence budgets.
11
+ */
12
+ export declare function writeGoldenCassette(path: string, opts?: {
13
+ recorder?: TraceRecorder;
14
+ tolerance?: CassetteTolerance;
15
+ }): unknown;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Golden cassette writer for CI regression replay (Phase 4b).
3
+ *
4
+ * After recording a run, write its cassette to a file you commit to your repo; `retrace ci replay`
5
+ * diffs a fresh run against it in CI. The JSON shape is the cross-language contract in
6
+ * `@retrace/shared` (CassetteSchema) — keep this writer in sync with it.
7
+ */
8
+ import { writeFileSync } from "node:fs";
9
+ import { getActiveRecorder } from "./init.js";
10
+ /**
11
+ * Write the active (or given) recorder's run to `path` as a golden cassette. Returns the cassette.
12
+ * Throws if no recorder is active — call it inside/after your traced function, before the process
13
+ * exits. `tolerance` is written through for CI divergence budgets.
14
+ */
15
+ export function writeGoldenCassette(path, opts) {
16
+ const recorder = opts?.recorder ?? getActiveRecorder();
17
+ if (!recorder)
18
+ throw new Error("writeGoldenCassette: no active recorder — call inside a traced run");
19
+ const cassette = recorder.toCassette();
20
+ if (opts?.tolerance)
21
+ cassette.tolerance = opts.tolerance;
22
+ writeFileSync(path, JSON.stringify(cassette, null, 2));
23
+ return cassette;
24
+ }
package/dist/config.d.ts CHANGED
@@ -20,6 +20,18 @@ export interface Config {
20
20
  * halt | error. Branch on `signal.code`; use `signal.retryable`/`signal.fatal` to decide
21
21
  * behavior. Defaults to a throttled console warning so signals are never silently dropped. */
22
22
  onError?: (signal: import("./errors.js").RetraceServerSignal) => void;
23
+ /** Enforcement (circuit breakers). LOCAL ceilings are enforced offline with zero network — they
24
+ * always apply; `undefined` = unset. Precedence: explicit configure() arg > env var > unset.
25
+ * `serverEnforcement=true` additionally consults the server /check endpoint for centrally-managed
26
+ * policies (best-effort from the SDK; authoritative at ingest). */
27
+ maxStepsPerRun?: number;
28
+ maxTokensPerRun?: number;
29
+ maxUsdPerRun?: number;
30
+ serverEnforcement: boolean;
31
+ /** On a server `hold` verdict, poll up to this many seconds for a human decision before applying
32
+ * the fail-closed timeout verdict. 0 = trip immediately. (The auto path is synchronous, so the
33
+ * poll runs in the background and a denial/timeout trips the NEXT span.) */
34
+ enforcementHoldWaitSeconds: number;
23
35
  }
24
36
  export declare function configure(opts: Partial<Config>): Config;
25
37
  export declare function requireApiKey(): string;
package/dist/config.js CHANGED
@@ -8,6 +8,11 @@ const config = {
8
8
  sampleSeed: process.env.RETRACE_SAMPLE_SEED || undefined,
9
9
  transport: (["auto", "ws", "http"].includes(process.env.RETRACE_TRANSPORT || "") ? process.env.RETRACE_TRANSPORT : "auto"),
10
10
  strictReplay: ["true", "1"].includes((process.env.RETRACE_STRICT_REPLAY || "").toLowerCase()),
11
+ maxStepsPerRun: process.env.RETRACE_MAX_STEPS_PER_RUN ? parseInt(process.env.RETRACE_MAX_STEPS_PER_RUN, 10) : undefined,
12
+ maxTokensPerRun: process.env.RETRACE_MAX_TOKENS_PER_RUN ? parseInt(process.env.RETRACE_MAX_TOKENS_PER_RUN, 10) : undefined,
13
+ maxUsdPerRun: process.env.RETRACE_MAX_USD_PER_RUN ? parseFloat(process.env.RETRACE_MAX_USD_PER_RUN) : undefined,
14
+ serverEnforcement: ["true", "1", "yes"].includes((process.env.RETRACE_SERVER_ENFORCEMENT || "").toLowerCase()),
15
+ enforcementHoldWaitSeconds: process.env.RETRACE_ENFORCEMENT_HOLD_WAIT_SECONDS ? parseInt(process.env.RETRACE_ENFORCEMENT_HOLD_WAIT_SECONDS, 10) : 0,
11
16
  };
12
17
  config.wsUrl = config.baseUrl.replace("https://", "wss://").replace("http://", "ws://");
13
18
  export function configure(opts) {
@@ -0,0 +1,33 @@
1
+ import type { Config } from "./config.js";
2
+ /** Stable short hash of tool arguments so raw args never leave the process for a loop/debounce check. */
3
+ export declare function hashToolArgs(args: unknown): string;
4
+ export declare class EnforcementGate {
5
+ private config;
6
+ private runId;
7
+ private steps;
8
+ private tokens;
9
+ private usd;
10
+ /** A server block resolved asynchronously; trips on the next span (best-effort, one-span lag). */
11
+ private pendingBlock;
12
+ constructor(config: Config, runId: string);
13
+ /** No-op fast path unless a local ceiling is set or server enforcement is enabled. */
14
+ get active(): boolean;
15
+ /**
16
+ * Accumulate this span's usage and enforce. Throws RetraceEnforcementError on a tripped local
17
+ * ceiling (synchronous) or a previously-resolved server block. Safe to call on every span.
18
+ */
19
+ recordAndCheck(opts: {
20
+ tokens?: number;
21
+ usd?: number;
22
+ traceId?: string;
23
+ toolName?: string;
24
+ toolArgsHash?: string;
25
+ }): void;
26
+ /** Best-effort server consult. Records a pending block on a block/hold verdict; never throws. */
27
+ private serverCheck;
28
+ /** A held action: poll for a human decision up to the configured wait, then trip on denial/timeout
29
+ * (sets pendingBlock, which stops the next span). With wait=0 the hold trips immediately. */
30
+ private handleHold;
31
+ /** Read a hold's status; returns "pending" on any transport error so the loop keeps waiting. */
32
+ private pollHold;
33
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Client-side enforcement gate (circuit breakers) — the TypeScript twin of the Python
3
+ * `EnforcementGate` (same names, same semantics).
4
+ *
5
+ * LOCAL ceilings (max steps / tokens / USD per run) are enforced entirely offline — zero network, so
6
+ * the breaker trips even when the API is unreachable, and it throws SYNCHRONOUSLY before the next
7
+ * call. When `serverEnforcement` is enabled the gate ALSO consults the server `/enforcement/check`
8
+ * endpoint; because auto-instrumentation routes spans synchronously, that call is best-effort and
9
+ * fire-and-forget — a server block trips the NEXT span (one-span lag) rather than the current one,
10
+ * and a transport failure is logged, never swallowed (server policy is authoritative at ingest).
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { RetraceEnforcementError } from "./errors.js";
14
+ /** Stable short hash of tool arguments so raw args never leave the process for a loop/debounce check. */
15
+ export function hashToolArgs(args) {
16
+ let serialized;
17
+ try {
18
+ serialized = typeof args === "string" ? args : JSON.stringify(args, Object.keys(args ?? {}).sort());
19
+ }
20
+ catch {
21
+ serialized = String(args);
22
+ }
23
+ return createHash("sha256").update(serialized).digest("hex").slice(0, 32);
24
+ }
25
+ export class EnforcementGate {
26
+ config;
27
+ runId;
28
+ steps = 0;
29
+ tokens = 0;
30
+ usd = 0;
31
+ /** A server block resolved asynchronously; trips on the next span (best-effort, one-span lag). */
32
+ pendingBlock = null;
33
+ constructor(config, runId) {
34
+ this.config = config;
35
+ this.runId = runId;
36
+ }
37
+ /** No-op fast path unless a local ceiling is set or server enforcement is enabled. */
38
+ get active() {
39
+ const c = this.config;
40
+ return c.maxStepsPerRun !== undefined || c.maxTokensPerRun !== undefined || c.maxUsdPerRun !== undefined || c.serverEnforcement;
41
+ }
42
+ /**
43
+ * Accumulate this span's usage and enforce. Throws RetraceEnforcementError on a tripped local
44
+ * ceiling (synchronous) or a previously-resolved server block. Safe to call on every span.
45
+ */
46
+ recordAndCheck(opts) {
47
+ if (!this.active)
48
+ return;
49
+ // A server block resolved after the previous span trips here, before this call proceeds.
50
+ if (this.pendingBlock) {
51
+ const err = this.pendingBlock;
52
+ this.pendingBlock = null;
53
+ throw err;
54
+ }
55
+ this.steps += 1;
56
+ this.tokens += Math.max(0, opts.tokens ?? 0);
57
+ this.usd += Math.max(0, opts.usd ?? 0);
58
+ const c = this.config;
59
+ if (c.maxStepsPerRun !== undefined && this.steps > c.maxStepsPerRun) {
60
+ throw new RetraceEnforcementError(`Local step ceiling reached: ${this.steps} > ${c.maxStepsPerRun} per run.`);
61
+ }
62
+ if (c.maxTokensPerRun !== undefined && this.tokens > c.maxTokensPerRun) {
63
+ throw new RetraceEnforcementError(`Local token ceiling reached: ${this.tokens} > ${c.maxTokensPerRun} per run.`);
64
+ }
65
+ if (c.maxUsdPerRun !== undefined && this.usd > c.maxUsdPerRun) {
66
+ throw new RetraceEnforcementError(`Local USD ceiling reached: $${this.usd.toFixed(4)} > $${c.maxUsdPerRun} per run.`);
67
+ }
68
+ if (c.serverEnforcement)
69
+ void this.serverCheck(opts);
70
+ }
71
+ /** Best-effort server consult. Records a pending block on a block/hold verdict; never throws. */
72
+ async serverCheck(opts) {
73
+ const c = this.config;
74
+ if (!c.apiKey)
75
+ return;
76
+ const body = { run_id: this.runId, proposed_tokens: opts.tokens ?? 0, proposed_usd: opts.usd ?? 0 };
77
+ if (c.projectId)
78
+ body.project_id = c.projectId;
79
+ if (opts.traceId)
80
+ body.trace_id = opts.traceId;
81
+ if (opts.toolName)
82
+ body.tool_name = opts.toolName;
83
+ if (opts.toolArgsHash)
84
+ body.tool_args_hash = opts.toolArgsHash;
85
+ try {
86
+ const resp = await fetch(`${c.baseUrl}/api/v1/enforcement/check`, {
87
+ method: "POST",
88
+ headers: { "x-retrace-key": c.apiKey, "Content-Type": "application/json" },
89
+ body: JSON.stringify(body),
90
+ signal: AbortSignal.timeout(5000),
91
+ });
92
+ if (resp.status !== 200 && resp.status !== 202) {
93
+ console.warn(`[retrace] server enforcement check returned ${resp.status} (local limits still apply)`);
94
+ return;
95
+ }
96
+ const data = (await resp.json());
97
+ if (data.verdict === "block") {
98
+ this.pendingBlock = new RetraceEnforcementError(data.reason || "Blocked by server policy.", "block", data.policy_id);
99
+ }
100
+ else if (data.verdict === "hold") {
101
+ await this.handleHold(data);
102
+ }
103
+ }
104
+ catch (e) {
105
+ console.warn(`[retrace] server enforcement check unreachable (local limits still apply): ${e.message}`);
106
+ }
107
+ }
108
+ /** A held action: poll for a human decision up to the configured wait, then trip on denial/timeout
109
+ * (sets pendingBlock, which stops the next span). With wait=0 the hold trips immediately. */
110
+ async handleHold(data) {
111
+ const c = this.config;
112
+ const err = new RetraceEnforcementError(data.reason || "Held by server policy.", "hold", data.policy_id);
113
+ if (!data.hold_id || c.enforcementHoldWaitSeconds <= 0) {
114
+ this.pendingBlock = err;
115
+ return;
116
+ }
117
+ const deadline = Date.now() + c.enforcementHoldWaitSeconds * 1000;
118
+ const interval = Math.min(2000, Math.max(500, (c.enforcementHoldWaitSeconds * 1000) / 10));
119
+ while (Date.now() < deadline) {
120
+ const status = await this.pollHold(data.hold_id);
121
+ if (status === "approved")
122
+ return; // released — no pending block
123
+ if (status === "denied" || status === "expired") {
124
+ this.pendingBlock = err;
125
+ return;
126
+ }
127
+ await new Promise((r) => setTimeout(r, interval));
128
+ }
129
+ this.pendingBlock = err; // timed out → fail-closed
130
+ }
131
+ /** Read a hold's status; returns "pending" on any transport error so the loop keeps waiting. */
132
+ async pollHold(holdId) {
133
+ try {
134
+ const resp = await fetch(`${this.config.baseUrl}/api/v1/enforcement/holds/${holdId}`, {
135
+ headers: { "x-retrace-key": this.config.apiKey },
136
+ signal: AbortSignal.timeout(5000),
137
+ });
138
+ if (resp.status === 200)
139
+ return (await resp.json()).status || "pending";
140
+ }
141
+ catch {
142
+ // transient — keep waiting
143
+ }
144
+ return "pending";
145
+ }
146
+ }
package/dist/errors.d.ts CHANGED
@@ -14,6 +14,21 @@ export declare class RetraceRateLimitError extends RetraceError {
14
14
  retryAfter: number;
15
15
  constructor(retryAfter: number);
16
16
  }
17
+ /**
18
+ * An enforcement circuit breaker blocked (or held) the action. Thrown by the pre-call gate when a
19
+ * LOCAL limit (max steps / tokens / USD per run) is exceeded, or when an opt-in server-side policy
20
+ * returns a block/hold verdict. NEVER thrown silently — the agent loop stops here rather than
21
+ * continuing past a runaway condition. Mirrors the Python `RetraceEnforcementError`.
22
+ */
23
+ export declare class RetraceEnforcementError extends RetraceError {
24
+ /** "block" or "hold". */
25
+ verdict: string;
26
+ /** The ceiling that tripped, or the server reason. */
27
+ reason: string;
28
+ /** The server policy that tripped, when the verdict came from the server (else undefined). */
29
+ policyId?: string;
30
+ constructor(reason: string, verdict?: string, policyId?: string);
31
+ }
17
32
  /**
18
33
  * Structured server-originated signal handed to `onError`. Actionable WITHOUT string-matching:
19
34
  * branch on `code`, decide retry from `retryable`, decide whether recording is still alive from
package/dist/errors.js CHANGED
@@ -14,6 +14,27 @@ export class RetraceRateLimitError extends RetraceError {
14
14
  retryAfter;
15
15
  constructor(retryAfter) { super(`Rate limited. Retry after ${retryAfter}s`); this.name = "RetraceRateLimitError"; this.retryAfter = retryAfter; }
16
16
  }
17
+ /**
18
+ * An enforcement circuit breaker blocked (or held) the action. Thrown by the pre-call gate when a
19
+ * LOCAL limit (max steps / tokens / USD per run) is exceeded, or when an opt-in server-side policy
20
+ * returns a block/hold verdict. NEVER thrown silently — the agent loop stops here rather than
21
+ * continuing past a runaway condition. Mirrors the Python `RetraceEnforcementError`.
22
+ */
23
+ export class RetraceEnforcementError extends RetraceError {
24
+ /** "block" or "hold". */
25
+ verdict;
26
+ /** The ceiling that tripped, or the server reason. */
27
+ reason;
28
+ /** The server policy that tripped, when the verdict came from the server (else undefined). */
29
+ policyId;
30
+ constructor(reason, verdict = "block", policyId) {
31
+ super(`Enforcement ${verdict}: ${reason}`);
32
+ this.name = "RetraceEnforcementError";
33
+ this.verdict = verdict;
34
+ this.reason = reason;
35
+ this.policyId = policyId;
36
+ }
37
+ }
17
38
  /**
18
39
  * Map a raw server frame to a structured signal. Single source of truth for category + retryable +
19
40
  * fatal, shared by the WS dispatch. Kept here (not inline in the dispatch) so TS and Python classify
package/dist/index.d.ts CHANGED
@@ -9,13 +9,17 @@ export { SpanType, TraceStatus } from "./trace.js";
9
9
  export { installGeminiInterceptor, uninstallGeminiInterceptor } from "./interceptors/gemini.js";
10
10
  export { installOpenAIInterceptor, uninstallOpenAIInterceptor } from "./interceptors/openai.js";
11
11
  export { installAnthropicInterceptor, uninstallAnthropicInterceptor } from "./interceptors/anthropic.js";
12
- export { RetraceError, RetraceAuthError, RetraceCreditsExhaustedError, RetraceConnectionError, RetraceRateLimitError } from "./errors.js";
12
+ export { RetraceError, RetraceAuthError, RetraceCreditsExhaustedError, RetraceConnectionError, RetraceRateLimitError, RetraceEnforcementError } from "./errors.js";
13
13
  export { registerResumable, handleResume } from "./resume.js";
14
14
  export type { ResumeCommand } from "./resume.js";
15
15
  export { isReplaying, consumeCassetteEntry, handleReplay } from "./replay.js";
16
16
  export type { CassetteEntry, ReplayCommand } from "./replay.js";
17
17
  export { setTraceContext, clearTraceContext, getTraceparent, injectTraceparent, parseTraceparent, withTraceContext } from "./traceparent.js";
18
18
  export { markGolden } from "./golden.js";
19
+ export { writeGoldenCassette } from "./cassette.js";
20
+ export type { CassetteTolerance } from "./cassette.js";
21
+ export { withAgent, currentAgent } from "./agents.js";
22
+ export type { AgentContext } from "./agents.js";
19
23
  export { setTruncationLimits } from "./utils.js";
20
24
  export { createLangChainHandler } from "./adapters/langchain.js";
21
25
  export { retraceOnStepFinish, recordVercelStep } from "./adapters/vercel-ai.js";
package/dist/index.js CHANGED
@@ -7,11 +7,13 @@ export { SpanType, TraceStatus } from "./trace.js";
7
7
  export { installGeminiInterceptor, uninstallGeminiInterceptor } from "./interceptors/gemini.js";
8
8
  export { installOpenAIInterceptor, uninstallOpenAIInterceptor } from "./interceptors/openai.js";
9
9
  export { installAnthropicInterceptor, uninstallAnthropicInterceptor } from "./interceptors/anthropic.js";
10
- export { RetraceError, RetraceAuthError, RetraceCreditsExhaustedError, RetraceConnectionError, RetraceRateLimitError } from "./errors.js";
10
+ export { RetraceError, RetraceAuthError, RetraceCreditsExhaustedError, RetraceConnectionError, RetraceRateLimitError, RetraceEnforcementError } from "./errors.js";
11
11
  export { registerResumable, handleResume } from "./resume.js";
12
12
  export { isReplaying, consumeCassetteEntry, handleReplay } from "./replay.js";
13
13
  export { setTraceContext, clearTraceContext, getTraceparent, injectTraceparent, parseTraceparent, withTraceContext } from "./traceparent.js";
14
14
  export { markGolden } from "./golden.js";
15
+ export { writeGoldenCassette } from "./cassette.js";
16
+ export { withAgent, currentAgent } from "./agents.js";
15
17
  export { setTruncationLimits } from "./utils.js";
16
18
  // Framework adapters (5B) — drop-in instrumentation for LangChain/LangGraph + Vercel AI SDK.
17
19
  export { createLangChainHandler } from "./adapters/langchain.js";
@@ -26,8 +26,30 @@ export declare class TraceRecorder {
26
26
  private forkPointReached;
27
27
  private spanCounter;
28
28
  output: unknown;
29
+ private enforcement;
29
30
  constructor(opts?: RecordOptions);
30
31
  get traceId(): string;
32
+ /**
33
+ * Export this recorder's completed spans as a golden cassette (Phase 4b). The snapshot is the
34
+ * CI regression baseline — `retrace ci replay` diffs a fresh run against it. Capture AFTER the
35
+ * traced function returns so all spans are present.
36
+ */
37
+ toCassette(): {
38
+ version: 1;
39
+ name: string;
40
+ trace_id: string;
41
+ recorded_at: string;
42
+ status: string;
43
+ steps: {
44
+ index: number;
45
+ span_type: string;
46
+ name: string;
47
+ model?: string | null;
48
+ input?: unknown;
49
+ output?: unknown;
50
+ error?: string | null;
51
+ }[];
52
+ };
31
53
  start(name?: string, input?: unknown, opts?: {
32
54
  managed?: boolean;
33
55
  }): this;
package/dist/recorder.js CHANGED
@@ -7,6 +7,8 @@ import { installOpenAIInterceptor } from "./interceptors/openai.js";
7
7
  import { installAnthropicInterceptor } from "./interceptors/anthropic.js";
8
8
  import { dispatchInterceptedSpan, runWithActiveRecorder, setActiveRecorderFallback, currentFallbackSink } from "./interceptors/_dispatch.js";
9
9
  import { withTraceContext, enterTraceContext, exitTraceContext } from "./traceparent.js";
10
+ import { EnforcementGate, hashToolArgs } from "./enforcement.js";
11
+ import { currentAgent } from "./agents.js";
10
12
  // Shared transport — stays open across multiple traces for resume/replay listening
11
13
  let sharedTransport = null;
12
14
  // Count of imperative (non-HOF) recorders currently between start() and end(). The bare imperative
@@ -59,6 +61,7 @@ export class TraceRecorder {
59
61
  forkPointReached = false;
60
62
  spanCounter = 0;
61
63
  output = undefined;
64
+ enforcement;
62
65
  constructor(opts) {
63
66
  requireApiKey();
64
67
  this.builder = new TraceBuilder();
@@ -69,6 +72,8 @@ export class TraceRecorder {
69
72
  // otherwise (normal recording, or a fork command without an index) emit everything.
70
73
  this.forkPointReached = !opts?.forkPointSpanId || opts?.forkPointIndex === undefined;
71
74
  const cfg = getConfig();
75
+ // Per-run circuit breaker (no-op unless a local ceiling or server enforcement is configured).
76
+ this.enforcement = new EnforcementGate(cfg, this.builder.id);
72
77
  if (cfg.projectId)
73
78
  this.builder.setProjectId(cfg.projectId);
74
79
  if (opts?.metadata)
@@ -80,6 +85,31 @@ export class TraceRecorder {
80
85
  }
81
86
  }
82
87
  get traceId() { return this.builder.id; }
88
+ /**
89
+ * Export this recorder's completed spans as a golden cassette (Phase 4b). The snapshot is the
90
+ * CI regression baseline — `retrace ci replay` diffs a fresh run against it. Capture AFTER the
91
+ * traced function returns so all spans are present.
92
+ */
93
+ toCassette() {
94
+ const data = this.builder.toDict();
95
+ const steps = (data.spans ?? []).map((s, index) => ({
96
+ index,
97
+ span_type: s.span_type,
98
+ name: s.name,
99
+ model: s.model ?? null,
100
+ input: s.input,
101
+ output: s.output,
102
+ error: s.error ?? null,
103
+ }));
104
+ return {
105
+ version: 1,
106
+ name: data.name ?? "trace",
107
+ trace_id: data.id,
108
+ recorded_at: new Date().toISOString(),
109
+ status: data.status,
110
+ steps,
111
+ };
112
+ }
83
113
  start(name, input, opts) {
84
114
  // Never-silent guard for the imperative path: if another imperative trace is already active when
85
115
  // this one starts, overlapping imperative record() use can cross-attribute spans/traceparent.
@@ -177,6 +207,15 @@ export class TraceRecorder {
177
207
  }
178
208
  }
179
209
  span.trace_id = this.builder.id;
210
+ const actx = currentAgent();
211
+ if (actx) {
212
+ if (!span.agent_id)
213
+ span.agent_id = actx.agentId;
214
+ if (!span.agent_role)
215
+ span.agent_role = actx.agentRole;
216
+ if (!span.parent_agent_id)
217
+ span.parent_agent_id = actx.parentAgentId;
218
+ }
180
219
  this.builder.addSpan(span);
181
220
  this.transport.send("span_started", span);
182
221
  if (span.ended_at) {
@@ -189,6 +228,19 @@ export class TraceRecorder {
189
228
  error: span.error,
190
229
  });
191
230
  }
231
+ // Circuit breaker: record this span's usage and enforce BEFORE the next call. Throws
232
+ // RetraceEnforcementError on a tripped local ceiling (recording above is already done, so the
233
+ // trace keeps the offending span; only the run is stopped).
234
+ if (this.enforcement.active) {
235
+ const isTool = span.span_type === SpanType.TOOL_CALL;
236
+ this.enforcement.recordAndCheck({
237
+ tokens: (span.input_tokens ?? 0) + (span.output_tokens ?? 0),
238
+ usd: span.cost ?? 0,
239
+ traceId: this.builder.id,
240
+ toolName: isTool ? span.name : undefined,
241
+ toolArgsHash: isTool ? hashToolArgs(span.input) : undefined,
242
+ });
243
+ }
192
244
  }
193
245
  startSpan(name, spanType = SpanType.LLM_CALL, input, model, parentId) {
194
246
  const sb = new SpanBuilder(name, spanType).start();
@@ -199,6 +251,9 @@ export class TraceRecorder {
199
251
  sb.setModel(model);
200
252
  if (parentId)
201
253
  sb.setParentId(parentId);
254
+ const actx = currentAgent();
255
+ if (actx)
256
+ sb.setAgent({ id: actx.agentId, role: actx.agentRole, parentId: actx.parentAgentId });
202
257
  this.transport.send("span_started", sb.toData());
203
258
  return sb;
204
259
  }
package/dist/trace.d.ts CHANGED
@@ -27,6 +27,8 @@ export interface SpanData {
27
27
  duration_ms?: number;
28
28
  metadata?: Record<string, unknown>;
29
29
  agent_id?: string;
30
+ parent_agent_id?: string;
31
+ agent_role?: string;
30
32
  started_at: string;
31
33
  ended_at?: string;
32
34
  error?: string;
@@ -60,6 +62,12 @@ export declare class SpanBuilder {
60
62
  setTraceId(id: string): this;
61
63
  setMetadata(m: Record<string, unknown>): this;
62
64
  setAgentId(id: string): this;
65
+ /** Multi-agent topology: who delegated to this agent + this agent's role/persona. */
66
+ setAgent(opts: {
67
+ id?: string;
68
+ parentId?: string;
69
+ role?: string;
70
+ }): this;
63
71
  start(): this;
64
72
  end(output?: unknown, error?: string): SpanData;
65
73
  get id(): string;
package/dist/trace.js CHANGED
@@ -28,6 +28,16 @@ export class SpanBuilder {
28
28
  setTraceId(id) { this.data.trace_id = id; return this; }
29
29
  setMetadata(m) { this.data.metadata = m; return this; }
30
30
  setAgentId(id) { this.data.agent_id = id; return this; }
31
+ /** Multi-agent topology: who delegated to this agent + this agent's role/persona. */
32
+ setAgent(opts) {
33
+ if (opts.id !== undefined)
34
+ this.data.agent_id = opts.id;
35
+ if (opts.parentId !== undefined)
36
+ this.data.parent_agent_id = opts.parentId;
37
+ if (opts.role !== undefined)
38
+ this.data.agent_role = opts.role;
39
+ return this;
40
+ }
31
41
  start() {
32
42
  this._startTime = utcNow();
33
43
  this.data.started_at = this._startTime.toISOString();
package/dist/transport.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import WebSocket from "ws";
2
2
  import { getConfig } from "./config.js";
3
3
  import { classifyServerSignal } from "./errors.js";
4
+ // Client identifier sent on every request so the backend can attribute SDK usage/version.
5
+ // Keep in sync with package.json on release.
6
+ const CLIENT_ID = "typescript-sdk/0.13.1";
4
7
  export class WSTransport {
5
8
  ws = null;
6
9
  connected = false;
@@ -265,7 +268,7 @@ export class WSTransport {
265
268
  try {
266
269
  await fetch(`${cfg.baseUrl}/api/v1/traces`, {
267
270
  method: "POST",
268
- headers: { "x-retrace-key": cfg.apiKey, "Content-Type": "application/json" },
271
+ headers: { "x-retrace-key": cfg.apiKey, "Content-Type": "application/json", "x-retrace-client": CLIENT_ID },
269
272
  body,
270
273
  keepalive: true,
271
274
  signal: ctrl.signal,
@@ -310,7 +313,7 @@ export class HTTPTransport {
310
313
  try {
311
314
  const res = await fetch(url, {
312
315
  method: "POST",
313
- headers: { "x-retrace-key": cfg.apiKey, "Content-Type": "application/json" },
316
+ headers: { "x-retrace-key": cfg.apiKey, "Content-Type": "application/json", "x-retrace-client": CLIENT_ID },
314
317
  body: payload,
315
318
  });
316
319
  if (res.ok)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retrace-sdk",
3
- "version": "0.11.7",
3
+ "version": "0.13.1",
4
4
  "description": "The execution replay engine for AI agents. Record, replay, fork, and share agent executions.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -19,7 +19,11 @@
19
19
  "import": "./dist/adapters/vercel-ai.js"
20
20
  }
21
21
  },
22
- "files": ["dist", "README.md", "LICENSE"],
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
23
27
  "license": "MIT",
24
28
  "author": "Yash Bogam",
25
29
  "repository": {
@@ -28,7 +32,17 @@
28
32
  "directory": "packages/sdk-typescript"
29
33
  },
30
34
  "homepage": "https://retrace.yashbogam.me/docs/sdk-typescript",
31
- "keywords": ["ai", "agent", "tracing", "observability", "replay", "llm", "openai", "anthropic", "gemini"],
35
+ "keywords": [
36
+ "ai",
37
+ "agent",
38
+ "tracing",
39
+ "observability",
40
+ "replay",
41
+ "llm",
42
+ "openai",
43
+ "anthropic",
44
+ "gemini"
45
+ ],
32
46
  "engines": {
33
47
  "node": ">=20"
34
48
  },