Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/agent/loop.native-send.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { AgentLoop } from './loop.js'
import { EventQueue } from './event-queue.js'

function makeLoop() {
const cacheDir = mkdtempSync(join(tmpdir(), 'agent-native-send-test-'))
let nextMessageId = 1
const sendSegmentChunks = vi.fn(async (_channelId, content) => ({
chunks: [{ id: `sent-${nextMessageId++}` }],
endCarry: [],
}))
const connector = {
sendSegmentChunks,
getBotUsername: () => 'TestBot',
}
const toolSystem = {
executeTool: vi.fn(async () => ({ output: 'tool result' })),
persistToolUse: vi.fn(async () => {}),
stripToolXml: (text: string) => text,
}
const loop = new AgentLoop(
'test-bot',
new EventQueue(),
connector as any,
{} as any,
{} as any,
{} as any,
toolSystem as any,
cacheDir,
)

return {
loop,
sendSegmentChunks,
cleanup: () => rmSync(cacheDir, { recursive: true, force: true }),
}
}

describe('AgentLoop native-tool sending', () => {
let cleanup: (() => void) | undefined

afterEach(() => cleanup?.())

it('does not resend prose already flushed before native tool calls', async () => {
const harness = makeLoop()
cleanup = harness.cleanup

;(harness.loop as any).membraneProvider = {
stream: async (_request: unknown, callbacks: any) => {
await callbacks.onPreToolContent('First preamble. ')
await callbacks.onToolCalls(
[{ id: 'tool-1', name: 'lookup', input: { q: 'one' } }],
{ depth: 0, preamble: 'First preamble. ', accumulated: 'First preamble. ' },
)
await callbacks.onPreToolContent('Second preamble. ')
await callbacks.onToolCalls(
[{ id: 'tool-2', name: 'lookup', input: { q: 'two' } }],
{ depth: 1, preamble: 'Second preamble. ', accumulated: 'First preamble. Second preamble. ' },
)

// Membrane returns every text block from the complete native tool loop,
// including both preambles delivered through onPreToolContent.
return {
content: [
{ type: 'text', text: 'First preamble. ' },
{ type: 'text', text: 'Second preamble. ' },
{ type: 'text', text: 'Final answer.' },
],
stopReason: 'end_turn',
usage: { inputTokens: 10, outputTokens: 10 },
model: 'test-model',
}
},
}

const result = await (harness.loop as any).executeWithNativeTools(
{ stop_sequences: [] },
{
name: 'TestBot',
continuation_model: 'test-model',
max_tool_depth: 4,
debug_thinking: false,
preserve_thinking_blocks: false,
},
'channel-1',
'trigger-1',
)

expect(harness.sendSegmentChunks.mock.calls.map((call) => call[1])).toEqual([
'First preamble.',
'Second preamble.',
'Final answer.',
])
expect(result.completion.content).toEqual([
{ type: 'text', text: 'First preamble. Second preamble. Final answer.' },
])
expect(result.preambleMessageIds).toEqual(['sent-1', 'sent-2'])
expect(result.sentMessageIds).toEqual(['sent-1', 'sent-2', 'sent-3'])
expect((harness.loop as any).toolSystem.persistToolUse.mock.calls.map((call: any[]) => call[2].originalCompletionText)).toEqual([
'First preamble. ',
'Second preamble. ',
])
})
})
102 changes: 84 additions & 18 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ChannelStateManager } from './state-manager.js'
import { DiscordConnector, type PinnedSteer } from '../discord/connector.js'
import { ConfigSystem } from '../config/system.js'
import { ContextBuilder, BuildContextParams } from '../context/builder.js'
import { collectCoveredToolMessageIds } from '../context/stages/tool-interleave.js'
import { ToolSystem } from '../tools/system.js'
import { Event, BotConfig, ContentBlock, DiscordMessage, ToolCall, ToolResult, VendorConfig } from '../types.js'
import { logger, withActivationLogging } from '../utils/logger.js'
Expand Down Expand Up @@ -1775,16 +1776,14 @@ export class AgentLoop {
}, 'Updated visible images with cached MCP results')
}

