From 48f5650375c3e7b2792c357e6b569626181d9284 Mon Sep 17 00:00:00 2001 From: Jakob Date: Fri, 27 Mar 2026 09:29:17 -0700 Subject: [PATCH] fix: extract action params from standalone XML blocks in comma-separated format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the LLM outputs actions as a comma-separated list (`REPLY,START_CODING_TASK`), the parser extracted action names but dropped their parameters. The LLM also outputs standalone blocks like `...` as sibling XML elements — the XML parser puts these as top-level keys on parsedXml, but the message service never collected them into parsedXml.params for downstream consumption by parseActionParams. This caused action handlers to receive empty parameters, breaking features like coding agent swarm spawning where task/agents/repo params are required for coordinator registration. The fix scans parsedXml for top-level keys matching comma-split action names and assembles them into the legacy flat params format that parseActionParams already handles. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../comma-separated-action-params.test.ts | 155 ++++++++++++++++++ packages/typescript/src/services/message.ts | 117 +++++++++---- 2 files changed, 242 insertions(+), 30 deletions(-) create mode 100644 packages/typescript/src/__tests__/comma-separated-action-params.test.ts diff --git a/packages/typescript/src/__tests__/comma-separated-action-params.test.ts b/packages/typescript/src/__tests__/comma-separated-action-params.test.ts new file mode 100644 index 0000000000000..9e6f2a3b067ba --- /dev/null +++ b/packages/typescript/src/__tests__/comma-separated-action-params.test.ts @@ -0,0 +1,155 @@ +/** + * Regression test: action params must be extracted from standalone XML blocks + * when the LLM outputs actions as a comma-separated list. + * + * The LLM may respond with: + * REPLY,START_CODING_TASK + * + * https://github.com/org/repo + * claude:Fix auth | codex:Write tests + * + * + * The XML parser puts "START_CODING_TASK" as a top-level key on parsedXml. + * extractStandaloneActionParams collects these into the legacy flat format + * that parseActionParams can consume downstream. + */ + +import { describe, expect, it } from "vitest"; +import { parseActionParams } from "../actions"; +import { + extractStandaloneActionParams, + RESERVED_XML_KEYS, +} from "../services/message"; + +// --------------------------------------------------------------------------- +// extractStandaloneActionParams +// --------------------------------------------------------------------------- + +describe("extractStandaloneActionParams", () => { + it("extracts params from a matching parsedXml key", () => { + const parsedXml = { + actions: "REPLY,START_CODING_TASK", + thought: "Spawning agents", + START_CODING_TASK: + "https://github.com/org/repoFix it", + }; + const result = extractStandaloneActionParams( + ["REPLY", "START_CODING_TASK"], + parsedXml, + ); + expect(result).toContain(""); + expect(result).toContain("https://github.com/org/repo"); + expect(result).toContain("Fix it"); + }); + + it("matches action names case-insensitively", () => { + const parsedXml = { + start_coding_task: "Fix", + }; + const result = extractStandaloneActionParams( + ["START_CODING_TASK"], + parsedXml, + ); + expect(result).toContain(""); + expect(result).toContain("Fix"); + }); + + it("skips reserved keys even if they match action names", () => { + const parsedXml = { + actions: "REPLY", + thought: "something", + text: "hello", + }; + const result = extractStandaloneActionParams( + ["actions", "thought", "text"], + parsedXml, + ); + expect(result).toBe(""); + }); + + it("skips keys that do not contain XML (plain text values)", () => { + const parsedXml = { + MY_ACTION: "just a plain string without XML", + }; + const result = extractStandaloneActionParams(["MY_ACTION"], parsedXml); + expect(result).toBe(""); + }); + + it("handles multiple actions with params", () => { + const parsedXml = { + START_CODING_TASK: "https://github.com/org/repo", + FINALIZE_WORKSPACE: "ws-123", + }; + const result = extractStandaloneActionParams( + ["START_CODING_TASK", "FINALIZE_WORKSPACE"], + parsedXml, + ); + expect(result).toContain(""); + expect(result).toContain(""); + }); + + it("returns empty string when no matches found", () => { + const result = extractStandaloneActionParams(["REPLY", "NONE"], {}); + expect(result).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// RESERVED_XML_KEYS +// --------------------------------------------------------------------------- + +describe("RESERVED_XML_KEYS", () => { + it("includes standard response schema fields", () => { + expect(RESERVED_XML_KEYS.has("actions")).toBe(true); + expect(RESERVED_XML_KEYS.has("thought")).toBe(true); + expect(RESERVED_XML_KEYS.has("text")).toBe(true); + expect(RESERVED_XML_KEYS.has("simple")).toBe(true); + expect(RESERVED_XML_KEYS.has("providers")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseActionParams integration (end-to-end with the assembled format) +// --------------------------------------------------------------------------- + +describe("parseActionParams with standalone action block format", () => { + it("extracts params from assembled ... format", () => { + // This is the format that extractStandaloneActionParams produces + const paramsXml = ` + https://github.com/org/repo + claude:Fix auth | codex:Write tests + Fix the login bug + `; + + const result = parseActionParams(paramsXml); + expect(result.has("START_CODING_TASK")).toBe(true); + + const params = result.get("START_CODING_TASK"); + expect(params).toBeTruthy(); + expect(params?.repo).toBe("https://github.com/org/repo"); + expect(params?.agents).toBe("claude:Fix auth | codex:Write tests"); + expect(params?.task).toBe("Fix the login bug"); + }); + + it("handles multiple action blocks", () => { + const paramsXml = ` + https://github.com/org/repo + Fix bugs + + + ws-123 + `; + + const result = parseActionParams(paramsXml); + expect(result.has("START_CODING_TASK")).toBe(true); + expect(result.has("FINALIZE_WORKSPACE")).toBe(true); + expect(result.get("START_CODING_TASK")?.task).toBe("Fix bugs"); + expect(result.get("FINALIZE_WORKSPACE")?.workspaceId).toBe("ws-123"); + }); + + it("returns empty map for empty input", () => { + expect(parseActionParams("").size).toBe(0); + expect(parseActionParams(undefined).size).toBe(0); + expect(parseActionParams(null).size).toBe(0); + }); +}); diff --git a/packages/typescript/src/services/message.ts b/packages/typescript/src/services/message.ts index 70ceeee13f32d..685c9d55a6403 100644 --- a/packages/typescript/src/services/message.ts +++ b/packages/typescript/src/services/message.ts @@ -49,6 +49,51 @@ import { hasFirstSentence, } from "../utils/text-splitting"; +/** + * Reserved XML response keys that are NOT action names. + * Used when scanning parsedXml for standalone action param blocks. + */ +export const RESERVED_XML_KEYS = new Set([ + "actions", + "thought", + "text", + "simple", + "providers", +]); + +/** + * Extract action params from standalone XML blocks in a parsedXml object. + * + * When the LLM outputs `REPLY,START_CODING_TASK` alongside + * `...`, the XML parser + * puts the action block as a top-level key on parsedXml. This function finds + * those keys and assembles them into the legacy flat params format that + * `parseActionParams` consumes. + * + * Returns the assembled params string, or empty string if none found. + */ +export function extractStandaloneActionParams( + actionNames: string[], + parsedXml: Record, +): string { + const fragments: string[] = []; + for (const actionName of actionNames) { + const upperName = actionName.toUpperCase(); + const matchingKey = Object.keys(parsedXml).find( + (k) => k.toUpperCase() === upperName, + ); + if ( + matchingKey && + !RESERVED_XML_KEYS.has(matchingKey.toLowerCase()) && + typeof parsedXml[matchingKey] === "string" && + (parsedXml[matchingKey] as string).includes("<") + ) { + fragments.push(`<${upperName}>${parsedXml[matchingKey]}`); + } + } + return fragments.join("\n"); +} + /** * Escape Handlebars syntax in a string to prevent template injection. * @@ -332,35 +377,33 @@ export class DefaultMessageService implements IMessageService { "nova"; let audioBuffer: Buffer | null = null; - const params: TextToSpeechParams & { - model?: string; - } = { - text: first, - voice: voiceId, - model: model, - }; - const result = runtime.getModel( - ModelType.TEXT_TO_SPEECH, - ) - ? await runtime.useModel( - ModelType.TEXT_TO_SPEECH, - params, - ) - : undefined; - - if ( - result instanceof ArrayBuffer || - Object.prototype.toString.call(result) === - "[object ArrayBuffer]" - ) { - audioBuffer = Buffer.from(result as ArrayBuffer); - } else if (Buffer.isBuffer(result)) { - audioBuffer = result; - } else if (result instanceof Uint8Array) { - audioBuffer = Buffer.from(result); - } - - + const params: TextToSpeechParams & { + model?: string; + } = { + text: first, + voice: voiceId, + model: model, + }; + const result = runtime.getModel( + ModelType.TEXT_TO_SPEECH, + ) + ? await runtime.useModel( + ModelType.TEXT_TO_SPEECH, + params, + ) + : undefined; + + if ( + result instanceof ArrayBuffer || + Object.prototype.toString.call(result) === + "[object ArrayBuffer]" + ) { + audioBuffer = Buffer.from(result as ArrayBuffer); + } else if (Buffer.isBuffer(result)) { + audioBuffer = result; + } else if (result instanceof Uint8Array) { + audioBuffer = Buffer.from(result); + } if (audioBuffer && callback) { const audioBase64 = audioBuffer.toString("base64"); @@ -1661,10 +1704,24 @@ export class DefaultMessageService implements IMessageService { } } // Legacy comma-separated format - return actionsXml + const commaSplitActions = actionsXml .split(",") .map((action) => String(action).trim()) .filter((action) => action.length > 0); + + // Extract params from standalone action blocks in parsedXml + // (e.g. ...). + if (!parsedXml.params || parsedXml.params === "") { + const assembled = extractStandaloneActionParams( + commaSplitActions, + parsedXml as Record, + ); + if (assembled) { + parsedXml.params = assembled; + } + } + + return commaSplitActions; } if (Array.isArray(parsedXml.actions)) { return parsedXml.actions as string[];