Skip to content

Commit 26caeb1

Browse files
kulvirgitclaude
andcommitted
feat: add tracing for fingerprint detection and skill selection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7afaff9 commit 26caeb1

8 files changed

Lines changed: 107 additions & 13 deletions

File tree

packages/opencode/src/altimate/fingerprint/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Filesystem } from "../../util/filesystem"
22
import { Glob } from "../../util/glob"
33
import { Log } from "../../util/log"
4+
import { Tracer } from "../observability/tracing"
45
import path from "path"
56

67
const log = Log.create({ service: "fingerprint" })
@@ -27,6 +28,7 @@ export namespace Fingerprint {
2728
export async function detect(cwd: string, root?: string): Promise<Result> {
2829
if (cached && cached.cwd === cwd) return cached
2930

31+
const startTime = Date.now()
3032
const timer = log.time("detect", { cwd, root })
3133
const tags: string[] = []
3234

@@ -48,6 +50,15 @@ export namespace Fingerprint {
4850
cached = result
4951
timer.stop()
5052
log.info("detected", { tags: unique.join(","), cwd })
53+
54+
Tracer.active?.logSpan({
55+
name: "fingerprint",
56+
startTime,
57+
endTime: Date.now(),
58+
input: { cwd, root },
59+
output: { tags: unique },
60+
})
61+
5162
return result
5263
}
5364

packages/opencode/src/altimate/observability/tracing.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface TraceSpan {
4141
spanId: string
4242
parentSpanId: string | null
4343
name: string
44-
kind: "session" | "generation" | "tool" | "text"
44+
kind: "session" | "generation" | "tool" | "text" | "span"
4545
startTime: number
4646
endTime?: number
4747
status: "ok" | "error"
@@ -246,6 +246,11 @@ interface TracerOptions {
246246
}
247247

248248
export class Tracer {
249+
// Global active tracer — set when a session starts, cleared on end.
250+
private static _active: Tracer | null = null
251+
static get active(): Tracer | null { return Tracer._active }
252+
static setActive(tracer: Tracer | null) { Tracer._active = tracer }
253+
249254
private traceId: string
250255
private sessionId: string | undefined
251256
private rootSpanId: string | undefined
@@ -561,6 +566,39 @@ export class Tracer {
561566
if (part.text != null) this.generationText.push(String(part.text))
562567
}
563568

569+
/**
570+
* Log a custom span (e.g., fingerprint detection, skill selection).
571+
* Used for internal operations that aren't LLM generations or tool calls.
572+
*/
573+
logSpan(span: {
574+
name: string
575+
startTime: number
576+
endTime: number
577+
status?: "ok" | "error"
578+
input?: unknown
579+
output?: unknown
580+
attributes?: Record<string, unknown>
581+
}) {
582+
if (!this.rootSpanId) return
583+
try {
584+
this.spans.push({
585+
spanId: randomUUIDv7(),
586+
parentSpanId: this.rootSpanId,
587+
name: span.name,
588+
kind: "span",
589+
startTime: span.startTime,
590+
endTime: span.endTime,
591+
status: span.status ?? "ok",
592+
input: span.input,
593+
output: span.output,
594+
attributes: span.attributes,
595+
})
596+
this.snapshot()
597+
} catch {
598+
// best-effort
599+
}
600+
}
601+
564602
/**
565603
* Build a TraceFile snapshot of the current state (in-progress or complete).
566604
* Used for incremental writes and live viewing.

packages/opencode/src/altimate/skill-selector.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// altimate_change start - LLM-based dynamic skill selection
2-
import { generateObject } from "ai"
2+
import { generateText } from "ai"
33
import type { LanguageModelV2 } from "@openrouter/ai-sdk-provider"
4-
import z from "zod"
54
import { Provider } from "../provider/provider"
65
import { Log } from "../util/log"
76
import type { Skill } from "../skill"
87
import type { Fingerprint } from "./fingerprint"
8+
import { Tracer } from "./observability/tracing"
99

1010
const log = Log.create({ service: "skill-selector" })
1111

@@ -27,9 +27,8 @@ export interface SkillSelectorDeps {
2727
generate: (params: {
2828
model: LanguageModelV2
2929
temperature: number
30-
schema: z.ZodType
3130
messages: Array<{ role: "system" | "user"; content: string }>
32-
}) => Promise<{ object: { selected: string[] } }>
31+
}) => Promise<{ text: string }>
3332
}
3433

3534
async function defaultResolveModel(): Promise<LanguageModelV2 | undefined> {
@@ -44,7 +43,7 @@ async function defaultResolveModel(): Promise<LanguageModelV2 | undefined> {
4443

4544
const defaultDeps: SkillSelectorDeps = {
4645
resolveModel: defaultResolveModel,
47-
generate: generateObject as any,
46+
generate: generateText as any,
4847
}
4948

5049
/**
@@ -58,12 +57,21 @@ export async function selectSkillsWithLLM(
5857
fingerprint: Fingerprint.Result | undefined,
5958
deps?: SkillSelectorDeps,
6059
): Promise<Skill.Info[]> {
60+
const startTime = Date.now()
61+
6162
// Return cached result if cwd hasn't changed (0ms)
6263
const cwd = fingerprint?.cwd
6364
if (cachedResult && cwd === cachedCwd) {
6465
log.info("returning cached skill selection", {
6566
count: cachedResult.length,
6667
})
68+
Tracer.active?.logSpan({
69+
name: "skill-selection",
70+
startTime,
71+
endTime: Date.now(),
72+
input: { fingerprint: fingerprint?.tags, source: "cache" },
73+
output: { count: cachedResult.length, skills: cachedResult.map((s) => s.name) },
74+
})
6775
return cachedResult
6876
}
6977

@@ -96,15 +104,15 @@ export async function selectSkillsWithLLM(
96104
const params = {
97105
model,
98106
temperature: 0,
99-
schema: z.object({ selected: z.array(z.string()) }),
100107
messages: [
101108
{
102109
role: "system" as const,
103110
content: [
104111
"You are a skill selector for a coding assistant.",
105112
"Given a project environment and available skills, select which skills are relevant for this project.",
106-
"Return ONLY skill names the user likely needs. Select 0-15 skills.",
107-
"Prefer fewer, more relevant skills over many loosely related ones.",
113+
"Return ONLY a JSON object: {\"selected\": [\"skill-name-1\", \"skill-name-2\"]}",
114+
"Select 0-15 skills. Prefer fewer, more relevant skills over many loosely related ones.",
115+
"Return ONLY the JSON object, no other text.",
108116
].join("\n"),
109117
},
110118
{
@@ -125,7 +133,15 @@ export async function selectSkillsWithLLM(
125133
),
126134
])
127135

128-
const selected = result.object.selected.slice(0, MAX_SKILLS)
136+
// Parse JSON from response text
137+
const text = result.text.trim()
138+
const jsonMatch = text.match(/\{[\s\S]*\}/)
139+
if (!jsonMatch) {
140+
log.info("LLM response not valid JSON, returning all skills")
141+
return cache(skills)
142+
}
143+
const parsed = JSON.parse(jsonMatch[0]) as { selected?: string[] }
144+
const selected = (parsed.selected ?? []).slice(0, MAX_SKILLS)
129145

130146
// Zero-selection guard
131147
if (selected.length === 0) {
@@ -147,11 +163,26 @@ export async function selectSkillsWithLLM(
147163
count: matched.length,
148164
names: matched.map((s) => s.name),
149165
})
166+
Tracer.active?.logSpan({
167+
name: "skill-selection",
168+
startTime,
169+
endTime: Date.now(),
170+
input: { fingerprint: fingerprint?.tags, totalSkills: skills.length, source: "llm" },
171+
output: { count: matched.length, skills: matched.map((s) => s.name) },
172+
})
150173
return cache(matched)
151174
} catch (e) {
152175
log.info("skill selection failed, returning all skills", {
153176
error: e instanceof Error ? e.message : String(e),
154177
})
178+
Tracer.active?.logSpan({
179+
name: "skill-selection",
180+
startTime,
181+
endTime: Date.now(),
182+
status: "error",
183+
input: { fingerprint: fingerprint?.tags, source: "fallback" },
184+
output: { count: skills.length, error: e instanceof Error ? e.message : String(e) },
185+
})
155186
return cache(skills)
156187
}
157188
}

packages/opencode/src/cli/cmd/run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,7 @@ You are speaking to a non-technical business executive. Follow these rules stric
720720
variant: args.variant,
721721
prompt: message,
722722
})
723+
if (tracer) Tracer.setActive(tracer)
723724

724725
// Register crash handlers to flush the trace on unexpected exit
725726
const onSigint = () => { tracer?.flushSync("Process interrupted"); process.exit(130) }
@@ -766,6 +767,7 @@ You are speaking to a non-technical business executive. Follow these rules stric
766767

767768
// Finalize trace and save to disk
768769
if (tracer) {
770+
Tracer.setActive(null)
769771
const tracePath = await tracer.endTrace(error)
770772
if (tracePath) {
771773
emit("trace_saved", { path: tracePath })

packages/opencode/src/cli/cmd/tui/worker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ function getOrCreateTracer(sessionID: string): Tracer | null {
9393
? Tracer.withExporters([...tracingExporters], { maxFiles: tracingMaxFiles })
9494
: Tracer.create()
9595
tracer.startTrace(sessionID, {})
96+
Tracer.setActive(tracer)
9697
sessionTracers.set(sessionID, tracer)
9798
return tracer
9899
} catch {

packages/opencode/src/session/prompt.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { decodeDataUrl } from "@/util/data-url"
5353
// altimate_change start - import fingerprint for env-based skill selection
5454
import { Fingerprint } from "../altimate/fingerprint"
5555
import { Config } from "../config/config"
56+
import { Tracer } from "../altimate/observability/tracing"
5657
// altimate_change end
5758
import { Telemetry } from "@/telemetry" // altimate_change — session telemetry
5859

@@ -727,6 +728,16 @@ export namespace SessionPrompt {
727728
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
728729
}
729730

731+
// altimate_change start - trace system prompt
732+
Tracer.active?.logSpan({
733+
name: "system-prompt",
734+
startTime: Date.now(),
735+
endTime: Date.now(),
736+
input: { agent: agent.name, step },
737+
output: { parts: system.length, content: system.join("\n\n") },
738+
})
739+
// altimate_change end
740+
730741
const result = await processor.process({
731742
user: lastUser,
732743
agent,

packages/opencode/test/altimate/skill-filtering.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function makeDeps(selected: string[], model: LanguageModelV2 = FAKE_MODEL): Skil
3535
resolveModel: async () => model,
3636
generate: async (params) => {
3737
calls.push(params)
38-
return { object: { selected } }
38+
return { text: JSON.stringify({ selected }) }
3939
},
4040
}
4141
}

packages/opencode/test/tool/skill.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function seedCache(skillNames: string[]) {
3939
})) as Skill.Info[]
4040
const deps: SkillSelectorDeps = {
4141
resolveModel: async () => FAKE_MODEL,
42-
generate: async () => ({ object: { selected: skillNames } }),
42+
generate: async () => ({ text: JSON.stringify({ selected: skillNames }) }),
4343
}
4444
return selectSkillsWithLLM(skills, undefined, deps)
4545
}
@@ -210,7 +210,7 @@ Use this skill.
210210
resetSkillSelectorCache()
211211
const deps: SkillSelectorDeps = {
212212
resolveModel: async () => FAKE_MODEL,
213-
generate: async () => ({ object: { selected: ["skill-alpha"] } }),
213+
generate: async () => ({ text: JSON.stringify({ selected: ["skill-alpha"] }) }),
214214
}
215215
await selectSkillsWithLLM(
216216
[{ name: "skill-alpha", description: "Test skill-alpha", location: alphaLocation, content: "# skill-alpha" } as Skill.Info],

0 commit comments

Comments
 (0)