Skip to content

Commit b86dfe7

Browse files
committed
fix: strip benign stderr output for clarity
1 parent 32ec0bf commit b86dfe7

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

api/src/agent/agent.utility.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
extractJsonBlock,
1010
generateHermesConfig,
1111
incompleteOutputReason,
12+
stripShutdownNoise,
1213
} from './agent.utility.js';
1314

1415
describe('buildVerifySpec', () => {
@@ -282,3 +283,41 @@ describe('eventInsertRows', () => {
282283
expect(rows.map((r) => r.seq)).toEqual([5, 6]);
283284
});
284285
});
286+
287+
describe('stripShutdownNoise', () => {
288+
const STANZA = [
289+
'Exception ignored in: <generator object Langfuse._create_span_with_parent_context at 0x7f04789a74c0>',
290+
'Traceback (most recent call last):',
291+
' File "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages/langfuse/_client/client.py", line 1196, in _create_span_with_parent_context',
292+
' File "/usr/local/share/uv/python/cpython-3.11.15/lib/python3.11/contextlib.py", line 158, in __exit__',
293+
' File "/usr/local/lib/hermes-agent/venv/lib/python3.11/site-packages/opentelemetry/trace/__init__.py", line 616, in use_span',
294+
'TypeError: isinstance() arg 2 must be a type, a tuple of types, or a union',
295+
].join('\n');
296+
297+
it('removes the langfuse shutdown stanza while keeping surrounding output', () => {
298+
const input = `real warning line\n${STANZA}\nanother real line`;
299+
const out = stripShutdownNoise(input);
300+
301+
expect(out).toContain('real warning line');
302+
expect(out).toContain('another real line');
303+
expect(out).not.toContain('isinstance() arg 2');
304+
expect(out).not.toContain('_create_span_with_parent_context');
305+
expect(out).not.toContain('Traceback');
306+
});
307+
308+
it('leaves a genuine error traceback untouched', () => {
309+
const realError = [
310+
'Traceback (most recent call last):',
311+
' File "app.py", line 10, in <module>',
312+
'ValueError: something actually broke',
313+
].join('\n');
314+
315+
expect(stripShutdownNoise(realError)).toBe(realError);
316+
});
317+
318+
it('is a no-op when the stanza is absent', () => {
319+
expect(stripShutdownNoise('plain stderr\n[worker_guard] capped read_file')).toBe(
320+
'plain stderr\n[worker_guard] capped read_file',
321+
);
322+
});
323+
});

api/src/agent/agent.utility.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,49 @@ function cap(buf: string): string {
370370
return buf.length > STDOUT_CAP ? `${buf.slice(0, STDOUT_CAP)}\n…[truncated]` : buf;
371371
}
372372

373+
/**
374+
* Strip a confirmed-benign shutdown artifact from captured agent stderr. langfuse leaves an
375+
* OpenTelemetry span context-manager open at process exit (typically a threaded `delegate_task`
376+
* subagent span); during interpreter-shutdown GC, `opentelemetry.trace.use_span` references a
377+
* module global already torn down to `None`, raising `isinstance() arg 2 must be a type`. Python
378+
* prints it as an "Exception ignored in: <generator object Langfuse._create_span_with_parent_context
379+
* …>" stanza and discards it — it never reaches the agent, so the run is unaffected. We drop the
380+
* stanza so it neither clutters every run's stderr nor buries the real error when a run genuinely
381+
* fails. Narrowly matched to that exact generator, so no other diagnostic is hidden.
382+
*/
383+
export function stripShutdownNoise(stderr: string): string {
384+
if (!stderr.includes('Langfuse._create_span_with_parent_context')) {
385+
return stderr;
386+
}
387+
388+
const lines = stderr.split('\n');
389+
const kept: string[] = [];
390+
391+
for (let i = 0; i < lines.length; i++) {
392+
const isStanzaStart =
393+
lines[i].startsWith('Exception ignored in:') &&
394+
lines[i].includes('Langfuse._create_span_with_parent_context');
395+
396+
if (!isStanzaStart) {
397+
kept.push(lines[i]);
398+
continue;
399+
}
400+
401+
// Drop the whole stanza: the "Exception ignored in:" line, the "Traceback" header, every
402+
// indented frame line, and the trailing column-0 exception summary (e.g. "TypeError: …").
403+
i++;
404+
while (
405+
i < lines.length &&
406+
(lines[i] === 'Traceback (most recent call last):' || /^\s/.test(lines[i]))
407+
) {
408+
i++;
409+
}
410+
// lines[i] is now the exception summary (or out of range); the for-loop's increment skips it.
411+
}
412+
413+
return kept.join('\n');
414+
}
415+
373416
/**
374417
* Spawns a process, captures (capped) stdout/stderr, and hard-kills it after
375418
* `timeoutMs`. Never rejects — failures surface in the result.
@@ -420,7 +463,7 @@ export function spawnProcess(
420463
resolve({
421464
exitCode,
422465
stdout: cap(Buffer.concat(stdoutChunks).toString()),
423-
stderr: cap(Buffer.concat(stderrChunks).toString()),
466+
stderr: stripShutdownNoise(cap(Buffer.concat(stderrChunks).toString())),
424467
durationMs: Date.now() - start,
425468
timedOut,
426469
});

0 commit comments

Comments
 (0)