Skip to content

Commit 0d3dda9

Browse files
authored
fix(logs): treat a logs query path as withheld only when its content field was omitted (#9318)
- The projection omits a content field whole, so logs query treats a path as withheld only when the span is marked and the content field it reads through (input/output/thinking/errorMessage, tool-call or provider-segment content) is absent; a key missing inside a present field is path not found and fails --where again - A direct path through a withheld field (e.g. thinking) stays unknown instead of falling back to a same-named key under output - Runs whose --where could not be checked get whereUnchecked: true and a separate whereUnchecked summary count; contentWithheld counts only withheld --field rows - Usage text and the v2 contentWithheld description state whole-field semantics (openapi regenerated)
1 parent 053c933 commit 0d3dda9

5 files changed

Lines changed: 179 additions & 22 deletions

File tree

‎apps/docs/openapi-v2-logs.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1451,7 +1451,7 @@
14511451
"contentWithheld": {
14521452
"type": "boolean",
14531453
"const": true,
1454-
"description": "Present when the log projection withheld some or all of this span's content (input, output, thinking, error message, tool-call or provider-segment content) because it could not be proven safe to display. Status, timing, and usage remain. A field missing from a span carrying this marker may exist on the run but was not returned."
1454+
"description": "Present when the log projection withheld some or all of this span's content (input, output, thinking, error message, tool-call or provider-segment content) because it could not be proven safe to display. Status, timing, and usage remain. A withheld content field is omitted whole, never a key inside it: a content field absent from a span carrying this marker may exist on the run but was not returned, while a key missing inside a content field that is present is genuinely absent."
14551455
},
14561456
"toolCalls": {
14571457
"type": "array",

‎apps/sim/lib/api/contracts/logs.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ export const traceSpanSchema: z.ZodType<LogTraceSpan> = z
277277
contentWithheld: z
278278
.literal(true)
279279
.describe(
280-
"Present when the log projection withheld some or all of this span's content (input, output, thinking, error message, tool-call or provider-segment content) because it could not be proven safe to display. Status, timing, and usage remain. A field missing from a span carrying this marker may exist on the run but was not returned."
280+
"Present when the log projection withheld some or all of this span's content (input, output, thinking, error message, tool-call or provider-segment content) because it could not be proven safe to display. Status, timing, and usage remain. A withheld content field is omitted whole, never a key inside it: a content field absent from a span carrying this marker may exist on the run but was not returned, while a key missing inside a content field that is present is genuinely absent."
281281
)
282282
.optional(),
283283
toolCalls: z.array(toolCallSchema).describe('Tool calls recorded by the span.').optional(),

‎apps/sim/lib/logs/types.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,8 @@ export interface TraceSpan {
313313
* this span (`input`, `output`, `thinking`, `errorMessage`, or its tool-call and
314314
* provider-segment content), or reduced the span to structure only. Status, timing,
315315
* and usage remain. Without it a withheld field is
316-
* indistinguishable from one the block never produced.
316+
* indistinguishable from one the block never produced. A content field is omitted
317+
* whole, so a key missing inside a field that is present is genuinely absent.
317318
*/
318319
contentWithheld?: true
319320
model?: string

‎apps/sim/lib/mothership/agent-cli/engines.test.ts‎

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,19 +147,110 @@ describe('logs query on a span whose content was withheld', () => {
147147
expect(report.contentWithheld).toBeUndefined()
148148
})
149149

150-
it('keeps a withheld run under --where instead of counting it as filtered out', async () => {
150+
it('keeps a withheld run under --where as unchecked, not as a match or filtered out', async () => {
151151
const report = await logsQueryWithheld({ where: 'result.count=6' })
152152
expect(report.filteredOut).toBe(0)
153153
expect(report.rows.map((row: { runId: string }) => row.runId)).toEqual(['run-1', 'run-2'])
154-
expect(report.rows[1].note).toMatch(/withheld/)
155-
expect(report.contentWithheld).toBe(1)
154+
expect(report.rows[0].whereUnchecked).toBeUndefined()
155+
expect(report.rows[1].whereUnchecked).toBe(true)
156+
expect(report.whereUnchecked).toBe(1)
156157
})
157158

158-
it('still resolves a readable --field when --where hits withheld content', async () => {
159-
const report = await logsQueryWithheld({ field: 'duration', where: 'result.count=6' })
160-
expect(report.rows[1]).toMatchObject({ runId: 'run-2', value: 12 })
161-
expect(report.rows[1].note).toMatch(/--where/)
162-
expect(report.contentWithheld).toBe(1)
159+
it('counts an unchecked --where apart from a withheld --field', async () => {
160+
const readableField = await logsQueryWithheld({ field: 'duration', where: 'result.count=6' })
161+
expect(readableField.rows[1]).toMatchObject({ runId: 'run-2', value: 12 })
162+
expect(readableField.rows[1].note).toMatch(/--where/)
163+
expect(readableField.whereUnchecked).toBe(1)
164+
expect(readableField.contentWithheld).toBeUndefined()
165+
166+
const withheldField = await logsQueryWithheld({ where: 'result.count=6' })
167+
expect(withheldField.rows[1].note).toMatch(/value unknown/)
168+
expect(withheldField.whereUnchecked).toBe(1)
169+
expect(withheldField.contentWithheld).toBe(1)
170+
})
171+
})
172+
173+
// The projection omits a content field whole, so a span marked for its `thinking`
174+
// still carries an intact `output` whose missing keys are genuinely absent.
175+
const PARTLY_WITHHELD_RUNS = {
176+
[RUNS_PATH]: {
177+
data: [
178+
{ runId: 'run-1', status: 'completed' },
179+
{ runId: 'run-2', status: 'completed' },
180+
],
181+
},
182+
'/api/v2/logs/run-1': {
183+
data: {
184+
traceSpans: [
185+
{
186+
name: 'Router',
187+
status: 'success',
188+
output: { route: 'priority', thinking: 'not the omitted span thinking' },
189+
contentWithheld: true,
190+
},
191+
],
192+
},
193+
},
194+
'/api/v2/logs/run-2': {
195+
data: {
196+
traceSpans: [
197+
{
198+
name: 'Router',
199+
status: 'success',
200+
output: { content: 'no route chosen' },
201+
toolCalls: [{ name: 'lookup' }],
202+
contentWithheld: true,
203+
},
204+
],
205+
},
206+
},
207+
}
208+
209+
async function logsQueryPartlyWithheld(flags: Record<string, string>) {
210+
const result = await runEngine('logs query', ['wf-1'], runtimeWith(PARTLY_WITHHELD_RUNS), {
211+
block: 'Router',
212+
...flags,
213+
})
214+
expect(result.exitCode).toBe(0)
215+
return JSON.parse(result.stdout)
216+
}
217+
218+
describe('logs query on a marked span whose read field is intact', () => {
219+
it('filters out a run whose intact output lacks the --where key', async () => {
220+
const report = await logsQueryPartlyWithheld({ where: 'output.route=priority' })
221+
expect(report.rows.map((row: { runId: string }) => row.runId)).toEqual(['run-1'])
222+
expect(report.filteredOut).toBe(1)
223+
expect(report.whereUnchecked).toBeUndefined()
224+
225+
const viaFallback = await logsQueryPartlyWithheld({ where: 'route=priority' })
226+
expect(viaFallback.rows.map((row: { runId: string }) => row.runId)).toEqual(['run-1'])
227+
expect(viaFallback.filteredOut).toBe(1)
228+
})
229+
230+
it('reports a key missing from intact output as not found, not withheld', async () => {
231+
const report = await logsQueryPartlyWithheld({ field: 'output.route' })
232+
expect(report.rows[1]).toMatchObject({
233+
runId: 'run-2',
234+
value: null,
235+
note: 'path not found on span',
236+
})
237+
expect(report.contentWithheld).toBeUndefined()
238+
})
239+
240+
it('reports a path through an omitted field as withheld', async () => {
241+
const thinking = await logsQueryPartlyWithheld({ field: 'thinking' })
242+
expect(thinking.rows[0].note).toMatch(/value unknown/)
243+
expect(thinking.contentWithheld).toBe(2)
244+
245+
const shadowed = await logsQueryPartlyWithheld({ field: 'thinking', where: 'route=priority' })
246+
expect(shadowed.rows[0]).toMatchObject({ runId: 'run-1', value: null })
247+
expect(shadowed.rows[0].note).toMatch(/value unknown/)
248+
expect(shadowed.fieldResolvedUnder).toBeUndefined()
249+
250+
const toolInput = await logsQueryPartlyWithheld({ field: 'toolCalls.0.input.query' })
251+
expect(toolInput.rows[1].note).toMatch(/value unknown/)
252+
const toolMissing = await logsQueryPartlyWithheld({ field: 'toolCalls.3.input.query' })
253+
expect(toolMissing.rows[1].note).toBe('path not found on span')
163254
})
164255
})
165256

‎apps/sim/lib/mothership/agent-cli/engines/query.ts‎

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,33 @@ const TRACE_FETCH_CONCURRENCY = 5
2424
const VALUE_MAX_CHARS = 600
2525
const PATH_NOT_FOUND_NOTE = 'path not found on span'
2626
/**
27-
* The log projection withheld this span's content rather than return what it could not
28-
* prove safe to display, so an unresolved content path says nothing about the run.
27+
* The log projection withheld the content field this path reads through rather than
28+
* return what it could not prove safe to display, so the value says nothing about the run.
2929
*/
3030
const CONTENT_WITHHELD_NOTE = 'span content withheld by the log projection; value unknown'
3131
const WHERE_WITHHELD_NOTE =
32-
'span content withheld by the log projection; --where could not be checked, run kept'
32+
'span content withheld by the log projection; --where could not be checked, run kept but not a confirmed match'
33+
34+
/**
35+
* Where the log projection can omit content on a span. It omits a content field whole
36+
* (never a key inside one), so a path is withheld only when it runs through one of these
37+
* fields and the field itself is absent on a span carrying `contentWithheld`; a present
38+
* field missing a nested key is a genuine miss. `#` matches an array index.
39+
*/
40+
const WITHHELD_CONTENT_PATHS: ReadonlyArray<readonly string[]> = [
41+
['input'],
42+
['output'],
43+
['thinking'],
44+
['errorMessage'],
45+
['toolCalls', '#', 'input'],
46+
['toolCalls', '#', 'output'],
47+
['toolCalls', '#', 'error'],
48+
['modelToolCalls', '#', 'arguments'],
49+
['providerTiming', 'segments', '#', 'assistantContent'],
50+
['providerTiming', 'segments', '#', 'thinkingContent'],
51+
['providerTiming', 'segments', '#', 'errorMessage'],
52+
['providerTiming', 'segments', '#', 'toolCalls', '#', 'arguments'],
53+
]
3354

3455
/**
3556
* Keys that live on the span itself. A path whose head is anything else is read
@@ -80,6 +101,8 @@ interface ResolvedSpanPath {
80101
/** The path that produced `value`: as given, or prefixed when the fallback applied. */
81102
path: string
82103
resolvedUnder?: 'output'
104+
/** Unresolved because the projection withheld the content field the path reads through. */
105+
withheld?: true
83106
}
84107

85108
function collectSpansByName(
@@ -102,16 +125,47 @@ function resolveSpanPath(span: QueryTraceSpan, path: string): unknown {
102125
return current
103126
}
104127

105-
/** The path as given first; under `output.` when the head is not a span-level key. */
128+
function isWithheldContentPrefix(segments: readonly string[]): boolean {
129+
return WITHHELD_CONTENT_PATHS.some(
130+
(pattern) =>
131+
pattern.length === segments.length &&
132+
pattern.every((part, index) =>
133+
part === '#' ? /^\d+$/.test(segments[index] ?? '') : part === segments[index]
134+
)
135+
)
136+
}
137+
138+
/** Whether an unresolved path stopped at a content field the projection omitted. */
139+
function isWithheldPath(span: QueryTraceSpan, path: string): boolean {
140+
if (span.contentWithheld !== true) return false
141+
const segments = path.split('.')
142+
let current: unknown = span
143+
for (let index = 0; index < segments.length; index++) {
144+
if (current == null || typeof current !== 'object') return false
145+
const next = (current as Record<string, unknown>)[segments[index] ?? '']
146+
if (next === undefined) return isWithheldContentPrefix(segments.slice(0, index + 1))
147+
current = next
148+
}
149+
return false
150+
}
151+
152+
/**
153+
* The path as given first; under `output.` when the head is not a span-level key. A
154+
* direct path through a withheld content field (`thinking`) stays unknown rather than
155+
* falling back to a same-named key under output.
156+
*/
106157
function resolveSpanPathWithFallback(span: QueryTraceSpan, path: string): ResolvedSpanPath {
107158
const direct = resolveSpanPath(span, path)
108159
if (direct !== undefined) return { value: direct, path }
160+
if (isWithheldPath(span, path)) return { value: undefined, path, withheld: true }
109161
const head = path.split('.')[0] ?? ''
110162
if (SPAN_LEVEL_KEYS.has(head)) return { value: undefined, path }
111163
const underOutput = `output.${path}`
112164
const value = resolveSpanPath(span, underOutput)
113-
if (value === undefined) return { value: undefined, path }
114-
return { value, path: underOutput, resolvedUnder: 'output' }
165+
if (value !== undefined) return { value, path: underOutput, resolvedUnder: 'output' }
166+
return isWithheldPath(span, underOutput)
167+
? { value: undefined, path, withheld: true }
168+
: { value: undefined, path }
115169
}
116170

117171
function clipValue(value: unknown): unknown {
@@ -134,7 +188,10 @@ export const logsQueryCommand: AgentCliEngine = {
134188
return agentCliFail(
135189
'Usage: sim logs query <workflowId> --block <name> [--field <path>] [--where <path>=<value>] [--status <s>] [--trigger <t>] [--limit N]\n' +
136190
'Paths resolve inside the matched block span: output.content, input.action_id, status, duration. ' +
137-
'A path that names no span-level key is read under output. (result.count means output.result.count).'
191+
'A path that names no span-level key is read under output. (result.count means output.result.count).\n' +
192+
'When the log projection withheld a content field (input, output, thinking, errorMessage, tool-call or provider-segment content), ' +
193+
'a path through it reports "value unknown" and, under --where, the run is kept with whereUnchecked: true (counted in whereUnchecked, not a confirmed match). ' +
194+
'A path missing inside a field that is present is "path not found" and fails --where.'
138195
)
139196
}
140197
const field = stringFlag(flags, 'field') ?? 'output'
@@ -167,6 +224,7 @@ export const logsQueryCommand: AgentCliEngine = {
167224
let filteredOut = 0
168225
let missingTrace = 0
169226
let contentWithheld = 0
227+
let whereUnchecked = 0
170228
let fieldResolvedUnder: 'output' | undefined
171229
let whereResolvedUnder: 'output' | undefined
172230
const rows = await mapWithConcurrency(
@@ -212,24 +270,30 @@ export const logsQueryCommand: AgentCliEngine = {
212270
const matched = resolveSpanPathWithFallback(last, wherePath)
213271
if (matched.resolvedUnder) whereResolvedUnder = matched.resolvedUnder
214272
// Filtering on a withheld value would silently drop a run that may well match.
215-
whereWithheld = matched.value === undefined && last.contentWithheld === true
273+
whereWithheld = matched.withheld === true
216274
if (!whereWithheld && String(matched.value) !== whereValue) {
217275
filteredOut++
218276
return undefined
219277
}
220278
}
221279
const resolved = resolveSpanPathWithFallback(last, field)
222280
if (resolved.resolvedUnder) fieldResolvedUnder = resolved.resolvedUnder
223-
const fieldWithheld = resolved.value === undefined && last.contentWithheld === true
224-
if (whereWithheld || fieldWithheld) contentWithheld++
281+
const fieldWithheld = resolved.withheld === true
282+
if (fieldWithheld) contentWithheld++
283+
if (whereWithheld) whereUnchecked++
225284
const note = fieldWithheld
226285
? CONTENT_WITHHELD_NOTE
227286
: resolved.value === undefined
228287
? PATH_NOT_FOUND_NOTE
229288
: whereWithheld
230289
? WHERE_WITHHELD_NOTE
231290
: undefined
232-
return { ...matchedRow, value: clipValue(resolved.value), ...(note ? { note } : {}) }
291+
return {
292+
...matchedRow,
293+
value: clipValue(resolved.value),
294+
...(note ? { note } : {}),
295+
...(whereWithheld ? { whereUnchecked: true } : {}),
296+
}
233297
}
234298
)
235299

@@ -247,6 +311,7 @@ export const logsQueryCommand: AgentCliEngine = {
247311
where: whereResolvedUnder ? `output.${where}` : where,
248312
...(whereResolvedUnder ? { whereResolvedUnder } : {}),
249313
filteredOut,
314+
...(whereUnchecked ? { whereUnchecked } : {}),
250315
}
251316
: {}),
252317
...(missingTrace ? { missingTrace } : {}),

0 commit comments

Comments
 (0)