diff --git a/backend/__tests__/unit/services/agentMentionService.nativeCue.test.js b/backend/__tests__/unit/services/agentMentionService.nativeCue.test.js index 583a1a5e32..e945953615 100644 --- a/backend/__tests__/unit/services/agentMentionService.nativeCue.test.js +++ b/backend/__tests__/unit/services/agentMentionService.nativeCue.test.js @@ -143,10 +143,44 @@ describe('native cue from kernel-built mention content', () => { expect(wake).not.toMatch(/commonly_claim_message|must be claimed first/); }); + // Cofounder's follow-up on #2261: dropping a frame whole also dropped its + // NO_REPLY guidance. A frame that teaches NO_REPLY loses only the sentences + // naming a tool native lacks. + test('a reworded wake frame whose cut misses keeps NO_REPLY and loses only the claim sentence', async () => { + const reworded = (await wakeOnMessage()) + .replace(' If you do act, the message must be claimed first', ' If you act, claim the message first'); + expect(reworded).toContain('If you act, claim the message first (commonly_claim_message)'); + + const cue = nativeCueFromKernel(reworded, 'pod-1', NATIVE_TOOL_NAMES); + + expect(cue).toMatch(/\[Wake-on-message: .*return NO_REPLY as your ENTIRE reply.*\]/); + expect(cue).not.toMatch(/commonly_claim_message|claim the message first/); + }); + + test('an agent-DM frame that teaches NO_REPLY and names a tool native lacks keeps NO_REPLY', () => { + const dmFrame = '[1:1 agent-DM with @peer (Peer) — talk directly to them, not a broadcast room. ' + + 'Reply only when your message materially advances the work; return NO_REPLY when the exchange ' + + 'reaches a natural conclusion. Open a side channel with commonly_open_dm if you need one.]'; + const cue = nativeCueFromKernel(`${dmFrame}\n\nare we done?`, 'pod-1', NATIVE_TOOL_NAMES); + + expect(cue).toBe('[1:1 agent-DM with @peer (Peer) — talk directly to them, not a broadcast room. ' + + 'Reply only when your message materially advances the work; return NO_REPLY when the exchange ' + + 'reaches a natural conclusion.]\n\nare we done?'); + }); + + test('the native pod frame never shows the upload syntax, which an agent repeats verbatim', async () => { + const cue = nativeCueFromKernel(await referenceWake(), 'pod-1', NATIVE_TOOL_NAMES); + expect(cue).not.toMatch(/\[\[upload/); + }); + test('a frame that still names a tool native lacks is dropped, not shown', () => { const content = '[Trigger: raised by **alice**.]\n[Some new cue: call commonly_brand_new_tool.]\n\nhi'; const cue = nativeCueFromKernel(content, 'pod-1', NATIVE_TOOL_NAMES); expect(cue).toBe('[Trigger: raised by **alice**.]\n\nhi'); + // Without NO_REPLY to keep, a frame goes whole, even when only one of its + // sentences names the tool: its remaining guidance was written around it. + const twoSentences = '[Other cue: be brief. Then call commonly_brand_new_tool.]\n\nhi'; + expect(nativeCueFromKernel(twoSentences, 'pod-1', NATIVE_TOOL_NAMES)).toBe('hi'); }); test('content that is not kernel-framed passes through unchanged', () => { diff --git a/backend/__tests__/unit/services/agentMessageService.phantom-directive.test.js b/backend/__tests__/unit/services/agentMessageService.phantom-directive.test.js index 7116616ea5..f4ecedc0f0 100644 --- a/backend/__tests__/unit/services/agentMessageService.phantom-directive.test.js +++ b/backend/__tests__/unit/services/agentMessageService.phantom-directive.test.js @@ -170,6 +170,33 @@ describe('AgentMessageService phantom-upload-directive footer', () => { expect(persistedDoc.content).toContain('`[[upload:abc.png|abc.png|12|image]]`'); }); + // QA walk 2026-10-16: explaining that it cannot open attachments, an agent + // typed the grammar's placeholder and drew a note it then retracted. + it.each([ + ["I can't open attached files ([[upload:...]]) in this pod — paste the text instead."], + ['Shared files show up as [[upload:…]] in chat; paste the text and I will work from that.'], + ])('a placeholder directive with no letter or digit is not a file reference: %s', async (content) => { + mockFindAgainst({}); + + await AgentMessageService.postMessage({ + agentName: 'openclaw', instanceId: 'aria', podId: '6a0da39bae757028b39f87a6', content, + }); + expect(persistedDoc.content).not.toContain('system note'); + expect(persistedDoc.content).toContain(content); + }); + + it('a made-up name still draws the note beside a placeholder', async () => { + mockFindAgainst({}); + + await AgentMessageService.postMessage({ + agentName: 'openclaw', instanceId: 'aria', podId: '6a0da39bae757028b39f87a6', + content: 'Like [[upload:...]] — here it is: [[upload:report.md]]', + }); + + expect(persistedDoc.content).toContain('no matching attachment was found'); + expect(persistedDoc.content).toContain('report.md'); + }); + it('does NOT append a footer when a File row matches the directive', async () => { // Real upload exists → no phantom. mockFindAgainst({ diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 48a8ba4557..c97b8dff72 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -1155,7 +1155,11 @@ class AgentMessageService { .trim() .replace(/[^A-Za-z0-9._\-/+ ]/g, '') .slice(0, 256); - if (sanitized && !referencedNames.includes(sanitized)) { + // A name with no letter or digit (`[[upload:...]]`, `[[upload:…]]`) + // is the grammar's placeholder, not a file: an agent explaining + // that it cannot open attachments typed it and drew a "no matching + // attachment" note it then had to retract (QA walk, 2026-10-16). + if (sanitized && /[A-Za-z0-9]/.test(sanitized) && !referencedNames.includes(sanitized)) { referencedNames.push(sanitized); } } diff --git a/backend/services/nativeKernelCue.ts b/backend/services/nativeKernelCue.ts index 52a3ff4d98..f6f121042b 100644 --- a/backend/services/nativeKernelCue.ts +++ b/backend/services/nativeKernelCue.ts @@ -17,10 +17,9 @@ import { // itself; the tool-agnostic @handle rule is kept. export const nativePodContextFrame = (podId: string): string => ( `[${KERNEL_FRAME_TAGS.podContext}: this conversation is in pod \`${podId}\`. Reply with ` - + 'commonly_post_message; it posts as you. You cannot open or attach files here: if a ' - + 'message points to an attached file ([[upload:...]]), say so and ask whoever posted it ' - + 'to paste the content. To ask a person for something, @-mention their handle: a bare name ' - + 'reaches no one.]' + + 'commonly_post_message; it posts as you. You cannot open or attach files here: if someone ' + + 'refers to a file they shared, say so and ask them to paste its content. To ask a person ' + + 'for something, @-mention their handle: a bare name reaches no one.]' ); // A frame cut just before `marker`, closed again; null when the marker is @@ -44,6 +43,22 @@ const CUT_MARKERS: Readonly> = Object.freeze({ const TOOL_NAME = /commonly_[a-z_]+/g; +// A frame that teaches NO_REPLY keeps it. Dropping such a frame whole because +// one sentence names a tool native lacks would also drop "return NO_REPLY as +// your ENTIRE reply", and an agent without it posts its private reasoning +// (AX-43). So there only the offending sentences go (Cofounder's follow-up on +// #2261); a frame left without NO_REPLY, or still naming a foreign tool, is +// dropped. +const SENTENCE_BREAK = /(?<=[.!?])\s+/; +const withoutSentencesNaming = (frame: string, foreign: readonly string[]): string | null => { + const close = frame.lastIndexOf(']'); + const sentences = frame.slice(1, close).split(SENTENCE_BREAK); + const kept = sentences.filter((sentence) => !foreign.some((name) => sentence.includes(name))); + if (!kept.length) return null; + const stripped = `[${kept.join(' ')}]`; + return stripped.includes('NO_REPLY') ? stripped : null; +}; + /** * The kernel's mention content as a native turn should read it. `nativeTools` * is every tool a native turn can be offered: after the rewrites above, a @@ -63,11 +78,16 @@ export const nativeCueFromKernel = (content: string, podId: string, nativeTools: else if (tag === KERNEL_FRAME_TAGS.trigger) next = frame.replace(/commonly_get_messages/g, 'commonly_read_context'); else if (tag && CUT_MARKERS[tag]) next = cutBefore(frame, CUT_MARKERS[tag]) ?? frame; const foreign = [...new Set(next.match(TOOL_NAME) || [])].filter((name) => !allowed.has(name)); - if (foreign.length) { - console.warn(`[native-cue] dropped a kernel frame (${tag || 'untagged'}) naming ${foreign.join(', ')}`); - return []; + if (!foreign.length) return [next]; + const stripped = next.includes('NO_REPLY') ? withoutSentencesNaming(next, foreign) : null; + if (stripped && !foreign.some((name) => stripped.includes(name))) { + console.warn( + `[native-cue] kept a kernel frame (${tag || 'untagged'}) for its NO_REPLY, without ${foreign.join(', ')}`, + ); + return [stripped]; } - return [next]; + console.warn(`[native-cue] dropped a kernel frame (${tag || 'untagged'}) naming ${foreign.join(', ')}`); + return []; }); return joinKernelFrames(rewritten, body); };