Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2a42838
fix(client): keep content helpers from throwing on a text part with n…
apucacao Oct 29, 2026
0d27576
feat(client)!: add configure() and capture content by default in the …
apucacao Oct 29, 2026
62f88f0
feat!: let every handler factory follow the configured content default
apucacao Oct 29, 2026
42ffeb1
fix(client)!: latch configure() on every handler call, not only SDK p…
apucacao Oct 29, 2026
2ac2878
fix(vercel-messages): latch configure() when vercelEvaluate calls the…
apucacao Oct 29, 2026
56811b4
refactor(client): export the capture resolver and invocation latch fo…
apucacao Oct 29, 2026
46c65d9
fix(client): keep an explicit captureContent override when a handler …
apucacao Oct 29, 2026
1ee1bca
test(client): cover the deferred runJudge explanation gate
apucacao Oct 29, 2026
5be56d0
test(client): check that inspectConfig does not latch configure
apucacao Oct 29, 2026
107959d
test: check that wrapper captureContent overrides configure and an om…
apucacao Oct 29, 2026
37aaae6
docs: add migration notes for the capture default and the new createH…
apucacao Oct 29, 2026
d000dc1
Merge main into ag/capture-content-default
apucacao Oct 29, 2026
48e924c
Merge branch 'main' of https://github.com/launchdarkly/js-ai-sdk into…
apucacao Oct 29, 2026
24c4ef3
refactor(client): resolve content capture through configure() only
apucacao Oct 29, 2026
7d7a7fe
test(client): cover a plain function handler's feature_flag value und…
apucacao Oct 29, 2026
e2bab1d
docs(client): say the AI Config variation goes out as feature_flag.re…
apucacao Oct 29, 2026
ede7625
docs(client): say feature_flag.result.value is on each span's feature…
apucacao Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ The handler package wires it to the provider SDK's built-in implementation and e

#### `ProviderHandler`

