Skip to content

Commit 9c842b0

Browse files
Merge pull request #6692 from elizaOS/fix/comma-separated-action-params
fix: extract action params from standalone XML blocks in comma-separated format
2 parents 376a098 + 48f5650 commit 9c842b0

File tree

2 files changed

+242
-30
lines changed

2 files changed

+242
-30
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* Regression test: action params must be extracted from standalone XML blocks
3+
* when the LLM outputs actions as a comma-separated list.
4+
*
5+
* The LLM may respond with:
6+
* <actions>REPLY,START_CODING_TASK</actions>
7+
* <START_CODING_TASK>
8+
* <repo>https://github.com/org/repo</repo>
9+
* <agents>claude:Fix auth | codex:Write tests</agents>
10+
* </START_CODING_TASK>
11+
*
12+
* The XML parser puts "START_CODING_TASK" as a top-level key on parsedXml.
13+
* extractStandaloneActionParams collects these into the legacy flat format
14+
* that parseActionParams can consume downstream.
15+
*/
16+
17+
import { describe, expect, it } from "vitest";
18+
import { parseActionParams } from "../actions";
19+
import {
20+
extractStandaloneActionParams,
21+
RESERVED_XML_KEYS,
22+
} from "../services/message";
23+
24+
// ---------------------------------------------------------------------------
25+
// extractStandaloneActionParams
26+
// ---------------------------------------------------------------------------
27+
28+
describe("extractStandaloneActionParams", () => {
29+
it("extracts params from a matching parsedXml key", () => {
30+
const parsedXml = {
31+
actions: "REPLY,START_CODING_TASK",
32+
thought: "Spawning agents",
33+
START_CODING_TASK:
34+
"<repo>https://github.com/org/repo</repo><task>Fix it</task>",
35+
};
36+
const result = extractStandaloneActionParams(
37+
["REPLY", "START_CODING_TASK"],
38+
parsedXml,
39+
);
40+
expect(result).toContain("<START_CODING_TASK>");
41+
expect(result).toContain("<repo>https://github.com/org/repo</repo>");
42+
expect(result).toContain("<task>Fix it</task>");
43+
});
44+
45+
it("matches action names case-insensitively", () => {
46+
const parsedXml = {
47+
start_coding_task: "<task>Fix</task>",
48+
};
49+
const result = extractStandaloneActionParams(
50+
["START_CODING_TASK"],
51+
parsedXml,
52+
);
53+
expect(result).toContain("<START_CODING_TASK>");
54+
expect(result).toContain("<task>Fix</task>");
55+
});
56+
57+
it("skips reserved keys even if they match action names", () => {
58+
const parsedXml = {
59+
actions: "REPLY",
60+
thought: "something",
61+
text: "<bold>hello</bold>",
62+
};
63+
const result = extractStandaloneActionParams(
64+
["actions", "thought", "text"],
65+
parsedXml,
66+
);
67+
expect(result).toBe("");
68+
});
69+
70+
it("skips keys that do not contain XML (plain text values)", () => {
71+
const parsedXml = {
72+
MY_ACTION: "just a plain string without XML",
73+
};
74+
const result = extractStandaloneActionParams(["MY_ACTION"], parsedXml);
75+
expect(result).toBe("");
76+
});
77+
78+
it("handles multiple actions with params", () => {
79+
const parsedXml = {
80+
START_CODING_TASK: "<repo>https://github.com/org/repo</repo>",
81+
FINALIZE_WORKSPACE: "<workspaceId>ws-123</workspaceId>",
82+
};
83+
const result = extractStandaloneActionParams(
84+
["START_CODING_TASK", "FINALIZE_WORKSPACE"],
85+
parsedXml,
86+
);
87+
expect(result).toContain("<START_CODING_TASK>");
88+
expect(result).toContain("<FINALIZE_WORKSPACE>");
89+
});
90+
91+
it("returns empty string when no matches found", () => {
92+
const result = extractStandaloneActionParams(["REPLY", "NONE"], {});
93+
expect(result).toBe("");
94+
});
95+
});
96+
97+
// ---------------------------------------------------------------------------
98+
// RESERVED_XML_KEYS
99+
// ---------------------------------------------------------------------------
100+
101+
describe("RESERVED_XML_KEYS", () => {
102+
it("includes standard response schema fields", () => {
103+
expect(RESERVED_XML_KEYS.has("actions")).toBe(true);
104+
expect(RESERVED_XML_KEYS.has("thought")).toBe(true);
105+
expect(RESERVED_XML_KEYS.has("text")).toBe(true);
106+
expect(RESERVED_XML_KEYS.has("simple")).toBe(true);
107+
expect(RESERVED_XML_KEYS.has("providers")).toBe(true);
108+
});
109+
});
110+
111+
// ---------------------------------------------------------------------------
112+
// parseActionParams integration (end-to-end with the assembled format)
113+
// ---------------------------------------------------------------------------
114+
115+
describe("parseActionParams with standalone action block format", () => {
116+
it("extracts params from assembled <ACTION_NAME>...</ACTION_NAME> format", () => {
117+
// This is the format that extractStandaloneActionParams produces
118+
const paramsXml = `<START_CODING_TASK>
119+
<repo>https://github.com/org/repo</repo>
120+
<agents>claude:Fix auth | codex:Write tests</agents>
121+
<task>Fix the login bug</task>
122+
</START_CODING_TASK>`;
123+
124+
const result = parseActionParams(paramsXml);
125+
expect(result.has("START_CODING_TASK")).toBe(true);
126+
127+
const params = result.get("START_CODING_TASK");
128+
expect(params).toBeTruthy();
129+
expect(params?.repo).toBe("https://github.com/org/repo");
130+
expect(params?.agents).toBe("claude:Fix auth | codex:Write tests");
131+
expect(params?.task).toBe("Fix the login bug");
132+
});
133+
134+
it("handles multiple action blocks", () => {
135+
const paramsXml = `<START_CODING_TASK>
136+
<repo>https://github.com/org/repo</repo>
137+
<task>Fix bugs</task>
138+
</START_CODING_TASK>
139+
<FINALIZE_WORKSPACE>
140+
<workspaceId>ws-123</workspaceId>
141+
</FINALIZE_WORKSPACE>`;
142+
143+
const result = parseActionParams(paramsXml);
144+
expect(result.has("START_CODING_TASK")).toBe(true);
145+
expect(result.has("FINALIZE_WORKSPACE")).toBe(true);
146+
expect(result.get("START_CODING_TASK")?.task).toBe("Fix bugs");
147+
expect(result.get("FINALIZE_WORKSPACE")?.workspaceId).toBe("ws-123");
148+
});
149+
150+
it("returns empty map for empty input", () => {
151+
expect(parseActionParams("").size).toBe(0);
152+
expect(parseActionParams(undefined).size).toBe(0);
153+
expect(parseActionParams(null).size).toBe(0);
154+
});
155+
});

