216 lines
7.0 KiB
TypeScript
216 lines
7.0 KiB
TypeScript
/**
|
|
* Langfuse Observability Extension for Pi Coding Agent
|
|
*
|
|
* Sends one complete Langfuse trace per Pi agent run:
|
|
* - root agent observation for the user prompt and final assistant response
|
|
* - one generation observation per provider request
|
|
* - one tool observation per tool call, keyed by toolCallId
|
|
*/
|
|
|
|
import { basename } from "node:path";
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
|
import { state, resetRunState, runWithSession, setCurrentSession } from "./src/state.js";
|
|
import { ensureConfig, promptForConfig, loadConfig } from "./src/config.js";
|
|
import { shutdownRuntime } from "./src/langfuse.js";
|
|
import { handleLangfusePrivacyCommand, handleLangfuseStatusCommand, handleLangfuseTestCommand } from "./src/commands.js";
|
|
import { getMessageFromEvent, extractAssistantOutput, getCapturePolicy } from "./src/utils.js";
|
|
import { applyCapturePolicy } from "./src/capture-policy.js";
|
|
import { startAgentRun, finishAgentRun } from "./src/handlers/agent.js";
|
|
import { startTurnObservation, finishTurnObservation } from "./src/handlers/turn.js";
|
|
import {
|
|
startGeneration,
|
|
updateGenerationMetadata,
|
|
finishGenerationFromMessage,
|
|
createFallbackGenerationFromTurn,
|
|
recordTTFT,
|
|
} from "./src/handlers/generation.js";
|
|
import {
|
|
startToolObservation,
|
|
finishToolObservation,
|
|
closeDanglingObservations,
|
|
} from "./src/handlers/tool.js";
|
|
|
|
// ============================================
|
|
// Extension
|
|
// ============================================
|
|
|
|
export default async function (pi: ExtensionAPI) {
|
|
if (!state.config) {
|
|
state.config = loadConfig();
|
|
}
|
|
|
|
if (state.config) {
|
|
console.log("📊 Langfuse: Tracing enabled →", state.config.host);
|
|
} else {
|
|
console.log("📊 Langfuse: Waiting for first-run setup");
|
|
}
|
|
|
|
pi.registerCommand("langfuse-setup", {
|
|
description: "Configure Langfuse API keys for this extension",
|
|
handler: async (_args, ctx) => {
|
|
await promptForConfig(ctx);
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("langfuse-test", {
|
|
description: "Send a test trace to Langfuse to verify configuration",
|
|
handler: async (args, ctx) => {
|
|
await handleLangfuseTestCommand(String(args ?? ""), ctx);
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("langfuse-status", {
|
|
description: "Show Langfuse configuration and runtime status",
|
|
handler: async (args, ctx) => {
|
|
await handleLangfuseStatusCommand(String(args ?? ""), ctx);
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("langfuse-privacy", {
|
|
description: "View or set Langfuse telemetry privacy preset",
|
|
handler: async (args, ctx) => {
|
|
await handleLangfusePrivacyCommand(String(args ?? ""), ctx);
|
|
},
|
|
});
|
|
|
|
const getSessionId = (ctx?: any) => {
|
|
try {
|
|
const sessionFile = ctx?.sessionManager?.getSessionFile?.();
|
|
return sessionFile ? basename(sessionFile, ".jsonl") : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const withSession = <T>(ctx: any, fn: () => T): T => runWithSession(getSessionId(ctx) ?? state.currentSessionId, fn);
|
|
|
|
pi.on("session_start", async (_event, ctx) => withSession(ctx, async () => {
|
|
state.setupAttemptedThisSession = false;
|
|
await ensureConfig(ctx);
|
|
resetRunState();
|
|
}));
|
|
|
|
pi.on("model_select", async (event, ctx) => withSession(ctx, async () => {
|
|
state.currentModel = event.model?.id || "";
|
|
state.currentProvider = event.model?.provider || "";
|
|
}));
|
|
|
|
pi.on("before_agent_start", async (event, ctx) => withSession(ctx, async () => {
|
|
await startAgentRun(event, ctx);
|
|
}));
|
|
|
|
pi.on("agent_start", async (event, ctx) => withSession(ctx, async () => {
|
|
if (!state.agentState?.root) {
|
|
await startAgentRun(event, ctx);
|
|
}
|
|
}));
|
|
|
|
pi.on("turn_start", async (event, ctx) => withSession(ctx, async () => {
|
|
await startTurnObservation(event);
|
|
}));
|
|
|
|
pi.on("before_provider_request", async (event, ctx) => withSession(ctx, async () => {
|
|
await startGeneration(event);
|
|
}));
|
|
|
|
pi.on("after_provider_response", async (event, ctx) => withSession(ctx, async () => {
|
|
updateGenerationMetadata(event);
|
|
}));
|
|
|
|
pi.on("message_update", async (event, ctx) => withSession(ctx, async () => {
|
|
recordTTFT(event);
|
|
const message = getMessageFromEvent(event);
|
|
if (message?.role === "assistant" && state.agentState) {
|
|
state.agentState.latestAssistantOutput = extractAssistantOutput(message);
|
|
}
|
|
}));
|
|
|
|
pi.on("message_end", async (event, ctx) => withSession(ctx, async () => {
|
|
await finishGenerationFromMessage(event);
|
|
}));
|
|
|
|
pi.on("tool_execution_start", async (event, ctx) => withSession(ctx, async () => {
|
|
await startToolObservation(event);
|
|
}));
|
|
|
|
pi.on("tool_call", async (event, ctx) => withSession(ctx, async () => {
|
|
await startToolObservation(event);
|
|
}));
|
|
|
|
pi.on("tool_result", async (event, ctx) => withSession(ctx, async () => {
|
|
await finishToolObservation(event);
|
|
}));
|
|
|
|
pi.on("tool_execution_end", async (event, ctx) => withSession(ctx, async () => {
|
|
await finishToolObservation(event);
|
|
}));
|
|
|
|
pi.on("turn_end", async (event, ctx) => withSession(ctx, async () => {
|
|
state.turnCount++;
|
|
const message = getMessageFromEvent(event);
|
|
if (message?.role === "assistant") {
|
|
await createFallbackGenerationFromTurn(event, message);
|
|
await finishGenerationFromMessage(event);
|
|
}
|
|
finishTurnObservation(event);
|
|
}));
|
|
|
|
pi.on("agent_end", async (event, ctx) => withSession(ctx, async () => {
|
|
await finishAgentRun(event);
|
|
const sessionId = state.currentSessionId;
|
|
setTimeout(() => {
|
|
shutdownRuntime(sessionId).catch((error) => {
|
|
console.warn("📊 Langfuse: Deferred shutdown failed", error);
|
|
});
|
|
}, 0);
|
|
}));
|
|
|
|
const handleSessionInterruption = (reason: string) => {
|
|
if (state.agentState?.root) {
|
|
closeDanglingObservations(reason);
|
|
state.agentState.root.update({ metadata: { completed: false, cancelled: true } }).end();
|
|
}
|
|
resetRunState();
|
|
};
|
|
|
|
pi.on("session_before_switch", async (_event, ctx) => {
|
|
const sessionId = getSessionId(ctx);
|
|
if (sessionId) {
|
|
setCurrentSession(sessionId);
|
|
}
|
|
});
|
|
|
|
pi.on("session_before_fork", async (_event, ctx) => {
|
|
const sessionId = getSessionId(ctx);
|
|
if (sessionId) {
|
|
setCurrentSession(sessionId);
|
|
}
|
|
});
|
|
|
|
pi.on("session_compact", async (event, ctx) => withSession(ctx, async () => {
|
|
if (state.agentState?.root) {
|
|
const parent = state.agentState.activeTurn ?? state.agentState.root;
|
|
try {
|
|
const observation = parent.startObservation ? parent.startObservation(
|
|
"session_compact",
|
|
{
|
|
level: "DEFAULT",
|
|
statusMessage: "Context was compacted",
|
|
metadata: applyCapturePolicy({ metadata: { ...event } }, getCapturePolicy()).metadata
|
|
},
|
|
{ asType: "span" }
|
|
) : undefined;
|
|
observation?.end();
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
}));
|
|
|
|
pi.on("session_shutdown", async (_event, ctx) => withSession(ctx, async () => {
|
|
handleSessionInterruption("Session shutdown before agent completed");
|
|
await shutdownRuntime();
|
|
}));
|
|
}
|