From 542ec8ce4583edaaa64dd4f629354bf0b61815a4 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 9 Mar 2026 12:29:20 -0700 Subject: [PATCH] fix: execute directives in background flows, reject partial send-file targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from PR review: 1. sendToAgent() now parses and executes directives from agent responses. Previously, directives were only executed in processMessage() (foreground), so and targeted never fired from heartbeats, cron, or webhook contexts. Targeted directives resolve their own adapter; non-targeted directives use source context from the trigger if available. 2. send-file with only one of channel/chat (partial targeting) is now rejected with a warning instead of silently falling back to the triggering chat, which could send to an unintended destination. Written by Cameron ◯ Letta Code "Be conservative in what you send, be liberal in what you accept." -- Jon Postel --- src/core/bot.ts | 31 ++++++++++++++++++++++++++++--- src/core/directives.test.ts | 18 ++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index cf84bee..bca4f07 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -363,6 +363,13 @@ export class LettaBot implements AgentSession { } if (directive.type === 'send-file') { + // Reject partial targeting: both channel and chat must be set together. + // Without this guard, a missing field silently falls back to the triggering chat. + if ((directive.channel && !directive.chat) || (!directive.channel && directive.chat)) { + log.warn(`Directive send-file skipped: cross-channel targeting requires both channel and chat (got channel=${directive.channel || 'missing'}, chat=${directive.chat || 'missing'})`); + continue; + } + // Resolve target adapter: use cross-channel targeting if both channel and chat are set, // otherwise fall back to the adapter/chatId that triggered this response. const targetAdapter = (directive.channel && directive.chat) @@ -1908,11 +1915,29 @@ export class LettaBot implements AgentSession { continue; } + // Parse and execute directives from the response. + // Targeted directives (send-message, cross-channel send-file) work in any context. + // Non-targeted directives work when source adapter context is available. + let executedDirectives = false; + if (response.trim()) { + const parsed = parseDirectives(response); + if (parsed.directives.length > 0) { + const sourceAdapter = (context?.sourceChannel ? this.channels.get(context.sourceChannel) : undefined) + ?? this.channels.values().next().value; + if (sourceAdapter) { + executedDirectives = await this.executeDirectives( + parsed.directives, sourceAdapter, context?.sourceChatId ?? '', + ); + } + response = parsed.cleanText; + } + } + if (isSilent && response.trim()) { - if (usedMessageCli) { - log.info(`Silent mode: agent used lettabot-message CLI, collected ${response.length} chars (not delivered)`); + if (usedMessageCli || executedDirectives) { + log.info(`Silent mode: agent delivered via ${[usedMessageCli && 'CLI', executedDirectives && 'directives'].filter(Boolean).join(' + ')}, remaining text (${response.length} chars) not delivered`); } else { - log.warn(`Silent mode: agent produced ${response.length} chars but did NOT use lettabot-message CLI — response discarded. If this keeps happening, the agent's model may not be following silent mode instructions.`); + log.warn(`Silent mode: agent produced ${response.length} chars but did NOT use lettabot-message CLI or directives — response discarded. If this keeps happening, the agent's model may not be following silent mode instructions.`); } } return response; diff --git a/src/core/directives.test.ts b/src/core/directives.test.ts index 00e29d3..a8cfd62 100644 --- a/src/core/directives.test.ts +++ b/src/core/directives.test.ts @@ -306,6 +306,24 @@ describe('parseDirectives', () => { { type: 'send-file', path: 'report.pdf', caption: 'Report' }, ]); }); + + it('parses send-file with only channel (no chat) -- stores partial targeting', () => { + const result = parseDirectives( + '', + ); + expect(result.directives).toEqual([ + { type: 'send-file', path: 'report.pdf', channel: 'whatsapp' }, + ]); + }); + + it('parses send-file with only chat (no channel) -- stores partial targeting', () => { + const result = parseDirectives( + '', + ); + expect(result.directives).toEqual([ + { type: 'send-file', path: 'report.pdf', chat: '123' }, + ]); + }); }); describe('stripActionsBlock', () => {