// 4c. Filter out Discord messages that are in tool cache's botMessageIds
// 4c. Filter only Discord messages whose text the tool cache reconstructs
// ONLY when preserve_thinking_context is DISABLED
// When enabled, the activation store handles full completions and needs the original messages
if (!config.preserve_thinking_context) {
const toolCacheBotMessageIds = new Set<string>()
for (const entry of toolCacheForContext) {
if (entry.call.botMessageIds) {
entry.call.botMessageIds.forEach(id => toolCacheBotMessageIds.add(id))
}
}
const toolCacheBotMessageIds = collectCoveredToolMessageIds(
toolCacheForContext,
discordContext.messages
)

if (toolCacheBotMessageIds.size > 0) {
const beforeFilter = discordContext.messages.length
Expand Down Expand Up @@ -2093,7 +2092,8 @@ export class AgentLoop {
}

// Combine text message IDs (already sent by inline execution) with image IDs
const allMessageIds = [...(inlineSentMessageIds ?? []), ...imageSentIds]
const textMessageIds = inlineSentMessageIds ?? []
const allMessageIds = [...new Set([...textMessageIds, ...imageSentIds])]
const responseText = completion.content
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text)
Expand All @@ -2118,6 +2118,21 @@ export class AgentLoop {
await this.activationStore.completeActivation(activation.id)
}

// This branch returns before normal response finalization below, so it
// must also attach liveness and reconstruction metadata to tool entries.
const coveredMessageIds = toolMode === 'native'
? [...new Set(preambleMessageIds)]
: [...new Set(textMessageIds)]
if (toolCallIds.length > 0 && allMessageIds.length > 0) {
await this.toolSystem.updateBotMessageIds(
this.botId,
channelId,
toolCallIds,
allMessageIds,
coveredMessageIds
)
}

// Update state and trace for image response
if (contextResult.cacheMarker) {
this.stateManager.updateCacheMarker(this.botId, channelId, contextResult.cacheMarker)
Expand Down Expand Up @@ -2206,11 +2221,21 @@ export class AgentLoop {
await this.activationStore.completeActivation(activation.id)
}

// Update tool cache entries with bot message IDs (for existence checking on reload)
// Include both preamble message IDs and final response message IDs
const allBotMessageIds = [...preambleMessageIds, ...sentMessageIds]
// Keep all emitted messages as liveness anchors, but only suppress Discord
// messages that the cached completion actually reconstructs. Native tool
// entries retain per-round preambles, not their post-tool final answer.
const allBotMessageIds = [...new Set(sentMessageIds)]
const coveredMessageIds = toolMode === 'native'
? [...new Set(preambleMessageIds)]
: allBotMessageIds
if (toolCallIds.length > 0 && allBotMessageIds.length > 0) {
await this.toolSystem.updateBotMessageIds(this.botId, channelId, toolCallIds, allBotMessageIds)
await this.toolSystem.updateBotMessageIds(
this.botId,
channelId,
toolCallIds,
allBotMessageIds,
coveredMessageIds
)
}

// 9. Update state
Expand Down Expand Up @@ -2501,9 +2526,14 @@ export class AgentLoop {
}> {
const allToolCallIds: string[] = []
const allSentMessageIds: string[] = []
const allPreambleMessageIds: string[] = []
const messageContexts: Record<string, MessageContext> = {}
const pendingToolPersistence: Array<{ call: ToolCall; result: ToolResult }> = []
let accumulatedPreToolText = ''
// Membrane's final native response includes all text from every tool round,
// including prose already delivered through onPreToolContent. Keep the raw
// flushed prefix so the final Discord send can advance past it exactly once.
let flushedPreToolText = ''
// Open markdown construct carried across this activation's messages
// (pre-tool flushes + final send).
let markdownCarry: MarkdownCarry = []
Expand Down Expand Up @@ -2595,10 +2625,12 @@ export class AgentLoop {
)
markdownCarry = sendResult.endCarry
allSentMessageIds.push(...sendResult.sentMessageIds)
allPreambleMessageIds.push(...sendResult.sentMessageIds)
for (const [msgId, ctx] of Object.entries(sendResult.messageContexts)) {
messageContexts[msgId] = ctx
}
// Reset so we don't re-send
flushedPreToolText += accumulatedPreToolText
accumulatedPreToolText = ''
}
},
Expand All @@ -2622,7 +2654,10 @@ export class AgentLoop {
input: call.input as Record<string, any>,
messageId: triggeringMessageId,
timestamp: new Date(),
originalCompletionText: context.accumulated || '',
// Membrane's accumulated field contains prose from every native
// tool round. Cache only this round's preamble or later context
// reconstruction repeats earlier prose.
originalCompletionText: context.preamble || '',
}

const toolResult = await this.toolSystem.executeTool(cxCall)
Expand Down Expand Up @@ -2724,6 +2759,22 @@ export class AgentLoop {
.map((c: any) => c.text)
.join('')

// onPreToolContent is a preview callback, not a destructive read: the
// same text is present at the front of the final aggregate response.
// Advance the display cursor past successfully flushed prose. Be
// conservative if a future Membrane version changes that contract.
let remainingCompletionText = completionText
if (flushedPreToolText) {
if (completionText.startsWith(flushedPreToolText)) {
remainingCompletionText = completionText.slice(flushedPreToolText.length)
} else {
logger.warn({
completionLength: completionText.length,
flushedLength: flushedPreToolText.length,
}, 'Native tool response did not contain the flushed pre-tool prefix')
}
}

// Capture generated image blocks (from image generation models like Gemini)
const generatedImageBlocks: ContentBlock[] = (result?.content || [])
.filter((c: any) => c.type === 'image')
Expand All @@ -2738,6 +2789,9 @@ export class AgentLoop {
const { stripped, content: textThinkingContent } = this.stripThinkingBlocks(
this.toolSystem.stripToolXml(completionText)
)
const { stripped: remainingStripped } = this.stripThinkingBlocks(
this.toolSystem.stripToolXml(remainingCompletionText)
)

// Thinking for debug display: structured blocks (native thinking) plus
// any literal <thinking> text the model wrote (legacy/prefill style)
Expand Down Expand Up @@ -2772,6 +2826,7 @@ export class AgentLoop {

// Truncate at participant names
let displayText = stripped
let remainingDisplayText = remainingStripped
if (discordMessages) {
const truncResult = this.truncateAtParticipant(
displayText,
Expand All @@ -2784,16 +2839,28 @@ export class AgentLoop {
logger.info({ truncatedAt: truncResult.truncatedAt }, 'Truncated native output at participant')
displayText = truncResult.text
}

const remainingTruncResult = this.truncateAtParticipant(
remainingDisplayText,
discordMessages,
this.connector.getBotUsername() || config.name,
llmRequest.stop_sequences,
config
)
if (remainingTruncResult.truncatedAt) {
remainingDisplayText = remainingTruncResult.text
}
}

// Replace mentions
if (discordMessages) {
displayText = await this.replaceMentions(displayText, discordMessages)
remainingDisplayText = await this.replaceMentions(remainingDisplayText, discordMessages)
}

// Send remaining text to Discord (text not already sent via onPreToolContent)
if (displayText.trim()) {
const segments = this.parseIntoSegments(displayText)
if (remainingDisplayText.trim()) {
const segments = this.parseIntoSegments(remainingDisplayText)
if (segments.length > 0) {
const sendResult = await this.sendSegments(
channelId,
Expand Down Expand Up @@ -2844,7 +2911,7 @@ export class AgentLoop {
raw: result?.raw ?? null,
},
toolCallIds: allToolCallIds,
preambleMessageIds: [],
preambleMessageIds: allPreambleMessageIds,
fullCompletionText: completionText,
sentMessageIds: allSentMessageIds,
messageContexts,
Expand Down Expand Up @@ -2876,7 +2943,7 @@ export class AgentLoop {
model: 'interrupted',
},
toolCallIds: allToolCallIds,
preambleMessageIds: [],
preambleMessageIds: allPreambleMessageIds,
fullCompletionText: ttsCtx.interruptedText,
sentMessageIds: allSentMessageIds,
messageContexts,
Expand Down Expand Up @@ -4041,4 +4108,3 @@ export class AgentLoop {
)
}
}

Loading