Files
lettabot/src/core/directives.ts
Cameron 5f7cdd3471 feat: XML response directives via <actions> wrapper block (#239)
Agents can now include an <actions> block at the start of their text
response to perform actions without tool calls. The block is stripped
before the message is delivered to the user.

Example:
  <actions>
    <react emoji="thumbsup" />
  </actions>
  Great idea!
  → Sends "Great idea!", reacts with thumbsup

- New directives parser (src/core/directives.ts) finds <actions> block
  at response start, parses self-closing child directives inside it
- addReaction() added to ChannelAdapter interface (Telegram, Slack,
  WhatsApp already implement it)
- Streaming holdback covers the full <actions> block duration (prefix
  check + incomplete block detection), preventing raw XML from flashing
- Directive execution extracted to executeDirectives() helper (no
  duplication between finalizeMessage and final send paths)
- Message envelope includes Response Directives section so all agents
  learn the feature regardless of system prompt
- System prompt documents the <actions> block syntax
- 19 unit tests for parser and stripping

Significantly cheaper than the Bash tool call approach (lettabot-react)
since no tool_call round trip is needed.

Relates to #19, #39, #240. Subsumes #210.

Written by Cameron ◯ Letta Code

"The best code is no code at all." - Jeff Atwood
2026-02-09 15:53:10 -08:00

114 lines
3.1 KiB
TypeScript

/**
* XML Directive Parser
*
* Parses an <actions> block at the start of agent text responses.
* Extends the existing <no-reply/> pattern to support richer actions
* (reactions, file sends, etc.) without requiring tool calls.
*
* The <actions> block must appear at the start of the response:
*
* <actions>
* <react emoji="thumbsup" />
* </actions>
* Great idea!
*
* → cleanText: "Great idea!"
* → directives: [{ type: 'react', emoji: 'thumbsup' }]
*/
export interface ReactDirective {
type: 'react';
emoji: string;
messageId?: string;
}
// Union type — extend with more directive types later
export type Directive = ReactDirective;
export interface ParseResult {
cleanText: string;
directives: Directive[];
}
/**
* Match the <actions>...</actions> wrapper at the start of the response.
* Captures the inner content of the block.
*/
const ACTIONS_BLOCK_REGEX = /^\s*<actions>([\s\S]*?)<\/actions>/;
/**
* Match self-closing child directive tags inside the actions block.
* Captures the tag name and the full attributes string.
*/
const CHILD_DIRECTIVE_REGEX = /<(react)\s+((?:[a-zA-Z-]+="[^"]*"\s*)+)\s*\/>/g;
/**
* Parse a single attribute string like: emoji="eyes" message="123"
*/
function parseAttributes(attrString: string): Record<string, string> {
const attrs: Record<string, string> = {};
const attrRegex = /([a-zA-Z-]+)="([^"]*)"/g;
let match;
while ((match = attrRegex.exec(attrString)) !== null) {
attrs[match[1]] = match[2];
}
return attrs;
}
/**
* Parse child directives from the inner content of an <actions> block.
*/
function parseChildDirectives(block: string): Directive[] {
const directives: Directive[] = [];
let match;
// Reset regex state (global flag)
CHILD_DIRECTIVE_REGEX.lastIndex = 0;
while ((match = CHILD_DIRECTIVE_REGEX.exec(block)) !== null) {
const [, tagName, attrString] = match;
if (tagName === 'react') {
const attrs = parseAttributes(attrString);
if (attrs.emoji) {
directives.push({
type: 'react',
emoji: attrs.emoji,
...(attrs.message ? { messageId: attrs.message } : {}),
});
}
}
}
return directives;
}
/**
* Parse XML directives from agent response text.
*
* Looks for an <actions>...</actions> block at the start of the response.
* Returns the cleaned text (block stripped) and an array of parsed directives.
* If no <actions> block is found, the text is returned unchanged.
*/
export function parseDirectives(text: string): ParseResult {
const match = text.match(ACTIONS_BLOCK_REGEX);
if (!match) {
return { cleanText: text, directives: [] };
}
const actionsContent = match[1];
const cleanText = text.slice(match[0].length).trim();
const directives = parseChildDirectives(actionsContent);
return { cleanText, directives };
}
/**
* Strip a leading <actions>...</actions> block from text for streaming display.
* Returns the text after the block, or the original text if no complete block found.
*/
export function stripActionsBlock(text: string): string {
return text.replace(ACTIONS_BLOCK_REGEX, '').trim();
}