220 lines
5.5 KiB
TypeScript
220 lines
5.5 KiB
TypeScript
import {
|
|
TraceBuilder,
|
|
SpanType,
|
|
SpanStatus,
|
|
DecisionType,
|
|
nowISO,
|
|
getClient,
|
|
} from "agentlens-sdk";
|
|
import type { JsonValue, TraceStatus } from "agentlens-sdk";
|
|
import { extractToolMetadata, safeJsonValue } from "./utils.js";
|
|
|
|
interface ToolCallState {
|
|
startTime: number;
|
|
tool: string;
|
|
args: unknown;
|
|
sessionID: string;
|
|
}
|
|
|
|
export class SessionState {
|
|
private traces = new Map<string, TraceBuilder>();
|
|
private toolCalls = new Map<string, ToolCallState>();
|
|
private rootSpans = new Map<string, string>();
|
|
|
|
startSession(
|
|
sessionId: string,
|
|
metadata?: Record<string, unknown>,
|
|
): TraceBuilder {
|
|
const trace = new TraceBuilder("opencode-session", {
|
|
sessionId,
|
|
tags: ["opencode", "coding-agent"],
|
|
metadata: metadata ? (safeJsonValue(metadata) as JsonValue) : undefined,
|
|
});
|
|
|
|
const rootSpanId = trace.addSpan({
|
|
name: "session",
|
|
type: SpanType.AGENT,
|
|
startedAt: nowISO(),
|
|
metadata: metadata ? (safeJsonValue(metadata) as JsonValue) : undefined,
|
|
});
|
|
|
|
this.traces.set(sessionId, trace);
|
|
this.rootSpans.set(sessionId, rootSpanId);
|
|
return trace;
|
|
}
|
|
|
|
getTrace(sessionId: string): TraceBuilder | undefined {
|
|
return this.traces.get(sessionId);
|
|
}
|
|
|
|
endSession(sessionId: string, status?: TraceStatus): void {
|
|
const trace = this.traces.get(sessionId);
|
|
if (!trace) return;
|
|
|
|
const rootSpanId = this.rootSpans.get(sessionId);
|
|
if (rootSpanId) {
|
|
trace.addSpan({
|
|
id: rootSpanId,
|
|
name: "session",
|
|
type: SpanType.AGENT,
|
|
status: status === "ERROR" ? SpanStatus.ERROR : SpanStatus.COMPLETED,
|
|
endedAt: nowISO(),
|
|
});
|
|
}
|
|
|
|
trace.end({ status: status ?? "COMPLETED" });
|
|
this.traces.delete(sessionId);
|
|
this.rootSpans.delete(sessionId);
|
|
}
|
|
|
|
startToolCall(
|
|
callID: string,
|
|
tool: string,
|
|
args: unknown,
|
|
sessionID: string,
|
|
): void {
|
|
this.toolCalls.set(callID, {
|
|
startTime: Date.now(),
|
|
tool,
|
|
args,
|
|
sessionID,
|
|
});
|
|
}
|
|
|
|
endToolCall(
|
|
callID: string,
|
|
output: string,
|
|
title: string,
|
|
metadata: unknown,
|
|
): void {
|
|
const call = this.toolCalls.get(callID);
|
|
if (!call) return;
|
|
this.toolCalls.delete(callID);
|
|
|
|
const trace = this.traces.get(call.sessionID);
|
|
if (!trace) return;
|
|
|
|
const durationMs = Date.now() - call.startTime;
|
|
const rootSpanId = this.rootSpans.get(call.sessionID);
|
|
const toolMeta = extractToolMetadata(call.tool, call.args);
|
|
|
|
trace.addSpan({
|
|
name: title || call.tool || "unknown-tool",
|
|
type: SpanType.TOOL_CALL,
|
|
parentSpanId: rootSpanId,
|
|
input: safeJsonValue(call.args),
|
|
output: output as JsonValue,
|
|
durationMs,
|
|
status: SpanStatus.COMPLETED,
|
|
startedAt: new Date(call.startTime).toISOString(),
|
|
endedAt: nowISO(),
|
|
metadata: safeJsonValue({ ...toolMeta, rawMetadata: metadata }),
|
|
});
|
|
|
|
const reasoningText =
|
|
title !== call.tool && title
|
|
? `Selected ${call.tool}: ${title}`
|
|
: `Selected tool: ${call.tool}`;
|
|
|
|
trace.addDecision({
|
|
type: DecisionType.TOOL_SELECTION,
|
|
chosen: call.tool as JsonValue,
|
|
alternatives: [],
|
|
reasoning: reasoningText,
|
|
durationMs,
|
|
parentSpanId: rootSpanId,
|
|
});
|
|
}
|
|
|
|
recordLLMCall(
|
|
sessionId: string,
|
|
options: {
|
|
model?: { providerID: string; modelID: string };
|
|
agent?: string;
|
|
messageID?: string;
|
|
},
|
|
): void {
|
|
const trace = this.traces.get(sessionId);
|
|
if (!trace) return;
|
|
|
|
const rootSpanId = this.rootSpans.get(sessionId);
|
|
const agentName = options.agent ?? "assistant";
|
|
const modelName = options.model?.modelID ?? "unknown";
|
|
|
|
trace.addSpan({
|
|
name: `${agentName} → ${modelName}`,
|
|
type: SpanType.LLM_CALL,
|
|
parentSpanId: rootSpanId,
|
|
status: SpanStatus.COMPLETED,
|
|
startedAt: nowISO(),
|
|
endedAt: nowISO(),
|
|
metadata: safeJsonValue({
|
|
provider: options.model?.providerID,
|
|
model: options.model?.modelID,
|
|
agent: options.agent,
|
|
messageID: options.messageID,
|
|
}),
|
|
});
|
|
}
|
|
|
|
recordPermission(
|
|
sessionId: string,
|
|
permission: unknown,
|
|
status: string,
|
|
): void {
|
|
const trace = this.traces.get(sessionId);
|
|
if (!trace) return;
|
|
|
|
const rootSpanId = this.rootSpans.get(sessionId);
|
|
const p = permission as Record<string, unknown> | null;
|
|
const title = (p?.["title"] as string) ?? "permission";
|
|
const permType = (p?.["type"] as string) ?? "unknown";
|
|
|
|
trace.addDecision({
|
|
type: DecisionType.ESCALATION,
|
|
chosen: safeJsonValue({ action: status }),
|
|
alternatives: [
|
|
"allow" as JsonValue,
|
|
"deny" as JsonValue,
|
|
"ask" as JsonValue,
|
|
],
|
|
reasoning: `${permType}: ${title}`,
|
|
parentSpanId: rootSpanId,
|
|
});
|
|
}
|
|
|
|
getRootSpanId(sessionId: string): string | undefined {
|
|
return this.rootSpans.get(sessionId);
|
|
}
|
|
|
|
getActiveSessionIds(): string[] {
|
|
return Array.from(this.traces.keys());
|
|
}
|
|
|
|
flushSession(sessionId: string): void {
|
|
const trace = this.traces.get(sessionId);
|
|
if (!trace) return;
|
|
|
|
const rootSpanId = this.rootSpans.get(sessionId);
|
|
if (rootSpanId) {
|
|
trace.addSpan({
|
|
id: rootSpanId,
|
|
name: "session",
|
|
type: SpanType.AGENT,
|
|
status: SpanStatus.COMPLETED,
|
|
endedAt: nowISO(),
|
|
});
|
|
}
|
|
|
|
trace.end({ status: "COMPLETED" });
|
|
|
|
const transport = getClient();
|
|
if (transport) {
|
|
transport.add(trace.toPayload());
|
|
}
|
|
|
|
this.traces.delete(sessionId);
|
|
this.rootSpans.delete(sessionId);
|
|
}
|
|
}
|