fix: execute directives in background flows, reject partial send-file targeting

Two fixes from PR review:

1. sendToAgent() now parses and executes directives from agent responses.
   Previously, directives were only executed in processMessage() (foreground),
   so <send-message> and targeted <send-file> 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
This commit is contained in:
Cameron
2026-03-09 12:29:20 -07:00
parent f7d8005be4
commit 542ec8ce45
2 changed files with 46 additions and 3 deletions

View File

@@ -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;

View File

@@ -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(
'<actions><send-file path="report.pdf" channel="whatsapp" /></actions>',
);
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(
'<actions><send-file path="report.pdf" chat="123" /></actions>',
);
expect(result.directives).toEqual([
{ type: 'send-file', path: 'report.pdf', chat: '123' },
]);
});
});
describe('stripActionsBlock', () => {