langsmith
Version:
Client library to connect to the LangSmith Observability and Evaluation Platform.
850 lines (849 loc) • 31.2 kB
JavaScript
"use strict";
/**
* LangSmith integration for OpenAI Agents SDK.
*
* This module provides tracing support for the OpenAI Agents SDK.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.OpenAIAgentsTracingProcessor = void 0;
const node_async_hooks_1 = require("node:async_hooks");
const run_trees_js_1 = require("../run_trees.cjs");
const client_js_1 = require("../client.cjs");
const traceable_js_1 = require("../singletons/traceable.cjs");
traceable_js_1.AsyncLocalStorageProviderSingleton.initializeGlobalInstance(new node_async_hooks_1.AsyncLocalStorage());
/**
* Set the current AsyncLocalStorage store to the given RunTree without a
* callback. Uses `AsyncLocalStorage.enterWith` if available on the underlying
* instance (it is on Node's built-in ALS). This is required because the
* OpenAI Agents tracing processor receives `onSpanStart`/`onSpanEnd` callbacks
* at different points with no single function to wrap via `withRunTree`.
*
* Returns the previous store so callers can restore it on exit.
*
* Caveats of `enterWith` (inherent, not avoidable with this API shape):
* - Replaces the ALS store for the current async task and all its
* descendants. Concurrent async tasks spawned from the caller's scope
* during the trace will see the installed store.
* - `onTraceEnd`/`onSpanEnd` restoration only works when it runs on the
* same async task as the matching start. This is guaranteed by the
* OpenAI Agents SDK's span lifecycle (span.start / fn / span.end are
* invoked on one task via `_withSpanFactory`).
*/
function enterRunTreeContext(runTree) {
const storage = traceable_js_1.AsyncLocalStorageProviderSingleton.getInstance();
const previous = storage.getStore();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const maybeEnterWith = storage.enterWith;
if (typeof maybeEnterWith === "function") {
maybeEnterWith.call(storage, runTree);
}
return previous;
}
/**
* Parse inputs or outputs into a dictionary format.
*/
function isOpenAIAgentsItemArray(data) {
return (Array.isArray(data) &&
data.length > 0 &&
data.every((item) => typeof item === "object" &&
item !== null &&
"type" in item &&
typeof item.type === "string"));
}
function normalizeResponseInputItemsForReplay(items) {
return items.map((item) => {
const type = item.type;
if (type === "message") {
return {
type: "message",
role: item.role,
content: item.content,
};
}
if (type === "reasoning") {
return {
type: "reasoning",
...(item.id ? { id: item.id } : {}),
content: Array.isArray(item.content) ? item.content : [],
};
}
if (type === "function_call") {
return {
type: "function_call",
...(item.id ? { id: item.id } : {}),
call_id: item.callId,
name: item.name,
arguments: item.arguments,
};
}
if (type === "function_call_result") {
const output = item.output;
return {
type: "function_call_output",
call_id: item.callId,
output: typeof output === "object" && output !== null && "text" in output
? output.text
: output,
};
}
return item;
});
}
function parseIO(data, defaultKey = "output") {
if (data === null || data === undefined) {
return {};
}
if (Array.isArray(data)) {
if (data.length === 0) {
return {};
}
// Check if this is a list of output blocks (reasoning, message, etc.)
if (data.length > 0 && typeof data[0] === "object" && data[0] !== null) {
if ("type" in data[0]) {
return { [defaultKey]: data };
}
else if (data.length === 1) {
return data[0];
}
}
return { [defaultKey]: data };
}
if (typeof data === "object") {
return data;
}
if (typeof data === "string") {
try {
const parsed = JSON.parse(data);
if (typeof parsed === "object" && parsed !== null) {
return parsed;
}
return { [defaultKey]: data };
}
catch {
return { [defaultKey]: data };
}
}
return { [defaultKey]: data };
}
/**
* Get the LangSmith run type for a span.
*/
function getRunType(span) {
const spanType = span.spanData?.type;
if (spanType === "agent" || spanType === "handoff" || spanType === "custom") {
return "chain";
}
else if (spanType === "function" || spanType === "guardrail") {
return "tool";
}
else if (spanType === "generation" || spanType === "response") {
return "llm";
}
return "chain";
}
/**
* Get the run name for a span.
*/
function getRunName(span) {
const spanData = span.spanData;
if ("name" in spanData && spanData.name) {
return spanData.name;
}
const spanType = spanData?.type;
if (spanType === "generation") {
return "Generation";
}
else if (spanType === "response") {
return "Response";
}
else if (spanType === "handoff") {
return "Handoff";
}
return "Span";
}
function deriveAgentInputsOutputs(run) {
const children = [...run.child_runs];
const firstChildWithInputs = children.find((child) => child.inputs && Object.keys(child.inputs).length > 0);
const lastChildWithOutputs = [...children]
.reverse()
.find((child) => child.outputs && Object.keys(child.outputs).length > 0);
return {
...(firstChildWithInputs ? { inputs: firstChildWithInputs.inputs } : {}),
...(lastChildWithOutputs ? { outputs: lastChildWithOutputs.outputs } : {}),
};
}
/**
* Extract span data into a format suitable for LangSmith runs.
*/
function extractSpanData(span) {
const spanData = span.spanData;
const data = {};
if (spanData.type === "function") {
const functionData = spanData;
data.inputs = parseIO(functionData.input, "input");
data.outputs = parseIO(functionData.output, "output");
}
else if (spanData.type === "generation") {
const generationData = spanData;
data.inputs = parseIO(generationData.input, "input");
data.outputs = parseIO(generationData.output, "output");
data.invocation_params = {
model: generationData.model,
model_config: generationData.model_config,
};
if (generationData.usage) {
data.metadata = {
usage_metadata: createUsageMetadata(generationData.usage),
};
}
}
else if (spanData.type === "response") {
const responseData = spanData;
if (responseData._input !== undefined) {
data.inputs = {
input: isOpenAIAgentsItemArray(responseData._input)
? normalizeResponseInputItemsForReplay(responseData._input)
: responseData._input,
instructions: typeof responseData._response?.instructions === "string"
? responseData._response.instructions
: "",
};
}
if (responseData._response) {
const response = responseData._response;
const outputData = response.output ?? [];
data.outputs = parseIO(outputData, "output");
// Extract invocation params
const invocationParams = {};
const invocationKeys = [
"max_output_tokens",
"model",
"parallel_tool_calls",
"reasoning",
"temperature",
"text",
"tool_choice",
"tools",
"top_p",
"truncation",
];
for (const key of invocationKeys) {
if (key in response) {
invocationParams[key] = response[key];
}
}
data.invocation_params = invocationParams;
// Extract metadata
const metadata = {};
const metadataKeys = Object.keys(response).filter((k) => k !== "output" &&
k !== "usage" &&
k !== "instructions" &&
!invocationKeys.includes(k));
for (const key of metadataKeys) {
metadata[key] = response[key];
}
metadata.ls_model_name = invocationParams.model;
metadata.ls_max_tokens = invocationParams.max_output_tokens;
metadata.ls_temperature = invocationParams.temperature;
metadata.ls_model_type = "chat";
metadata.ls_provider = "openai";
if (response.usage) {
metadata.usage_metadata = createResponsesUsageMetadata(response.usage);
}
data.metadata = metadata;
}
}
else if (spanData.type === "agent") {
const agentData = spanData;
data.invocation_params = {
tools: agentData.tools,
handoffs: agentData.handoffs,
};
data.metadata = {
output_type: agentData.output_type,
};
}
else if (spanData.type === "handoff") {
const handoffData = spanData;
data.inputs = {
from_agent: handoffData.from_agent,
};
data.outputs = {
to_agent: handoffData.to_agent,
};
}
else if (spanData.type === "guardrail") {
const guardrailData = spanData;
data.metadata = {
triggered: guardrailData.triggered,
};
}
else if (spanData.type === "custom") {
const customData = spanData;
data.metadata = customData.data;
}
return data;
}
/**
* Create usage metadata from a `generation` span's `GenerationUsageData`.
*
* The Agents SDK's generation-span usage shape is intentionally flexible and
* puts token breakdowns under `usage.details` (e.g. `cached_tokens`,
* `reasoning_tokens`, `audio_tokens`). This is distinct from the OpenAI
* Responses API shape used by `response` spans (see
* {@link createResponsesUsageMetadata}).
*/
function createUsageMetadata(usage) {
const inputTokens = usage.input_tokens ?? 0;
const outputTokens = usage.output_tokens ?? 0;
const result = {
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
};
// Handle details if present
if (usage.details) {
const details = usage.details;
const inputTokenDetails = {};
const outputTokenDetails = {};
// Map common detail fields
if (typeof details.cached_tokens === "number") {
inputTokenDetails.cache_read = details.cached_tokens;
}
if (typeof details.reasoning_tokens === "number") {
outputTokenDetails.reasoning = details.reasoning_tokens;
}
if (typeof details.audio_tokens === "number") {
inputTokenDetails.audio = details.audio_tokens;
}
if (Object.keys(inputTokenDetails).length > 0) {
result.input_token_details = inputTokenDetails;
}
if (Object.keys(outputTokenDetails).length > 0) {
result.output_token_details = outputTokenDetails;
}
}
return result;
}
/**
* Create usage metadata from a `response` span's embedded OpenAI Responses API
* usage object.
*
* Shape:
* ```
* {
* input_tokens, output_tokens, total_tokens,
* input_tokens_details: { cached_tokens },
* output_tokens_details: { reasoning_tokens },
* }
* ```
*
* This is distinct from {@link createUsageMetadata}, which handles the
* Agents SDK `GenerationUsageData` shape (with breakdowns under `details`).
*/
function createResponsesUsageMetadata(usage) {
const inputTokens = usage.input_tokens ?? 0;
const outputTokens = usage.output_tokens ?? 0;
const totalTokens = usage.total_tokens ?? inputTokens + outputTokens;
const result = {
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
};
const inputTokenDetails = {};
const outputTokenDetails = {};
const inputDetails = usage.input_tokens_details;
if (inputDetails && typeof inputDetails.cached_tokens === "number") {
inputTokenDetails.cache_read = inputDetails.cached_tokens;
}
const outputDetails = usage.output_tokens_details;
if (outputDetails && typeof outputDetails.reasoning_tokens === "number") {
outputTokenDetails.reasoning = outputDetails.reasoning_tokens;
}
if (Object.keys(inputTokenDetails).length > 0) {
result.input_token_details = inputTokenDetails;
}
if (Object.keys(outputTokenDetails).length > 0) {
result.output_token_details = outputTokenDetails;
}
return result;
}
/**
* Tracing processor for the [OpenAI Agents SDK](https://openai.github.io/openai-agents-js/).
*
* Traces all intermediate steps of your OpenAI Agent to LangSmith.
*
* Requirements: Make sure to install `npm install @openai/agents`.
*
* Installing this processor is itself an explicit opt-in to tracing,
* so traces will be posted regardless of the `LANGSMITH_TRACING` env
* variable. Any nested `traceable()` calls made from within an agent
* run (e.g. inside a tool handler) will inherit this and also post,
* even if `LANGSMITH_TRACING` is not set.
*
* @param client - An instance of `langsmith.Client`. If not provided, a default client is created.
* @param metadata - Metadata to associate with all traces.
* @param tags - Tags to associate with all traces.
* @param projectName - LangSmith project to trace to.
* @param name - Name of the root trace.
*
* @example
* ```typescript
* import { Agent, Runner, function_tool, setTraceProcessors } from "@openai/agents";
* import { OpenAIAgentsTracingProcessor } from "langsmith/wrappers/openai_agents";
*
* setTraceProcessors([new OpenAIAgentsTracingProcessor()]);
*
* const getWeather = function_tool({
* name: "get_weather",
* description: "Get the weather for a city",
* parameters: { type: "object", properties: { city: { type: "string" } } },
* run: async ({ city }: { city: string }) => `The weather in ${city} is sunny`,
* });
*
* const agent = new Agent({
* name: "Assistant",
* instructions: "You are a helpful assistant",
* model: "gpt-4.1-mini",
* tools: [getWeather],
* });
*
* const result = await Runner.run(agent, "What's the weather in New York?");
* console.log(result.finalOutput);
* ```
*/
class OpenAIAgentsTracingProcessor {
constructor(options) {
Object.defineProperty(this, "client", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_metadata", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_tags", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_projectName", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_name", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "_firstResponseInputs", {
enumerable: true,
configurable: true,
writable: true,
value: {}
});
Object.defineProperty(this, "_lastResponseOutputs", {
enumerable: true,
configurable: true,
writable: true,
value: {}
});
Object.defineProperty(this, "_runs", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
Object.defineProperty(this, "_spanDataTypes", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
Object.defineProperty(this, "_unpostedTraces", {
enumerable: true,
configurable: true,
writable: true,
value: new Set()
});
Object.defineProperty(this, "_unpostedSpans", {
enumerable: true,
configurable: true,
writable: true,
value: new Set()
});
// Previous AsyncLocalStorage store for each trace/span, so nested
// traceable() calls inside Agents tools correctly nest under the
// enclosing span and context can be restored when the span/trace ends.
Object.defineProperty(this, "_previousStoreByTrace", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
Object.defineProperty(this, "_previousStoreBySpan", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
this.client = options?.client ?? new client_js_1.Client();
this._metadata = options?.metadata;
this._tags = options?.tags;
this._projectName = options?.projectName;
this._name = options?.name;
}
async onTraceStart(trace) {
let currentRunTree;
try {
currentRunTree = (0, traceable_js_1.getCurrentRunTree)();
}
catch {
// Not in a traceable context
currentRunTree = undefined;
}
// Determine run name
let runName;
if (this._name) {
runName = this._name;
}
else if (trace.name) {
runName = trace.name;
}
else {
runName = "Agent workflow";
}
// Build metadata
const runExtra = {
metadata: {
...this._metadata,
ls_integration: "openai-agents-sdk",
ls_agent_type: "root",
},
};
const traceDict = trace.toJSON() ?? {};
const groupId = trace.groupId ??
traceDict.groupId ??
traceDict.group_id;
if (groupId !== undefined && groupId !== null) {
runExtra.metadata.thread_id = groupId;
}
try {
let newRun;
if (currentRunTree !== undefined) {
// Nest under existing trace
newRun = currentRunTree.createChild({
name: runName,
run_type: "chain",
inputs: {},
extra: runExtra,
tags: this._tags,
});
}
else {
// Create new root trace. Force `tracingEnabled: true` because
// installing this processor is itself an explicit opt-in to
// tracing; this ensures nested `traceable()` calls inside tools
// (which otherwise gate on LANGSMITH_TRACING) also post their
// runs. The setting propagates to children via createChild.
const runTreeConfig = {
name: runName,
run_type: "chain",
inputs: {},
extra: runExtra,
tags: this._tags,
client: this.client,
tracingEnabled: true,
};
if (this._projectName !== undefined) {
runTreeConfig.project_name = this._projectName;
}
newRun = new run_trees_js_1.RunTree(runTreeConfig);
}
// Delay posting until first response/generation span ends
// so inputs can be included in the POST.
this._unpostedTraces.add(trace.traceId);
this._runs.set(trace.traceId, newRun);
// Set this run as the current context so nested traceable() calls
// invoked from inside Agents tools nest under it. Remember the previous
// store so we can restore it in onTraceEnd.
const previousStore = enterRunTreeContext(newRun);
this._previousStoreByTrace.set(trace.traceId, previousStore);
}
catch (e) {
console.error("Error creating trace run:", e);
}
}
async onTraceEnd(trace) {
const run = this._runs.get(trace.traceId);
if (!run) {
return;
}
this._runs.delete(trace.traceId);
const traceDict = trace.toJSON() ?? {};
const metadata = {
...traceDict.metadata,
...this._metadata,
};
try {
// Update run with final inputs/outputs
run.outputs = this._lastResponseOutputs[trace.traceId] ?? {};
// Update metadata
if (!run.extra) {
run.extra = {};
}
if (!run.extra.metadata) {
run.extra.metadata = {};
}
run.extra.metadata = {
...run.extra.metadata,
...metadata,
};
// End and patch
await run.end();
if (this._unpostedTraces.has(trace.traceId)) {
// No response/generation spans ended, post now
run.inputs = this._firstResponseInputs[trace.traceId] ?? {};
this._unpostedTraces.delete(trace.traceId);
await run.postRun();
}
else {
await run.patchRun({ excludeInputs: true });
}
delete this._firstResponseInputs[trace.traceId];
delete this._lastResponseOutputs[trace.traceId];
}
catch (e) {
console.error("Error updating trace run:", e);
}
finally {
// Restore the previous AsyncLocalStorage store so contexts outside
// this trace are not polluted.
if (this._previousStoreByTrace.has(trace.traceId)) {
const previousStore = this._previousStoreByTrace.get(trace.traceId);
this._previousStoreByTrace.delete(trace.traceId);
enterRunTreeContext(previousStore);
}
}
}
async onSpanStart(span) {
// Find parent run
const parentId = span.parentId;
const parentRun = parentId
? this._runs.get(parentId)
: this._runs.get(span.traceId);
if (!parentRun) {
console.warn(`No trace info found for span, skipping: ${span.spanId}`);
return;
}
// Extract span data
let runName = getRunName(span);
const spanData = span.spanData;
if (spanData.type === "response") {
const parentName = parentRun.name;
const rawSpanName = runName;
if (parentName) {
runName = `${parentName} ${rawSpanName}`.trim();
}
else {
runName = rawSpanName;
}
}
const runType = getRunType(span);
const extracted = extractSpanData(span);
// Create child run and install it into AsyncLocalStorage SYNCHRONOUSLY,
// before any `await`. The OpenAI Agents runtime invokes `span.start()`
// (which calls this method without awaiting) right before it executes
// the tool/agent body in the same async task. Setting ALS via
// `enterWith` here ensures nested `traceable()` calls inside tool
// `execute` functions see this span's RunTree as their parent.
let childRun;
try {
childRun = parentRun.createChild({
name: runName,
run_type: runType,
inputs: extracted.inputs ?? {},
extra: extracted,
start_time: span.startedAt
? new Date(span.startedAt).getTime()
: undefined,
});
}
catch (e) {
console.error("Error creating span run:", e);
return;
}
// Add ls_agent_type metadata for agent spans that are children of
// function spans (i.e., agents used as tools).
// Handoff agents are not considered subagents.
if (spanData.type === "agent") {
const parentSpanType = parentId
? this._spanDataTypes.get(parentId)
: undefined;
if (parentSpanType === "function") {
if (!childRun.extra) {
childRun.extra = {};
}
if (!childRun.extra.metadata) {
childRun.extra.metadata = {};
}
childRun.extra.metadata = {
...childRun.extra.metadata,
ls_agent_type: "subagent",
};
}
}
// Track span data type for parent lookups
this._spanDataTypes.set(span.spanId, spanData.type);
this._runs.set(span.spanId, childRun);
// Enter AsyncLocalStorage context synchronously so nested traceable()
// calls inside the span's body nest under this run. Remember the
// previous store so we can restore it in onSpanEnd.
const previousStore = enterRunTreeContext(childRun);
this._previousStoreBySpan.set(span.spanId, previousStore);
try {
// Delay posting for spans whose complete inputs/outputs aren't
// available at start.
if (spanData.type === "generation" ||
spanData.type === "response" ||
spanData.type === "function" ||
spanData.type === "handoff") {
this._unpostedSpans.add(span.spanId);
}
else {
await childRun.postRun();
}
}
catch (e) {
console.error("Error posting span run:", e);
}
}
async onSpanEnd(span) {
// Restore the previous AsyncLocalStorage store synchronously so any
// further async work in the enclosing scope doesn't see this span's
// run as its parent. Done before any await to match span.end()
// which fires onSpanEnd without awaiting.
if (this._previousStoreBySpan.has(span.spanId)) {
const previousStore = this._previousStoreBySpan.get(span.spanId);
this._previousStoreBySpan.delete(span.spanId);
enterRunTreeContext(previousStore);
}
const run = this._runs.get(span.spanId);
this._spanDataTypes.delete(span.spanId);
if (!run) {
return;
}
this._runs.delete(span.spanId);
try {
// Extract outputs and metadata
const extracted = extractSpanData(span);
const outputs = extracted.outputs ?? {};
const inputs = extracted.inputs ?? {};
// Update run
run.outputs = outputs;
if (Object.keys(inputs).length > 0) {
run.inputs = inputs;
}
if (span.error) {
run.error = span.error.message;
}
if (span.spanData.type === "agent") {
const derived = deriveAgentInputsOutputs(run);
if (Object.keys(run.inputs ?? {}).length === 0 &&
derived.inputs &&
Object.keys(derived.inputs).length > 0) {
run.inputs = derived.inputs;
}
if (Object.keys(run.outputs ?? {}).length === 0 &&
derived.outputs &&
Object.keys(derived.outputs).length > 0) {
run.outputs = derived.outputs;
}
}
// Add OpenAI metadata
if (!run.extra) {
run.extra = {};
}
if (!run.extra.metadata) {
run.extra.metadata = {};
}
run.extra.metadata = {
...run.extra.metadata,
openai_parent_id: span.parentId ?? undefined,
openai_trace_id: span.traceId,
openai_span_id: span.spanId,
};
if (extracted.metadata) {
run.extra.metadata = {
...run.extra.metadata,
...extracted.metadata,
};
}
if (extracted.invocation_params) {
run.extra.invocation_params = extracted.invocation_params;
}
const spanData = span.spanData;
if (spanData.type === "response") {
this._firstResponseInputs[span.traceId] =
this._firstResponseInputs[span.traceId] ?? inputs;
this._lastResponseOutputs[span.traceId] = outputs;
await this._maybePostTrace(span.traceId, inputs);
}
else if (spanData.type === "generation") {
this._firstResponseInputs[span.traceId] =
this._firstResponseInputs[span.traceId] ?? inputs;
this._lastResponseOutputs[span.traceId] = outputs;
await this._maybePostTrace(span.traceId, inputs);
}
// End the run
if (span.endedAt) {
await run.end(undefined, undefined, new Date(span.endedAt).getTime());
}
else {
await run.end();
}
if (this._unpostedSpans.has(span.spanId)) {
this._unpostedSpans.delete(span.spanId);
await run.postRun();
}
else {
await run.patchRun(span.spanData.type === "agent" ? undefined : { excludeInputs: true });
}
}
catch (e) {
console.error("Error updating span run:", e);
}
}
async _maybePostTrace(traceId, inputs) {
if (this._unpostedTraces.has(traceId)) {
const traceRun = this._runs.get(traceId);
if (traceRun) {
traceRun.inputs = inputs;
try {
await traceRun.postRun();
}
catch (e) {
console.error("Error posting trace:", e);
}
this._unpostedTraces.delete(traceId);
}
}
}
async shutdown() {
await this.client.flush();
await this.client.awaitPendingTraceBatches();
}
async forceFlush() {
await this.client.flush();
await this.client.awaitPendingTraceBatches();
}
}
exports.OpenAIAgentsTracingProcessor = OpenAIAgentsTracingProcessor;