fix(core): Keep Observer detail lines instead of discarding the response (#41134)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jaakko Husso 2026-10-15 07:13:19 +00:00 committed by GitHub
parent 5d79c16f51
commit 48edbef643
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 156 additions and 20 deletions

View File

@ -237,6 +237,68 @@ describe('parseObservationLogMarkdown', () => {
]);
expect(parsed.skippedLines).toEqual(['not a bullet']);
});
it('appends indented unmarked detail lines to the entry above', () => {
const parsed = parseObservationLogMarkdown(
[
"* CRITICAL (15:09) User's invoice rules:",
' * Count only invoices with status paid or pending.',
' - Status void means cancelled.',
'* IMPORTANT (15:10) Open questions for the user',
' * INFO (15:10) Setup is blocked on two answers.',
' 1. Which bridge do they use?',
' 2. Which webhook URL?',
].join('\n'),
);
expect(parsed.entries).toEqual([
{
marker: 'critical',
text: "User's invoice rules: Count only invoices with status paid or pending. Status void means cancelled.",
parentIndex: null,
},
{ marker: 'important', text: 'Open questions for the user', parentIndex: null },
{
marker: 'info',
text: 'Setup is blocked on two answers. 1. Which bridge do they use? 2. Which webhook URL?',
parentIndex: 1,
},
]);
expect(parsed.skippedLines).toEqual([]);
});
it('joins a detail line with a semicolon when the entry has no closing punctuation', () => {
const parsed = parseObservationLogMarkdown(
['* INFO (10:02) Published version 90f4e3d6', ' * Current draft fbda5a15'].join('\n'),
);
expect(parsed.entries).toEqual([
{
marker: 'info',
text: 'Published version 90f4e3d6; Current draft fbda5a15',
parentIndex: null,
},
]);
});
it('keeps detail lines that start with an uppercase word', () => {
const parsed = parseObservationLogMarkdown(
[
'* INFO (10:02) Endpoints checked:',
' * GET /orders returns 200.',
' * API key rotated.',
].join('\n'),
);
expect(parsed.entries).toEqual([
{
marker: 'info',
text: 'Endpoints checked: GET /orders returns 200. API key rotated.',
parentIndex: null,
},
]);
expect(parsed.skippedLines).toEqual([]);
});
});
describe('renderObserverTranscript', () => {
@ -520,6 +582,43 @@ describe('runObservationLogObserver', () => {
},
);
it('writes observations with indented detail lists and advances the cursor', async () => {
const store = new InMemoryMemory();
await store.saveThread({ id: 'thread-1', resourceId: 'user-1' });
await store.saveMessages({
threadId: 'thread-1',
resourceId: 'user-1',
messages: [message('m1', 'user', 'Remember my rules.', new Date(2026, 4, 12, 14, 30))],
});
const result = await runObservationLogObserver({
memory: store,
observationScopeId: 'thread-1',
observationLogTailLimit: 20,
tokenCounter: () => 10,
now: new Date(2026, 4, 12, 14, 31),
observe: async () =>
await Promise.resolve(
[
'* CRITICAL (14:31) User rules:',
' * Count paid invoices.',
' * Skip void ones.',
].join('\n'),
),
});
expect(result).toMatchObject({
status: 'ran',
observationsWritten: 1,
cursorAdvanced: true,
skippedLines: [],
});
expect(await store.getActiveObservationLog({ observationScopeId: 'thread-1' })).toMatchObject([
{ marker: 'critical', text: 'User rules: Count paid invoices. Skip void ones.' },
]);
expect(await store.getCursor('thread-1')).toMatchObject({ lastObservedMessageId: 'm1' });
});
it('writes parsed observations and advances the cursor after observing', async () => {
const store = new InMemoryMemory();
const parentText = 'User needs the current request remembered.';
@ -610,6 +709,12 @@ describe('runObservationLogObserver', () => {
'```\nNO_OBSERVATIONS\n```',
'NO_OBSERVATIONS\n* CRITICAL Keep this fact.',
'* CRITICAL Keep this fact.\nUnformatted detail.',
' Indented detail before any entry.\n* CRITICAL Keep this fact.',
'* CRITICAL Keep this fact.\n * COMPLETED (14:31) Unknown marker.',
'* CRITICAL Keep this fact.\n * COMPLETED Deploy is set for noon.',
'* CRITICAL Keep this fact.\n * INFO: Marker with a colon.',
'* CRITICAL Keep this fact.\n ```\n code\n ```',
'* CRITICAL Keep this fact.\n NO_OBSERVATIONS',
'* INFO ',
'```\n* CRITICAL Keep this fact.\n```',
])('rejects an invalid response without changing memory: %j', async (output) => {

View File

@ -42,6 +42,8 @@ A valid response has exactly one of these forms:
Use only one indentation level. A child attaches to the preceding top-level bullet in this response. Never omit the \`*\` prefix.
Every line is one observation with its own marker. To add details to an observation, write each detail as a marked child, or put the details on the same line separated by semicolons. Never write a plain sub-list, a numbered list, or any line without a marker.
2. Exactly this marker when there is nothing durable to record:
NO_OBSERVATIONS
@ -90,6 +92,16 @@ GOOD:
* CRITICAL (14:30) User approved the data migration. Deployment requires separate approval.
* IMPORTANT (14:31) Agent plans to start the migration; completion is not verified.
Details go on marked lines
BAD:
* CRITICAL (15:09) User's invoice rules:
* Count only invoices with status paid or pending.
- Status void means cancelled.
GOOD:
* CRITICAL (15:09) User's invoice rules: count only invoices with status paid or pending; status void means cancelled.
Keep verification evidence together
Transcript:

View File

@ -29,6 +29,12 @@ const MARKER_BY_TOKEN: Record<string, ObservationLogMarker> = {
const BULLET_PATTERN =
/^(\s*)[*-]\s+(CRITICAL|IMPORTANT|INFO|COMPLETION)(?:\s+\(\d{2}:\d{2}\))?\s+(.+)$/iu;
/**
* Indented lines that are malformed structure, not detail: a fence, the sentinel, a timed
* unknown marker, or a misspelled marker. Other uppercase words (API, GET) stay detail.
*/
const NON_DETAIL_PATTERN =
/^(?:```|NO_OBSERVATIONS$|(?:[*-]\s+)?(?:[A-Z_]+\s+\(\d{2}:\d{2}\)|(?:COMPLET|CRITIC|IMPORTAN|INFO)[A-Z]*\b))/u;
const DEFAULT_MAX_SERIALIZED_CHARS = 2_000;
const DEFAULT_MAX_STRING_CHARS = 500;
const DEFAULT_MAX_ARRAY_ITEMS = 20;
@ -119,6 +125,13 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
const match = BULLET_PATTERN.exec(rawLine);
if (!match) {
// Models often add an indented detail list under an observation. Keep
// that detail on the entry above instead of rejecting the response.
const previous = entries.at(-1);
if (previous && /^\s/.test(rawLine) && !NON_DETAIL_PATTERN.test(trimmed)) {
previous.text = appendObservationDetail(previous.text, trimmed);
continue;
}
skippedLines.push(rawLine);
continue;
}
@ -141,6 +154,12 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
return { entries, skippedLines };
}
/** Joins a detail line onto one line, so the rendered log keeps one entry per line. */
function appendObservationDetail(text: string, detailLine: string): string {
const detail = detailLine.replace(/^[*-]\s+/, '');
return /[:;.!?]$/.test(text) ? `${text} ${detail}` : `${text}; ${detail}`;
}
export function renderObserverTranscript(
messages: AgentDbMessage[],
options: RenderObserverTranscriptOptions = {},