packages/typescript/src/services/message.ts

Lines changed: 87 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,51 @@ import {
4949
hasFirstSentence,
5050
} from "../utils/text-splitting";
5151

52+
/**
53+
* Reserved XML response keys that are NOT action names.
54+
* Used when scanning parsedXml for standalone action param blocks.
55+
*/
56+
export const RESERVED_XML_KEYS = new Set([
57+
"actions",
58+
"thought",
59+
"text",
60+
"simple",
61+
"providers",
62+
]);
63+
64+
/**
65+
* Extract action params from standalone XML blocks in a parsedXml object.
66+
*
67+
* When the LLM outputs `<actions>REPLY,START_CODING_TASK</actions>` alongside
68+
* `<START_CODING_TASK><repo>...</repo></START_CODING_TASK>`, the XML parser
69+
* puts the action block as a top-level key on parsedXml. This function finds
70+
* those keys and assembles them into the legacy flat params format that
71+
* `parseActionParams` consumes.
72+
*
73+
* Returns the assembled params string, or empty string if none found.
74+
*/
75+
export function extractStandaloneActionParams(
76+
actionNames: string[],
77+
parsedXml: Record<string, unknown>,
78+
): string {
79+
const fragments: string[] = [];
80+
for (const actionName of actionNames) {
81+
const upperName = actionName.toUpperCase();
82+
const matchingKey = Object.keys(parsedXml).find(
83+
(k) => k.toUpperCase() === upperName,
84+
);
85+
if (
86+
matchingKey &&
87+
!RESERVED_XML_KEYS.has(matchingKey.toLowerCase()) &&
88+
typeof parsedXml[matchingKey] === "string" &&
89+
(parsedXml[matchingKey] as string).includes("<")
90+
) {
91+
fragments.push(`<${upperName}>${parsedXml[matchingKey]}</${upperName}>`);
92+
}
93+
}
94+
return fragments.join("\n");
95+
}
96+
5297
/**
5398
* Escape Handlebars syntax in a string to prevent template injection.
5499
*
@@ -332,35 +377,33 @@ export class DefaultMessageService implements IMessageService {
332377
"nova";
333378

334379
let audioBuffer: Buffer | null = null;
335-
const params: TextToSpeechParams & {
336-
model?: string;
337-
} = {
338-
text: first,
339-
voice: voiceId,
340-
model: model,
341-
};
342-
const result = runtime.getModel(
343-
ModelType.TEXT_TO_SPEECH,
344-
)
345-
? await runtime.useModel(
346-
ModelType.TEXT_TO_SPEECH,
347-
params,
348-
)
349-
: undefined;
350-
351-
if (
352-
result instanceof ArrayBuffer ||
353-
Object.prototype.toString.call(result) ===
354-
"[object ArrayBuffer]"
355-
) {
356-
audioBuffer = Buffer.from(result as ArrayBuffer);
357-
} else if (Buffer.isBuffer(result)) {
358-
audioBuffer = result;
359-
} else if (result instanceof Uint8Array) {
360-
audioBuffer = Buffer.from(result);
361-
}
362-
363-
380+
const params: TextToSpeechParams & {
381+
model?: string;
382+
} = {
383+
text: first,
384+
voice: voiceId,
385+
model: model,
386+
};
387+
const result = runtime.getModel(
388+
ModelType.TEXT_TO_SPEECH,
389+
)
390+
? await runtime.useModel(
391+
ModelType.TEXT_TO_SPEECH,
392+
params,
393+
)
394+
: undefined;
395+
396+
if (
397+
result instanceof ArrayBuffer ||
398+
Object.prototype.toString.call(result) ===
399+
"[object ArrayBuffer]"
400+
) {
401+
audioBuffer = Buffer.from(result as ArrayBuffer);
402+
} else if (Buffer.isBuffer(result)) {
403+
audioBuffer = result;
404+
} else if (result instanceof Uint8Array) {
405+
audioBuffer = Buffer.from(result);
406+
}
364407

365408
if (audioBuffer && callback) {
366409
const audioBase64 = audioBuffer.toString("base64");
@@ -1661,10 +1704,24 @@ export class DefaultMessageService implements IMessageService {
16611704
}
16621705
}
16631706
// Legacy comma-separated format
1664-
return actionsXml
1707+
const commaSplitActions = actionsXml
16651708
.split(",")
16661709
.map((action) => String(action).trim())
16671710
.filter((action) => action.length > 0);
1711+
1712+
// Extract params from standalone action blocks in parsedXml
1713+
// (e.g. <START_CODING_TASK><repo>...</repo></START_CODING_TASK>).
1714+
if (!parsedXml.params || parsedXml.params === "") {
1715+
const assembled = extractStandaloneActionParams(
1716+
commaSplitActions,
1717+
parsedXml as Record<string, unknown>,
1718+
);
1719+
if (assembled) {
1720+
parsedXml.params = assembled;
1721+
}
1722+
}
1723+
1724+
return commaSplitActions;
16681725
}
16691726
if (Array.isArray(parsedXml.actions)) {
16701727
return parsedXml.actions as string[];

0 commit comments

Comments
 (0)