Skip to content

Commit a1fa913

Browse files
feat(go): add ContentCaptureMode for SDK-level content stripping (#21)
Callers now have a way to prevent text from leaving the process via the export pipeline or OTel attributes. `MetadataOnly` mode strips all text content at the SDK level while preserving message structure, tool names, usage, and timing. The existing `IncludeContent` bool on `ToolExecutionStart` only controlled span attributes for individual tools. `ContentCapture` supersedes it with a mode that applies to the entire generation and propagates to child tool executions via context. `IncludeContent` continues to work for backward compatibility under the default `NoToolContent` mode. ### Modes | Mode | Generations | Tool spans | |------|------------|------------| | `Full` | All content exported | Arguments and results in span attributes | | `NoToolContent` (SDK default, same as before this PR) | All content exported | Excluded | | `MetadataOnly` | Structure preserved, text stripped | Arguments and results excluded | ### Example ```go // Client-level default client := sigil.NewClient(sigil.Config{ ContentCapture: sigil.ContentCaptureModeMetadataOnly, }) // Per-generation override ctx, rec := client.StartGeneration(ctx, sigil.GenerationStart{ ContentCapture: sigil.ContentCaptureModeFull, }) // Tool executions inherit from parent generation _, toolRec := client.StartToolExecution(ctx, sigil.ToolExecutionStart{ ToolName: "search", }) ``` --- ### Dynamic resolution via ContentCaptureResolver A callback on `sigil.Config` that resolves the capture mode per-recording at runtime. Useful for feature flags, per-tenant policies, or context-dependent decisions. ```go client := sigil.NewClient(sigil.Config{ ContentCaptureResolver: func(ctx context.Context, metadata map[string]any) sigil.ContentCaptureMode { if shouldStripContent(ctx, metadata) { return sigil.ContentCaptureModeMetadataOnly } return sigil.ContentCaptureModeDefault // fall through to Config.ContentCapture }, }) ``` Resolution precedence (highest -> lowest): 1. Per-recording `ContentCapture` field (on `GenerationStart` / `ToolExecutionStart`) 2. `ContentCaptureResolver` return value 3. `Config.ContentCapture` (client default, resolves to `NoToolContent` when unset to preserve old behaviour) Panics in the resolver are recovered and treated as `MetadataOnly`. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes what generation payloads and tool span attributes can contain (including new MetadataOnly stripping), which can affect downstream ingestion/observability and validation behavior. Defaults aim to preserve prior behavior, but misconfiguration could unintentionally drop content or export more than intended. > > **Overview** > Introduces `ContentCaptureMode` with client defaults (`Config.ContentCapture`), per-generation (`GenerationStart.ContentCapture`) and per-tool (`ToolExecutionStart.ContentCapture`) overrides, plus a `Config.ContentCaptureResolver` callback (panic-safe, fail-closed) to resolve capture policy per request. > > When `MetadataOnly` is effective, generations are stamped with `sigil.sdk.content_capture_mode` and have prompts/messages/tool I/O/artifacts stripped before enqueue/export, while preserving structure and operational fields; tool executions also inherit the parent generation’s mode via context and only include arguments/results in span attributes when allowed (keeping `IncludeContent` as deprecated backward-compat opt-in under the default behavior). > > Extends validation to accept stripped text/thinking parts, updates rating submission to drop `Comment` under `MetadataOnly`, and adds/updates docs, examples, and tests to cover mode resolution, inheritance, and stripping behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ed684ca. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a6d244e commit a1fa913

File tree

13 files changed

+1074
-68
lines changed

13 files changed

+1074
-68
lines changed

go/sigil/client.go

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ type Config struct {
2626
GenerationExport GenerationExportConfig
2727
API APIConfig
2828
EmbeddingCapture EmbeddingCaptureConfig
29+
// ContentCapture controls the default content capture mode for all
30+
// generations and tool executions. Per-recording overrides take precedence.
31+
ContentCapture ContentCaptureMode
32+
// ContentCaptureResolver, when set, is called before each generation,
33+
// tool execution, and rating submission to dynamically resolve the content
34+
// capture mode. It receives the request context and the recording's
35+
// metadata (nil when the recording type has no metadata, e.g. tool
36+
// executions).
37+
//
38+
// Resolution precedence (highest → lowest):
39+
// 1. Per-recording ContentCapture field (explicit override)
40+
// 2. ContentCaptureResolver return value
41+
// 3. Config.ContentCapture (static default)
42+
//
43+
// Returning ContentCaptureModeDefault defers to Config.ContentCapture.
44+
// Panics are recovered and treated as ContentCaptureModeMetadataOnly
45+
// (fail-closed).
46+
ContentCaptureResolver func(ctx context.Context, metadata map[string]any) ContentCaptureMode
2947
// Tracer is optional and mainly used for tests. If nil, the client uses the global OpenTelemetry tracer.
3048
Tracer trace.Tracer
3149
// Meter is optional and mainly used for tests. If nil, the client uses the global OpenTelemetry meter.
@@ -226,11 +244,12 @@ type Client struct {
226244
//
227245
// All methods are safe to call on a nil or no-op recorder.
228246
type GenerationRecorder struct {
229-
client *Client
230-
ctx context.Context
231-
span trace.Span
232-
seed GenerationStart
233-
startedAt time.Time
247+
client *Client
248+
ctx context.Context
249+
span trace.Span
250+
seed GenerationStart
251+
startedAt time.Time
252+
contentCaptureMode ContentCaptureMode
234253

235254
mu sync.Mutex
236255
ended bool
@@ -455,12 +474,19 @@ func (c *Client) startGeneration(ctx context.Context, start GenerationStart, def
455474
ThinkingEnabled: cloneBoolPtr(seed.ThinkingEnabled),
456475
})...)
457476

477+
// Resolve content capture mode: per-recording > resolver > client default.
478+
resolverMode := callContentCaptureResolver(c.config.ContentCaptureResolver, ctx, seed.Metadata)
479+
clientMode := resolveClientContentCaptureMode(resolveContentCaptureMode(resolverMode, c.config.ContentCapture))
480+
ccMode := resolveContentCaptureMode(seed.ContentCapture, clientMode)
481+
callCtx = withContentCaptureMode(callCtx, ccMode)
482+
458483
return callCtx, &GenerationRecorder{
459-
client: c,
460-
ctx: callCtx,
461-
span: span,
462-
seed: seed,
463-
startedAt: startedAt,
484+
client: c,
485+
ctx: callCtx,
486+
span: span,
487+
seed: seed,
488+
startedAt: startedAt,
489+
contentCaptureMode: ccMode,
464490
}
465491
}
466492

@@ -575,13 +601,19 @@ func (c *Client) StartToolExecution(ctx context.Context, start ToolExecutionStar
575601
attrs := toolSpanAttributes(seed)
576602
span.SetAttributes(attrs...)
577603

604+
// Resolve content capture: per-tool > context (parent generation) > resolver > client default.
605+
resolverMode := callContentCaptureResolver(c.config.ContentCaptureResolver, ctx, nil)
606+
effectiveClientDefault := resolveContentCaptureMode(resolverMode, c.config.ContentCapture)
607+
ctxMode, ctxSet := contentCaptureModeFromContext(ctx)
608+
includeContent := shouldIncludeToolContent(seed.ContentCapture, ctxMode, ctxSet, effectiveClientDefault, seed.IncludeContent)
609+
578610
return callCtx, &ToolExecutionRecorder{
579611
client: c,
580612
ctx: callCtx,
581613
span: span,
582614
seed: seed,
583615
startedAt: startedAt,
584-
includeContent: seed.IncludeContent,
616+
includeContent: includeContent,
585617
}
586618
}
587619

@@ -661,14 +693,19 @@ func (r *GenerationRecorder) End() {
661693
normalized := r.normalizeGeneration(generation, completedAt, callErr)
662694
applyTraceContextFromSpan(r.span, &normalized)
663695

696+
stampContentCaptureMetadata(&normalized, r.contentCaptureMode)
697+
if r.contentCaptureMode == ContentCaptureModeMetadataOnly {
698+
stripContent(&normalized, classifyErrorCategory(callErr, false))
699+
}
700+
664701
r.span.SetName(generationSpanName(normalized))
665702
r.span.SetAttributes(generationSpanAttributes(normalized)...)
666703

667704
r.mu.Lock()
668705
r.lastGeneration = cloneGeneration(normalized)
669706
r.mu.Unlock()
670707

671-
enqueueErr := r.client.persistGeneration(r.ctx, normalized)
708+
enqueueErr := r.client.persistGeneration(normalized)
672709

673710
// Record errors on span.
674711
if callErr != nil {
@@ -1078,11 +1115,10 @@ func combineAllErrors(errs ...error) error {
10781115
return errors.Join(filtered...)
10791116
}
10801117

1081-
func (c *Client) persistGeneration(_ context.Context, generation Generation) error {
1082-
if err := ValidateGeneration(generation); err != nil {
1118+
func (c *Client) persistGeneration(generation Generation) error {
1119+
if err := validateGeneration(generation); err != nil {
10831120
return fmt.Errorf("%w: %v", errGenerationValidation, err)
10841121
}
1085-
10861122
if err := c.enqueueGeneration(generation); err != nil {
10871123
return fmt.Errorf("%w: %w", errGenerationEnqueue, err)
10881124
}

go/sigil/client_test.go

Lines changed: 41 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,59 +1086,57 @@ func TestStartToolExecutionSetsExecuteToolAttributes(t *testing.T) {
10861086
}
10871087

10881088
func TestToolExecutionRecorderContentCapture(t *testing.T) {
1089+
// Backward compat: Config{} with IncludeContent controls tool content.
10891090
client, recorder, _ := newTestClient(t, Config{})
10901091

1091-
_, withContent := client.StartToolExecution(context.Background(), ToolExecutionStart{
1092-
ToolName: "weather",
1093-
IncludeContent: true,
1094-
})
1095-
withContent.SetResult(ToolExecutionEnd{
1096-
Arguments: map[string]any{"city": "Paris"},
1097-
Result: map[string]any{"temp_c": 18},
1098-
})
1099-
withContent.End()
1100-
if err := withContent.Err(); err != nil {
1101-
t.Fatalf("end tool execution with content: %v", err)
1102-
}
1103-
1104-
_, withoutContent := client.StartToolExecution(context.Background(), ToolExecutionStart{
1105-
ToolName: "weather",
1106-
})
1107-
withoutContent.SetResult(ToolExecutionEnd{
1108-
Arguments: map[string]any{"city": "Paris"},
1109-
Result: map[string]any{"temp_c": 18},
1110-
})
1111-
withoutContent.End()
1112-
if err := withoutContent.Err(); err != nil {
1113-
t.Fatalf("end tool execution without content: %v", err)
1114-
}
1115-
1116-
toolSpans := make([]sdktrace.ReadOnlySpan, 0, 2)
1117-
for _, span := range recorder.Ended() {
1118-
if isToolSpan(span) {
1119-
toolSpans = append(toolSpans, span)
1092+
execTool := func(t *testing.T, start ToolExecutionStart) sdktrace.ReadOnlySpan {
1093+
t.Helper()
1094+
start.ToolName = "weather"
1095+
_, rec := client.StartToolExecution(context.Background(), start)
1096+
rec.SetResult(ToolExecutionEnd{
1097+
Arguments: map[string]any{"city": "Paris"},
1098+
Result: map[string]any{"temp_c": 18},
1099+
})
1100+
rec.End()
1101+
if err := rec.Err(); err != nil {
1102+
t.Fatalf("tool execution error: %v", err)
11201103
}
1121-
}
1122-
if len(toolSpans) != 2 {
1123-
t.Fatalf("expected 2 tool spans, got %d", len(toolSpans))
1104+
spans := recorder.Ended()
1105+
for i := len(spans) - 1; i >= 0; i-- {
1106+
if isToolSpan(spans[i]) {
1107+
return spans[i]
1108+
}
1109+
}
1110+
t.Fatal("tool span not found")
1111+
return nil
11241112
}
11251113

1126-
var sawWithContent, sawWithoutContent bool
1127-
for _, span := range toolSpans {
1114+
hasContent := func(span sdktrace.ReadOnlySpan) bool {
11281115
attrs := spanAttributeMap(span)
11291116
_, hasArgs := attrs[spanAttrToolCallArguments]
1130-
_, hasResult := attrs[spanAttrToolCallResult]
1131-
if hasArgs && hasResult {
1132-
sawWithContent = true
1117+
return hasArgs
1118+
}
1119+
1120+
t.Run("Config{} + bare ToolExecutionStart — no content", func(t *testing.T) {
1121+
span := execTool(t, ToolExecutionStart{})
1122+
if hasContent(span) {
1123+
t.Fatal("expected no tool content with default config and no IncludeContent")
11331124
}
1134-
if !hasArgs && !hasResult {
1135-
sawWithoutContent = true
1125+
})
1126+
1127+
t.Run("Config{} + IncludeContent:true — content included", func(t *testing.T) {
1128+
span := execTool(t, ToolExecutionStart{IncludeContent: true})
1129+
if !hasContent(span) {
1130+
t.Fatal("expected tool content with IncludeContent: true")
11361131
}
1137-
}
1132+
})
11381133

1139-
if !sawWithContent || !sawWithoutContent {
1140-
t.Fatalf("expected both content and non-content tool spans")
1141-
}
1134+
t.Run("Config{} + IncludeContent:false — no content", func(t *testing.T) {
1135+
span := execTool(t, ToolExecutionStart{IncludeContent: false})
1136+
if hasContent(span) {
1137+
t.Fatal("expected no tool content with IncludeContent: false")
1138+
}
1139+
})
11421140
}
11431141

11441142
func TestToolExecutionRecorderErrorSetsStatusAndType(t *testing.T) {

0 commit comments

Comments
 (0)