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
114 lines
3.1 KiB
TypeScript
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();
|
|
}
|