retrace-sdk 0.11.7 → 0.13.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/README.md +52 -2
- package/dist/agents.d.ts +16 -0
- package/dist/agents.js +32 -0
- package/dist/cassette.d.ts +15 -0
- package/dist/cassette.js +24 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +5 -0
- package/dist/enforcement.d.ts +33 -0
- package/dist/enforcement.js +146 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +21 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/recorder.d.ts +22 -0
- package/dist/recorder.js +55 -0
- package/dist/trace.d.ts +8 -0
- package/dist/trace.js +10 -0
- package/package.json +17 -3
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.
|
|
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
|
package/dist/agents.d.ts
ADDED
|
@@ -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;
|
package/dist/cassette.js
ADDED
|
@@ -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";
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retrace-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
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": [
|
|
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": [
|
|
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
|
},
|