Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions src/langfuseToolOutputTracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,37 @@ function toolNameMatches(
/** Whether a tool's outputs are excluded from tracing (global disable or
* `redactedToolNames` match). Exported for the activity-label prompt
* builder, whose prompt becomes Langfuse generation input. */
/**
* Whether `candidate` redacts at least every tool output `required` redacts:
* text produced under `candidate` cannot carry output `required` hides.
*/
export function coversToolOutputRedaction(
candidate: ResolvedLangfuseToolOutputTracingConfig | undefined,
required: ResolvedLangfuseToolOutputTracingConfig
): boolean {
if (candidate == null) {
return false;
}
if (candidate.enabled === false) {
return true;
}
if (required.enabled === false) {
return false;
}
if (
required.redactedToolNameMatchMode === 'partial' &&
candidate.redactedToolNameMatchMode !== 'partial'
) {
return false;
}
for (const toolName of required.redactedToolNames) {
if (!candidate.redactedToolNames.has(toolName)) {
return false;
Comment on lines +183 to +185

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare partial patterns by coverage, not equality

With partial matching, set membership is not equivalent to redaction coverage: a candidate pattern such as run_ redacts every tool matched by a required pattern run_select_query, but this check returns false because the longer string is not literally in the candidate set. This occurs, for example, when the phase destination has the narrower partial rule and the contributing agent has the broader one; childLabelsRedacted remains false and a labels-only phase still returns {} despite the labels having been generated under a strictly stronger policy.

Useful? React with 👍 / 👎.

}
}
return true;
}

export function shouldRedactTool(
toolName: string | undefined,
config: ResolvedLangfuseToolOutputTracingConfig
Expand Down
9 changes: 8 additions & 1 deletion src/prompts/activityLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ export type BuildActivityPhaseLabelPromptParams = {
charLimit: number;
assistantContext?: string[];
redaction?: ResolvedLangfuseToolOutputTracingConfig;
/**
* Every child label was generated under a policy covering `redaction`, so
* labels stay usable while reasoning and commentary remain suppressed.
*/
childLabelsRedacted?: boolean;
};

/**
Expand All @@ -290,10 +295,12 @@ export function buildActivityPhaseLabelPrompt({
charLimit,
assistantContext,
redaction,
childLabelsRedacted = false,
}: BuildActivityPhaseLabelPromptParams): string {
const freeFormSuppressed =
redaction != null &&
(redaction.enabled === false || redaction.redactedToolNames.size > 0);
const labelsAllowed = !freeFormSuppressed || childLabelsRedacted;
const sections: string[] = [];
if (
!freeFormSuppressed &&
Expand Down Expand Up @@ -325,7 +332,7 @@ export function buildActivityPhaseLabelPrompt({
status = 'partial';
}
if (
!freeFormSuppressed &&
labelsAllowed &&
activity.label != null &&
activity.label.trim() !== ''
) {
Expand Down
16 changes: 16 additions & 0 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
resolveLangfuseConfig,
resolveToolOutputTracingConfig,
} from '@/langfuseConfig';
import { coversToolOutputRedaction } from '@/langfuseToolOutputTracing';
import {
cloneToolApprovalInterruptPayload,
TOOL_APPROVAL_REVIEW_CONFIG_KEY,
Expand Down Expand Up @@ -3179,13 +3180,28 @@ export class Run<_T extends t.BaseGraphState> {
redactionText: redaction.redactionText,
};
}
/** Child labels were generated from each agent's own redacted evidence,
* so they can stand in for that evidence only when every agent's policy
* covers the merged phase policy. */
const childLabelsRedacted =
redaction != null &&
redactionContexts.length > 0 &&
redactionContexts.every((context) =>
coversToolOutputRedaction(
hasToolOutputTracingConfig(this.langfuse, context.langfuse)
? resolveToolOutputTracingConfig(this.langfuse, context.langfuse)
: undefined,
redaction
)
Comment on lines +3186 to +3195

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify each label's redaction provenance before reuse

childLabelsRedacted is inferred only from the agents' current configurations, but ActivityPhaseEntry.label is a host-supplied string with no guarantee or metadata proving it was produced by generateActivityLabel under that policy. When a host supplies a legacy, manual, or otherwise unredacted committed label and all contributing agents share the active policy, this becomes true and the label—including any copied sensitive tool output—is sent to the phase model and recorded as generation input. Require per-label redaction provenance rather than treating matching agent configuration as proof.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

);

const userPrompt = buildActivityPhaseLabelPrompt({
activities,
totalActivityCount,
charLimit,
assistantContext,
redaction,
childLabelsRedacted,
});
if (userPrompt === '') {
return {};
Expand Down
112 changes: 111 additions & 1 deletion src/specs/activity-label-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { ActivityLabelToolEntry } from '@/types/activityLabel';
import type { LangfuseToolOutputTracingConfig } from '@/types/graph';
import {
ACTIVITY_PHASE_PROMPT_MAX_LENGTH,
buildActivityLabelPrompt,
buildActivityPhaseLabelPrompt,
normalizeActivityPhaseLabel,
} from '@/prompts/activityLabel';
import { LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT } from '@/langfuseToolOutputTracing';
import {
coversToolOutputRedaction,
LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT,
} from '@/langfuseToolOutputTracing';
import { resolveToolOutputTracingConfig } from '@/langfuseConfig';

const entries: ActivityLabelToolEntry[] = [
Expand Down Expand Up @@ -356,4 +360,110 @@ describe('buildActivityPhaseLabelPrompt', () => {
);
expect(normalizeActivityPhaseLabel('x'.repeat(300))).toHaveLength(160);
});

it('keeps committed labels but not reasoning or commentary when child labels are redacted', () => {
const redaction = resolveToolOutputTracingConfig({
toolOutputTracing: { redactedToolNames: ['db_query'] },
});
const prompt = buildActivityPhaseLabelPrompt({
activities: [
{ label: 'Counted open incidents' },
{
label: 'Grouped incidents by service',
thinkingExcerpts: ['REASONING_MAY_QUOTE_REDACTED_OUTPUT'],
},
],
charLimit: 600,
assistantContext: ['COMMENTARY_MAY_QUOTE_REDACTED_OUTPUT'],
redaction,
childLabelsRedacted: true,
});
expect(prompt).toContain('Counted open incidents');
expect(prompt).toContain('Grouped incidents by service');
expect(prompt).not.toContain('REASONING_MAY_QUOTE_REDACTED_OUTPUT');
expect(prompt).not.toContain('COMMENTARY_MAY_QUOTE_REDACTED_OUTPUT');
});

it('drops committed labels under an active policy unless child labels are redacted', () => {
const redaction = resolveToolOutputTracingConfig({
toolOutputTracing: { redactedToolNames: ['db_query'] },
});
expect(
buildActivityPhaseLabelPrompt({
activities: [
{ label: 'Counted open incidents' },
{ label: 'Grouped by service' },
],
charLimit: 600,
redaction,
})
).toBe('');
});
});

describe('coversToolOutputRedaction', () => {
const policy = (toolOutputTracing: LangfuseToolOutputTracingConfig) =>
resolveToolOutputTracingConfig({ toolOutputTracing });

it('treats a missing candidate policy as not covering', () => {
expect(
coversToolOutputRedaction(undefined, policy({ redactedToolNames: ['a'] }))
).toBe(false);
});

it('covers when the candidate redacts every tool output', () => {
expect(
coversToolOutputRedaction(
policy({ enabled: false }),
policy({ redactedToolNames: ['a'] })
)
).toBe(true);
});

it('does not cover a globally disabled requirement with a named list', () => {
expect(
coversToolOutputRedaction(
policy({ redactedToolNames: ['a'] }),
policy({ enabled: false })
)
).toBe(false);
});

it('requires every required tool name', () => {
const required = policy({ redactedToolNames: ['a', 'b'] });
expect(
coversToolOutputRedaction(
policy({ redactedToolNames: ['a', 'b', 'c'] }),
required
)
).toBe(true);
expect(
coversToolOutputRedaction(policy({ redactedToolNames: ['a'] }), required)
).toBe(false);
});

it('does not let exact matching cover a partial requirement', () => {
const required = policy({
redactedToolNames: ['a'],
redactedToolNameMatchMode: 'partial',
});
expect(
coversToolOutputRedaction(
policy({
redactedToolNames: ['a'],
redactedToolNameMatchMode: 'exact',
}),
required
)
).toBe(false);
expect(
coversToolOutputRedaction(
policy({
redactedToolNames: ['a'],
redactedToolNameMatchMode: 'partial',
}),
required
)
).toBe(true);
});
});
82 changes: 82 additions & 0 deletions src/specs/activity-phase-label.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,4 +303,86 @@ describe('generateActivityPhaseLabel', () => {
expect(String(messages[1].content)).not.toContain('OMITTED_AGENT_SECRET');
expect(String(messages[1].content)).toContain('public-one');
});

it('keeps child labels when every agent shares the redaction policy', async () => {
const run = await Run.create({
runId: 'uniform-policy-phase-run',
graphConfig: {
type: 'standard',
agents: [
{
agentId: 'agent-1',
provider: Providers.OPENAI,
clientOptions: { model: 'gpt-4.1-mini' },
tools: [],
},
],
},
langfuse: {
toolOutputTracing: {
redactedToolNames: ['run_select_query'],
redactedToolNameMatchMode: 'partial',
},
},
});

await expect(
run.generateActivityPhaseLabel({
provider: Providers.OPENAI,
activities: [
{ agentId: 'agent-1', label: 'Listed the orders tables' },
{ agentId: 'agent-1', label: 'Measured daily order volume' },
],
assistantContext: ['COMMENTARY_MAY_QUOTE_REDACTED_OUTPUT'],
})
).resolves.toEqual({
label: 'Fixed session refresh handling and verified auth tests',
});

const prompt = String((invoke.mock.calls[0][0] as AIMessage[])[1].content);
expect(prompt).toContain('Listed the orders tables');
expect(prompt).toContain('Measured daily order volume');
expect(prompt).not.toContain('COMMENTARY_MAY_QUOTE_REDACTED_OUTPUT');
});

it('suppresses child labels when a contributing agent has a weaker policy', async () => {
const run = await Run.create({
runId: 'weaker-overlay-phase-run',
graphConfig: {
type: 'multi-agent',
agents: [
{
agentId: 'agent-1',
provider: Providers.OPENAI,
clientOptions: { model: 'gpt-4.1-mini' },
tools: [],
},
{
agentId: 'agent-2',
provider: Providers.OPENAI,
clientOptions: { model: 'gpt-4.1-mini' },
tools: [],
langfuse: {
toolOutputTracing: { redactedToolNames: ['secret_tool'] },
},
},
],
edges: [],
},
});

await expect(
run.generateActivityPhaseLabel({
provider: Providers.OPENAI,
activities: [
{
agentId: 'agent-1',
label: 'Read WEAKER_AGENT_SECRET from secret_tool',
},
{ agentId: 'agent-2', label: 'Checked the strict agent state' },
],
})
).resolves.toEqual({});
expect(invoke).not.toHaveBeenCalled();
});
});