The callable data type that handler packages produce. Use `createHandler(providesFor, fn)` to construct one — it attaches `providesFor` and returns the function as a typed `ProviderHandler`. See [Handler Package Contract](#handler-package-contract) for full details.
The callable data type that handler packages produce. Use `createHandler(providesFor, fn)` to construct one — it returns a new function, a typed `ProviderHandler` that carries `providesFor` and calls yours. See [Handler Package Contract](#handler-package-contract) for full details.

---

Expand Down Expand Up @@ -473,7 +473,7 @@ const combined = compose(globalRegistry, localRegistry);

| Export | Description |
|---|---|
| `createHandler(providesFor, handler, options?)` | Attaches `providesFor` metadata to a handler function and returns it as a `ProviderHandler`. `options` is `{ stream?, captureContent?, providers? }`; a third argument that is not an object, or a fourth argument, throws a `TypeError`. This is the canonical way to build any handler — both package-internal factories and user-supplied custom handlers. See [Factory Function](#factory-function). |
| `createHandler(providesFor, handler, options?)` | Returns a new `ProviderHandler` that carries `providesFor` and calls `handler`; `handler` itself is not changed. `options` is `{ stream?, captureContent?, providers? }`; a third argument that is not an object, or a fourth argument, throws a `TypeError`. This is the canonical way to build any handler — both package-internal factories and user-supplied custom handlers. See [Factory Function](#factory-function). |
| `makeNodeTrackData(node, graphKey, runId)` | Builds the standard `TrackData` for a graph node event (including the `_ldMeta` model stamps). Native graph adapter packages must use this instead of building their own payload. |
| `parseTemplate(template, variables)` | Replaces `{{variable}}` placeholders in a string. Supports dot-notation for nested values (e.g. `{{user.name}}`). Unrecognized placeholders are left as-is. |
| `parseJSONWithPossibleFences(text)` | Parses a JSON string that may be wrapped in markdown code fences (` ```json ` or ` ``` `). Returns `null` if the text is not valid JSON. |
Expand Down Expand Up @@ -518,7 +518,7 @@ Each handler package must export a **factory function** that:

The naming convention is `create<Provider>Handler()`. For example: `createClaudeAgentsHandler()`, `createOpenAIAgentsHandler()`.

**Always use `createHandler` to build and return the handler.** It attaches `providesFor` in place and returns the same function reference as a properly typed `ProviderHandler`. Do not manually assign `handler.providesFor` — `createHandler` is the single canonical path.
**Always use `createHandler` to build and return the handler.** It returns a new function, a properly typed `ProviderHandler` that carries `providesFor` and calls yours. The function you pass in is not changed, so always use the returned handler. Passing a returned handler through `createHandler` again keeps its `stream`, explicit `captureContent` and `providers` unless the new options replace them. Do not manually assign `handler.providesFor` — `createHandler` is the single canonical path.

```ts
import { createHandler, parseTemplate } from '@launchdarkly/ai-server';
Expand Down Expand Up @@ -612,9 +612,13 @@ two names for one concept. Don't reintroduce it: a reader filtering on
`launchdarkly.` should see everything this SDK owns and nothing should hide
elsewhere.

**Content attributes** — only when the caller passes `captureContent: true`.
Conversation content is PII, so it is off by default and every write goes
through `client/src/content.ts`, which takes the flag as an argument:
**Content attributes** — written unless capture is off for the run. Each handler
resolves its effective `captureContent` when it runs: an explicit `true` or
`false` from the factory (or convenience wrapper), otherwise the process default
from `configure({ options })`, otherwise `true`. Factories leave the option unset
by default and read `handler.captureContent` at the start of each run, so a
`configure()` after construction applies. Every write goes through
`client/src/content.ts`, which takes the resolved flag as an argument:
- `gen_ai.system_instructions` — the system prompt, as a JSON part array
- `gen_ai.input.messages` / `gen_ai.output.messages` — the canonical
`[{ role, parts }]` JSON from the OTel GenAI semconv
Expand Down Expand Up @@ -658,12 +662,12 @@ a number, no conversation content — and the same two keys are mirrored as span
attributes. It is defined by the GenAI semantic conventions and read by the
conversation view's turn badges, so do not "fix" it by deleting it.

The judge's reasoning is deliberately **not** on the span or the event.
`gen_ai.evaluation.explanation` is model-generated prose about the user's
conversation, which makes it a content attribute under the rule above, and
`captureContent` is a handler-factory option the client core never receives.
The reasoning still reaches the caller in `judgeResults`; only the telemetry
copy is withheld. Adding it back needs its own opt-in, not a quiet write.
The judge's reasoning, `gen_ai.evaluation.explanation`, is model-generated
prose about the user's conversation, which makes it a content attribute under
the rule above. It is written on the span and the event only when the judge
handler's effective capture flag is true (`handler.captureContent`, or the
process default for a plain function). The reasoning always reaches the caller
in `judgeResults`.

**Span status:**
- Set to OK on success.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ console.log(result.response);

`config()` accepts either a single handler or an array of handlers and routes to the correct one at invoke-time based on the flag variation's provider and mode. The handler can be one of the pre-built `create*Handler()` factories, an array of them, or any function you write — useful for internal endpoints or providers without a pre-built package.

Use `createHandler(providesFor, fn)` to build a custom handler. It attaches routing metadata (`providesFor`) so the handler works with `config()` routing and registries.
Use `createHandler(providesFor, fn)` to build a custom handler. It returns a new handler that carries routing metadata (`providesFor`), so it works with `config()` routing and registries. Use the returned handler, not the function you passed in.


| Argument | Type | Required | Description |
Expand Down
2 changes: 2 additions & 0 deletions api-surface/ai-server.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ createHandler usage=client.createHandler
+ createSkillSource
+ Criterion
+ DatasetRow
- effectiveCaptureContent # internal: plumbing for the handler packages; moves to the internal entry point
- endSpanOnce
+ EvalRunResult
+ EvalTool
Expand Down Expand Up @@ -110,6 +111,7 @@ MessageContent
- NATIVE_TOOL_KEY
NativeTool
- normalizeModelParameters
- noteAiInvocation # internal: plumbing for the handler packages; moves to the internal entry point
- omitModelStamps
+ OnUnavailable
- parseJSONWithPossibleFences
Expand Down
9 changes: 5 additions & 4 deletions examples/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
* the same id: root, `chat`, `execute_tool`, and the judge's own `invoke_agent`.
* 2. Each turn shows a score badge, sourced from the `gen_ai.evaluation.result` span event on
* the judge span.
* 3. No judge reasoning anywhere in the telemetry. The score and the judge's config key are
* exported; the explanation is not, because it is model prose about the user's conversation
* and content attributes require `captureContent`. The reasoning IS printed below, straight
* from `judgeResults` — that is the caller's copy, and it is unaffected.
* 3. The judge's reasoning follows the content gate. The score and the judge's config key are
* always exported; the explanation (`gen_ai.evaluation.explanation`) is model prose about the
* user's conversation, so it is exported only while content capture is on, which is the
* default. The reasoning IS printed below, straight from `judgeResults`: that is the caller's
* copy, and capture does not affect it.
*
* The flag key must point at an AI Config with a `judgeConfiguration`, otherwise there are no
* judge turns to look at.
Expand Down
2 changes: 1 addition & 1 deletion packages/claude-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ await shutdown();

| Option | Default | Description |
|---|---|---|
| `captureContent` | `false` | Put prompts, model output, and tool arguments and results on the emitted spans. |
| `captureContent` | unset (follows `configure()`, which defaults to `true`) | Put prompts, model output, and tool arguments and results on the emitted spans. Pass `false` to keep this handler to metadata only. |
| `cwd` | `process.cwd()` | Working directory for the Claude Code process the Agent SDK starts. |
| `settingSources` | `[]` (none) | Which Claude Code settings sources to load: `'user'` (`~/.claude/settings.json`), `'project'` (`.claude/settings.json`, and `CLAUDE.md`), `'local'` (`.claude/settings.local.json`). |

Expand Down
5 changes: 3 additions & 2 deletions packages/claude-agents/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,9 @@ Root-span attributes:
- that call's own usage — not the run's, which would multiply the reported cost by
the number of turns

Conversation content, only when the handler is built with `captureContent: true`
(off by default — content is PII): `gen_ai.system_instructions`,
Conversation content, written unless capture is off for the run (the handler's
explicit `captureContent`, otherwise the `configure()` default, which is `true`):
`gen_ai.system_instructions`,
`gen_ai.input.messages`, `gen_ai.output.messages` and `gen_ai.tool.definitions`
as canonical JSON, mirrored into the flat `gen_ai.prompt.{i}.*` /
`gen_ai.completion.{i}.*` attributes the LaunchDarkly trace view reads today. The
Expand Down
74 changes: 64 additions & 10 deletions packages/claude-agents/src/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { propagation, SpanStatusCode, type TextMapPropagator } from '@opentelemetry/api';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// ─── Mocks ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -54,7 +54,14 @@ vi.mock('@launchdarkly/ai-server', async (importOriginal) => {
};
});

import { type Message, NATIVE_TOOL_KEY, NativeTool } from '@launchdarkly/ai-server';
import {
configure,
type Message,
NATIVE_TOOL_KEY,
NativeTool,
type ProviderHandler,
shutdown,
} from '@launchdarkly/ai-server';
import { buildPrompt, buildToolMCP, createClaudeAgentsHandler, partitionTools } from '../handler.js';
import { expectNoNeverForwardedValue, NEVER_FORWARDED_PARAMETERS } from './never-forwarded.js';

Expand Down Expand Up @@ -801,15 +808,62 @@ describe('createClaudeAgentsHandler', () => {
expect(toolSpan?.end).toHaveBeenCalledOnce();
});

it('emits no content at all by default', async () => {
mockQuery.mockImplementation(makeResultMessage('done'));
await createClaudeAgentsHandler()(baseConfig as any, 'question');
describe('content capture resolves at run time', () => {
// `configure` is process-wide; shutdown() restores the built-in default and clears its lock.
afterEach(() => shutdown());

const run = async (handler: ProviderHandler = createClaudeAgentsHandler()) => {
mockQuery.mockImplementation(makeResultMessage('done'));
await handler(baseConfig as any, 'question');
return JSON.stringify(mockSpan.setAttribute.mock.calls);
};

it('captures content when nothing sets it', async () => {
expect(await run()).toContain('question');
});

it('captures nothing with an explicit false', async () => {
expect(await run(createClaudeAgentsHandler({ captureContent: false }))).not.toContain('question');
});

it('captures nothing when configure turns the default off', async () => {
configure({ options: { captureContent: false } });
expect(await run()).not.toContain('question');
});

it('reads configure when the handler runs, not when it is built', async () => {
const handler = createClaudeAgentsHandler();
configure({ options: { captureContent: false } });
expect(await run(handler)).not.toContain('question');
});

it('captures content with an explicit true when configure turns the default off', async () => {
configure({ options: { captureContent: false } });
expect(await run(createClaudeAgentsHandler({ captureContent: true }))).toContain('question');
});

/** The handler `claudeAgents` built for its `config()` call. */
const wrapperHandler = async (options: { captureContent?: boolean }): Promise<ProviderHandler> => {
const { claudeAgents } = await import('../handler.js');
const { configInternal } = await import('@launchdarkly/ai-server');
await claudeAgents('flag', 'hello', { kind: 'user', key: 'u' }, options);
return vi.mocked(configInternal).mock.calls.at(-1)?.[0].handler as ProviderHandler;
};

// Conversation content is PII, so a caller has to ask for it. Asserting on the recorded
// arguments rather than on specific keys catches a new content attribute added without a gate.
const written = JSON.stringify(mockSpan.setAttribute.mock.calls);
expect(written).not.toContain('question');
expect(mockSpan.addEvent).not.toHaveBeenCalledWith('gen_ai.content.prompt', expect.anything());
it('lets an explicit false on claudeAgents beat a configured true', async () => {
configure({ options: { captureContent: true } });
const handler = await wrapperHandler({ captureContent: false });
expect(handler.captureContent).toBe(false);
expect(await run(handler)).not.toContain('question');
});

it('forwards an omitted claudeAgents option as unset, so it follows configure', async () => {
const handler = await wrapperHandler({});
expect(handler.captureContent).toBe(true);
configure({ options: { captureContent: false } });
expect(handler.captureContent).toBe(false);
expect(await run(handler)).not.toContain('question');
});
});

it('puts prompt and completion on span attributes when capture is enabled', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/claude-agents/src/__tests__/spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ describe('claude-agents span tree against a real tracer', () => {
yield resultMessage('bye');
});

await createClaudeAgentsHandler()(toolConfig as never, 'q');
await createClaudeAgentsHandler({ captureContent: false })(toolConfig as never, 'q');

for (const span of [root(), ...named('chat')]) {
expect(span?.attributes['gen_ai.input.messages']).toBeUndefined();
Expand Down
8 changes: 6 additions & 2 deletions packages/claude-agents/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,13 +1039,13 @@ export function createClaudeAgentsHandler(

/** `createClaudeAgentsHandler` without the `$ld:ai:sdk:usage` report. Package-internal; not exported from the package index. */
export function createClaudeAgentsHandlerInternal(
{ captureContent = false, ...host }: ContentCaptureOptions & ClaudeAgentsHostOptions = {},
{ captureContent, ...host }: ContentCaptureOptions & ClaudeAgentsHostOptions = {},
/** Package-internal hooks; never part of the public factory's options. */
{ prepare }: { prepare?: PrepareQuery } = {},
): ProviderHandler {
// Trusted, so spread with the handler's own options, after `model.parameters`.
const hostOptions = hostQueryOptions(host);
return createHandlerInternal(
const handler = createHandlerInternal(
['Anthropic', 'agent'],
async (
config: AiConfigRep,
Expand All @@ -1054,6 +1054,8 @@ export function createClaudeAgentsHandlerInternal(
variables: Record<string, unknown> = {},
history?: Message[],
) => {
// Resolved per run, not per factory call, so a `configure()` after construction applies.
const captureContent = handler.captureContent === true;
return trace.getTracer('@launchdarkly/ai-claude-agents').startActiveSpan('invoke_agent', async (span) => {
span.setAttribute('gen_ai.operation.name', 'invoke_agent');
setModelIdentityAttributes(span, 'anthropic', config.model.name);
Expand Down Expand Up @@ -1190,6 +1192,7 @@ export function createClaudeAgentsHandlerInternal(
variables: Record<string, unknown> = {},
history?: Message[],
) {
const captureContent = handler.captureContent === true;
const span = trace.getTracer('@launchdarkly/ai-claude-agents').startSpan('invoke_agent');
span.setAttribute('gen_ai.operation.name', 'invoke_agent');
setModelIdentityAttributes(span, 'anthropic', config.model.name);
Expand Down Expand Up @@ -1340,6 +1343,7 @@ export function createClaudeAgentsHandlerInternal(
},
captureContent,
);
return handler;
}

export const claudeAgents = (
Expand Down
5 changes: 3 additions & 2 deletions packages/claude-messages/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@ Span attributes set before the call:
- `gen_ai.system` = `'anthropic'`
- `gen_ai.request.model` = `config.model.name`

Conversation content, only when the handler is built with `captureContent: true`
(off by default — content is PII): `gen_ai.system_instructions`,
Conversation content, written unless capture is off for the run (the handler's
explicit `captureContent`, otherwise the `configure()` default, which is `true`):
`gen_ai.system_instructions`,
`gen_ai.input.messages`, `gen_ai.output.messages` and `gen_ai.tool.definitions`
as canonical JSON, mirrored into the flat `gen_ai.prompt.{i}.*` /
`gen_ai.completion.{i}.*` attributes the LaunchDarkly trace view reads today.
Expand Down
12 changes: 10 additions & 2 deletions packages/claude-messages/src/__tests__/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,11 +458,19 @@ describe('createClaudeMessagesHandler', () => {
expect(modelSpan?.setAttribute).toHaveBeenCalledWith('gen_ai.usage.total_tokens', 15);
});

it('emits no content at all by default', async () => {
it('emits content by default', async () => {
mockMessagesCreate.mockResolvedValue(mockFinalResponse('answer'));
await createClaudeMessagesHandler()(baseConfig as any, 'question');

// Conversation content is PII, so a caller has to ask for it. Asserting on the recorded
expect(mockSpan.setAttribute).toHaveBeenCalledWith('gen_ai.prompt.1.content', 'question');
expect(mockSpan.setAttribute).toHaveBeenCalledWith('gen_ai.completion.0.content', 'answer');
});

it('emits no content at all with captureContent: false', async () => {
mockMessagesCreate.mockResolvedValue(mockFinalResponse('answer'));
await createClaudeMessagesHandler({ captureContent: false })(baseConfig as any, 'question');

// An explicit false keeps the run to metadata. Asserting on the recorded
// arguments rather than on specific keys catches a new content attribute added without a gate.
const written = JSON.stringify(mockSpan.setAttribute.mock.calls);
expect(written).not.toContain('question');
Expand Down
Loading
Loading