Skip to content

Commit 8ef55a2

Browse files
authored
feat(trace-analyst): add Ax RLM trace analyst (#25)
1 parent 00cac67 commit 8ef55a2

13 files changed

Lines changed: 2379 additions & 4 deletions

File tree

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,3 +908,6 @@ export type {
908908

909909
// Pareto extensions (paretoFrontier + dominates already exported above)
910910
export { scalarScore, crowdingDistance, paretoFrontierWithCrowding } from './pareto'
911+
912+
// Ax RLM trace analyst.
913+
export * from './trace-analyst'

src/trace-analyst/analyst.test.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import { analyzeTraces } from './analyst'
4+
import type { TraceAnalysisStore } from './store'
5+
import type { DatasetOverview } from './types'
6+
7+
const axMock = vi.hoisted(() => ({
8+
agentCalls: [] as Array<{ signature: string; options: Record<string, unknown> }>,
9+
forwardCalls: [] as Array<{ ai: unknown; values: unknown }>,
10+
}))
11+
12+
vi.mock('@ax-llm/ax', () => {
13+
const field = {
14+
optional() {
15+
return field
16+
},
17+
array() {
18+
return field
19+
},
20+
}
21+
const f = Object.assign(
22+
() => ({
23+
input() {
24+
return this
25+
},
26+
output() {
27+
return this
28+
},
29+
build() {
30+
return {}
31+
},
32+
}),
33+
{
34+
string: () => field,
35+
number: () => field,
36+
boolean: () => field,
37+
json: () => field,
38+
},
39+
)
40+
const fn = (name: string) => {
41+
const tool: Record<string, unknown> = { name }
42+
const builder = {
43+
description(value: string) {
44+
tool.description = value
45+
return builder
46+
},
47+
namespace(value: string) {
48+
tool.namespace = value
49+
return builder
50+
},
51+
arg() {
52+
return builder
53+
},
54+
returns() {
55+
return builder
56+
},
57+
handler(value: unknown) {
58+
tool.handler = value
59+
return builder
60+
},
61+
example() {
62+
return builder
63+
},
64+
build() {
65+
return tool
66+
},
67+
}
68+
return builder
69+
}
70+
return {
71+
f,
72+
fn,
73+
AxJSRuntime: class {
74+
readonly options: unknown
75+
constructor(options?: unknown) {
76+
this.options = options
77+
}
78+
},
79+
agent: (signature: string, options: Record<string, unknown>) => {
80+
axMock.agentCalls.push({ signature, options })
81+
return {
82+
async forward(ai: unknown, values: unknown) {
83+
axMock.forwardCalls.push({ ai, values })
84+
const onTurn = options.actorTurnCallback
85+
if (typeof onTurn === 'function') {
86+
await onTurn({
87+
turn: 1,
88+
actionLogEntryCount: 1,
89+
guidanceLogEntryCount: 0,
90+
actorResult: {},
91+
code: 'const overview = await traces.getDatasetOverview({})',
92+
result: {},
93+
output: 'overview loaded',
94+
isError: false,
95+
thought: 'inspect first',
96+
})
97+
}
98+
return {
99+
answer: 'publish_finding hits MaxTurnsExceeded in t000000000001/s004',
100+
findings: ['t000000000001/s004: publish_finding hit MaxTurnsExceeded'],
101+
}
102+
},
103+
getUsage() {
104+
return { actor: [{ tokens: { totalTokens: 10 } }], responder: [] }
105+
},
106+
getChatLog() {
107+
return { actor: [{ role: 'assistant' }], responder: [] }
108+
},
109+
resetUsage() {},
110+
}
111+
},
112+
}
113+
})
114+
115+
describe('analyzeTraces', () => {
116+
it('constructs an Ax RLM analyst with bounded trace tools and returns run telemetry', async () => {
117+
const overview: DatasetOverview = {
118+
total_traces: 1,
119+
raw_jsonl_bytes: 100,
120+
services: ['bench'],
121+
agents: ['driver'],
122+
models: ['model-a'],
123+
tool_names: ['publish_finding'],
124+
sample_trace_ids: ['t000000000001'],
125+
errors: { trace_count: 1, span_count: 1 },
126+
time_range: null,
127+
}
128+
const store: TraceAnalysisStore = {
129+
async getOverview() {
130+
return overview
131+
},
132+
async queryTraces() {
133+
return {
134+
traces: [],
135+
total: 0,
136+
has_more: false,
137+
}
138+
},
139+
async countTraces() {
140+
return 1
141+
},
142+
async viewTrace() {
143+
return { trace_id: 't000000000001', spans: [] }
144+
},
145+
async viewSpans() {
146+
return {
147+
trace_id: 't000000000001',
148+
spans: [],
149+
missing_span_ids: [],
150+
truncated_attribute_count: 0,
151+
}
152+
},
153+
async searchTrace() {
154+
return {
155+
trace_id: 't000000000001',
156+
hits: [],
157+
total_matches: 0,
158+
has_more: false,
159+
}
160+
},
161+
async searchSpan() {
162+
return {
163+
trace_id: 't000000000001',
164+
span_id: 's004',
165+
hits: [],
166+
total_matches: 0,
167+
has_more: false,
168+
}
169+
},
170+
}
171+
172+
const ai = { provider: 'test' }
173+
const result = await analyzeTraces(
174+
{ question: 'Which harness failure mode blocks success?' },
175+
{ source: store, ai, model: 'rlm-test', maxDepth: 1 },
176+
)
177+
178+
expect(axMock.agentCalls).toHaveLength(1)
179+
expect(axMock.agentCalls[0].signature).toBe('question:string -> answer:string, findings:string[]')
180+
expect(axMock.agentCalls[0].options.mode).toBe('advanced')
181+
expect(axMock.agentCalls[0].options.functions).toMatchObject({
182+
local: expect.arrayContaining([
183+
expect.objectContaining({ namespace: 'traces', name: 'getDatasetOverview' }),
184+
expect.objectContaining({ namespace: 'traces', name: 'searchSpan' }),
185+
]),
186+
})
187+
expect(axMock.forwardCalls).toEqual([
188+
{
189+
ai,
190+
values: { question: 'Which harness failure mode blocks success?' },
191+
},
192+
])
193+
expect(result.answer).toContain('MaxTurnsExceeded')
194+
expect(result.findings).toEqual(['t000000000001/s004: publish_finding hit MaxTurnsExceeded'])
195+
expect(result.turnCount).toBe(1)
196+
expect(result.turns[0]).toMatchObject({
197+
turn: 1,
198+
isError: false,
199+
output: 'overview loaded',
200+
})
201+
expect(result.actorPromptVersion).toMatch(/^trace-analyst-actor-v1-/)
202+
})
203+
})

src/trace-analyst/analyst.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import {
2+
AxJSRuntime,
3+
agent,
4+
type AxActorTurn,
5+
type AxAIService,
6+
type AxFunction,
7+
} from '@ax-llm/ax'
8+
9+
import { TraceFileMissingError } from './store-otlp'
10+
import {
11+
TRACE_ANALYST_ACTOR_DESCRIPTION,
12+
TRACE_ANALYST_ACTOR_DESCRIPTION_VERSION,
13+
TRACE_ANALYST_SUBAGENT_DESCRIPTION,
14+
} from './prompts'
15+
import { buildTraceAnalystTools } from './tools'
16+
import type { TraceAnalysisStore } from './store'
17+
import { OtlpFileTraceStore } from './store-otlp'
18+
19+
export interface AnalyzeTracesInput {
20+
/** The user-facing question. Domain framing belongs here, not in the
21+
* actor description. */
22+
question: string
23+
}
24+
25+
export interface AnalyzeTracesResult {
26+
/** The responder's prose answer. */
27+
answer: string
28+
/** Bulleted findings extracted from the responder's structured output. */
29+
findings: string[]
30+
/** Per-actor-turn snapshots captured via `actorTurnCallback`. */
31+
turns: AnalyzeTracesTurnSnapshot[]
32+
/** Total turns the actor took. */
33+
turnCount: number
34+
/** Token usage by role. */
35+
usage: { actor: unknown[]; responder: unknown[] }
36+
/** Full system + assistant + tool message log by role. */
37+
chatLog: { actor: unknown[]; responder: unknown[] }
38+
/** Prompt version that produced this run. */
39+
actorPromptVersion: string
40+
}
41+
42+
export interface AnalyzeTracesTurnSnapshot {
43+
turn: number
44+
isError: boolean
45+
/** The JS code the actor produced for this turn. */
46+
code: string
47+
/** The formatted action-log entry the actor sees on the next turn. */
48+
output: string
49+
/** Provider thought (when `actorOptions.showThoughts` is true and the
50+
* provider returns it). */
51+
thought?: string
52+
}
53+
54+
export interface AnalyzeTracesOptions {
55+
/** Trace data source. Pass either an OTLP-JSONL path or a custom store. */
56+
source: string | TraceAnalysisStore
57+
/** Caller-provided AxAIService. */
58+
ai: AxAIService
59+
/** Model id forwarded to actor + responder. */
60+
model?: string
61+
/** Recursion depth. 0 = no sub-agent dispatch. Default 1. */
62+
maxDepth?: number
63+
/** Maximum actor turns. Default 12. */
64+
maxTurns?: number
65+
/** Maximum parallel sub-agent calls in batched llmQuery. Default 2. */
66+
maxParallelSubagents?: number
67+
/** Override the actor description. */
68+
actorDescription?: string
69+
/** Override the subagent description. */
70+
subagentDescription?: string
71+
/** Per-turn observability hook. */
72+
onTurn?: (turn: AnalyzeTracesTurnSnapshot) => void | Promise<void>
73+
/** Override max runtime characters per turn. Default 6000. */
74+
maxRuntimeChars?: number
75+
}
76+
77+
/**
78+
* Run the trace analyst.
79+
*
80+
* Throws:
81+
* - `TraceFileMissingError` if `source` is a path and doesn't exist.
82+
* - `AxAgentClarificationError` if the analyst asks for clarification.
83+
* - Provider errors (auth, rate limits) propagate from the AI service.
84+
*/
85+
export async function analyzeTraces(
86+
input: AnalyzeTracesInput,
87+
options: AnalyzeTracesOptions,
88+
): Promise<AnalyzeTracesResult> {
89+
if (!input.question || typeof input.question !== 'string') {
90+
throw new TypeError('analyzeTraces: input.question must be a non-empty string')
91+
}
92+
93+
const store: TraceAnalysisStore =
94+
typeof options.source === 'string'
95+
? new OtlpFileTraceStore({ path: options.source })
96+
: options.source
97+
98+
// Pre-warm file stores so missing inputs fail before the RLM starts.
99+
if (store instanceof OtlpFileTraceStore) {
100+
await store.ensureIndexed()
101+
}
102+
103+
const tools: AxFunction[] = buildTraceAnalystTools({ store })
104+
const turns: AnalyzeTracesTurnSnapshot[] = []
105+
106+
const actorTurnCallback = async (turn: AxActorTurn): Promise<void> => {
107+
const snap: AnalyzeTracesTurnSnapshot = {
108+
turn: turn.turn,
109+
isError: turn.isError,
110+
code: turn.code,
111+
output: turn.output,
112+
thought: turn.thought,
113+
}
114+
turns.push(snap)
115+
if (options.onTurn) await options.onTurn(snap)
116+
}
117+
118+
const maxDepth = options.maxDepth ?? 1
119+
const maxTurns = options.maxTurns ?? 12
120+
const maxParallelSubagents = options.maxParallelSubagents ?? 2
121+
const maxRuntimeChars = options.maxRuntimeChars ?? 6000
122+
123+
const analyst = agent<{ question: string }, { answer: string; findings: string[] }>(
124+
'question:string -> answer:string, findings:string[]',
125+
{
126+
agentIdentity: {
127+
name: 'TraceAnalyst',
128+
description:
129+
'Analyzes OTLP-shaped JSONL traces using bounded discovery tools to identify systemic failure modes.',
130+
},
131+
contextFields: ['question'],
132+
runtime: new AxJSRuntime({
133+
permissions: [],
134+
blockDynamicImport: true,
135+
allowedModules: [],
136+
freezeIntrinsics: true,
137+
blockShadowRealm: true,
138+
preventGlobalThisExtensions: true,
139+
}),
140+
mode: maxDepth > 0 ? 'advanced' : 'simple',
141+
recursionOptions: maxDepth > 0 ? { maxDepth } : undefined,
142+
maxTurns,
143+
maxRuntimeChars,
144+
maxBatchedLlmQueryConcurrency: maxParallelSubagents,
145+
promptLevel: 'detailed',
146+
contextPolicy: { preset: 'checkpointed', budget: 'balanced' },
147+
functions: { local: tools },
148+
actorOptions: {
149+
description: options.actorDescription ?? TRACE_ANALYST_ACTOR_DESCRIPTION,
150+
...(options.model ? { model: options.model } : {}),
151+
},
152+
responderOptions: {
153+
...(options.model ? { model: options.model } : {}),
154+
description:
155+
options.subagentDescription ?? TRACE_ANALYST_SUBAGENT_DESCRIPTION,
156+
},
157+
actorTurnCallback,
158+
bubbleErrors: [TraceFileMissingError],
159+
},
160+
)
161+
162+
const result = await analyst.forward(options.ai, { question: input.question })
163+
164+
return {
165+
answer: typeof result.answer === 'string' ? result.answer : String(result.answer ?? ''),
166+
findings: Array.isArray(result.findings)
167+
? result.findings.filter((s): s is string => typeof s === 'string')
168+
: [],
169+
turns,
170+
turnCount: turns.length,
171+
usage: analyst.getUsage(),
172+
chatLog: analyst.getChatLog(),
173+
actorPromptVersion: TRACE_ANALYST_ACTOR_DESCRIPTION_VERSION,
174+
}
175+
}

0 commit comments

Comments
 (0)