mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 21:49:36 +02:00
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:
parent
5d79c16f51
commit
48edbef643
File diff suppressed because one or more lines are too long
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user