From deb48a96fb8d71f0b0665a4f4af549411cd040f4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 30 May 2026 02:55:15 +0200 Subject: [PATCH] refactor: share prompt template arguments --- .../src/harness/prompt-template-arguments.ts | 72 ++++++++++++ .../src/harness/prompt-templates.test.ts | 11 +- .../src/harness/prompt-templates.ts | 75 +----------- src/agents/sessions/prompt-templates.test.ts | 11 +- src/agents/sessions/prompt-templates.ts | 110 ++---------------- 5 files changed, 102 insertions(+), 177 deletions(-) create mode 100644 packages/agent-core/src/harness/prompt-template-arguments.ts diff --git a/packages/agent-core/src/harness/prompt-template-arguments.ts b/packages/agent-core/src/harness/prompt-template-arguments.ts new file mode 100644 index 00000000000..0c463b04402 --- /dev/null +++ b/packages/agent-core/src/harness/prompt-template-arguments.ts @@ -0,0 +1,72 @@ +/** Parse an argument string using simple shell-style single and double quotes. */ +export function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (/\s/.test(char)) { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + if (current) { + args.push(current); + } + return args; +} + +function parseSafeNonNegativeInteger(raw: string): number | undefined { + const parsed = Number(raw); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */ +export function substituteArgs(content: string, args: string[]): string { + let result = content; + result = result.replace(/\$(\d+)/g, (_, num: string) => { + const parsed = parseSafeNonNegativeInteger(num); + if (parsed === undefined || parsed <= 0) { + return ""; + } + return args[parsed - 1] ?? ""; + }); + result = result.replace( + /\$\{@:(\d+)(?::(\d+))?\}/g, + (_, startStr: string, lengthStr?: string) => { + const parsedStart = parseSafeNonNegativeInteger(startStr); + if (parsedStart === undefined) { + return ""; + } + let start = parsedStart - 1; + if (start < 0) { + start = 0; + } + if (lengthStr) { + const length = parseSafeNonNegativeInteger(lengthStr); + if (length === undefined) { + return ""; + } + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + }, + ); + const allArgs = args.join(" "); + result = result.replace(/\$ARGUMENTS/g, allArgs); + result = result.replace(/\$@/g, allArgs); + return result; +} diff --git a/packages/agent-core/src/harness/prompt-templates.test.ts b/packages/agent-core/src/harness/prompt-templates.test.ts index 6aa6641d536..6532e059b15 100644 --- a/packages/agent-core/src/harness/prompt-templates.test.ts +++ b/packages/agent-core/src/harness/prompt-templates.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from "vitest"; -import { substituteArgs } from "./prompt-templates.js"; +import { parseCommandArgs, substituteArgs } from "./prompt-templates.js"; describe("prompt template argument substitution", () => { + it("parses quoted and multiline arguments", () => { + expect(parseCommandArgs(`alpha "beta gamma"\ndelta 'echo one two'`)).toEqual([ + "alpha", + "beta gamma", + "delta", + "echo one two", + ]); + }); + it("rejects unsafe positional placeholders", () => { expect(substituteArgs("$9007199254740992", ["first", "second"])).toBe(""); }); diff --git a/packages/agent-core/src/harness/prompt-templates.ts b/packages/agent-core/src/harness/prompt-templates.ts index 41c1c59b91a..8b0df29f3fe 100644 --- a/packages/agent-core/src/harness/prompt-templates.ts +++ b/packages/agent-core/src/harness/prompt-templates.ts @@ -3,6 +3,8 @@ import { parseFrontmatter, resolveFileInfoKind as resolveKind, } from "./file-loader-utils.js"; +export { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js"; +import { substituteArgs } from "./prompt-template-arguments.js"; import { type ExecutionEnv, type PromptTemplate, type Result } from "./types.js"; export type PromptTemplateDiagnosticCode = @@ -188,79 +190,6 @@ async function loadTemplateFromFile( }; } -/** Parse an argument string using simple shell-style single and double quotes. */ -export function parseCommandArgs(argsString: string): string[] { - const args: string[] = []; - let current = ""; - let inQuote: string | null = null; - - for (let i = 0; i < argsString.length; i++) { - const char = argsString[i]; - if (inQuote) { - if (char === inQuote) { - inQuote = null; - } else { - current += char; - } - } else if (char === '"' || char === "'") { - inQuote = char; - } else if (char === " " || char === "\t") { - if (current) { - args.push(current); - current = ""; - } - } else { - current += char; - } - } - if (current) { - args.push(current); - } - return args; -} - -function parseSafeNonNegativeInteger(raw: string): number | undefined { - const parsed = Number(raw); - return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; -} - -/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */ -export function substituteArgs(content: string, args: string[]): string { - let result = content; - result = result.replace(/\$(\d+)/g, (_, num: string) => { - const parsed = parseSafeNonNegativeInteger(num); - if (parsed === undefined || parsed <= 0) { - return ""; - } - return args[parsed - 1] ?? ""; - }); - result = result.replace( - /\$\{@:(\d+)(?::(\d+))?\}/g, - (_, startStr: string, lengthStr?: string) => { - const parsedStart = parseSafeNonNegativeInteger(startStr); - if (parsedStart === undefined) { - return ""; - } - let start = parsedStart - 1; - if (start < 0) { - start = 0; - } - if (lengthStr) { - const length = parseSafeNonNegativeInteger(lengthStr); - if (length === undefined) { - return ""; - } - return args.slice(start, start + length).join(" "); - } - return args.slice(start).join(" "); - }, - ); - const allArgs = args.join(" "); - result = result.replace(/\$ARGUMENTS/g, allArgs); - result = result.replace(/\$@/g, allArgs); - return result; -} - /** Format a prompt template invocation with positional arguments. */ export function formatPromptTemplateInvocation( template: PromptTemplate, diff --git a/src/agents/sessions/prompt-templates.test.ts b/src/agents/sessions/prompt-templates.test.ts index 6aa6641d536..6532e059b15 100644 --- a/src/agents/sessions/prompt-templates.test.ts +++ b/src/agents/sessions/prompt-templates.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from "vitest"; -import { substituteArgs } from "./prompt-templates.js"; +import { parseCommandArgs, substituteArgs } from "./prompt-templates.js"; describe("prompt template argument substitution", () => { + it("parses quoted and multiline arguments", () => { + expect(parseCommandArgs(`alpha "beta gamma"\ndelta 'echo one two'`)).toEqual([ + "alpha", + "beta gamma", + "delta", + "echo one two", + ]); + }); + it("rejects unsafe positional placeholders", () => { expect(substituteArgs("$9007199254740992", ["first", "second"])).toBe(""); }); diff --git a/src/agents/sessions/prompt-templates.ts b/src/agents/sessions/prompt-templates.ts index 67cfd6f1f1d..c808176ca0b 100644 --- a/src/agents/sessions/prompt-templates.ts +++ b/src/agents/sessions/prompt-templates.ts @@ -1,6 +1,14 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; +export { + parseCommandArgs, + substituteArgs, +} from "../../../packages/agent-core/src/harness/prompt-template-arguments.js"; +import { + parseCommandArgs, + substituteArgs, +} from "../../../packages/agent-core/src/harness/prompt-template-arguments.js"; import { CONFIG_DIR_NAME } from "../config.js"; import { parseFrontmatter } from "../utils/frontmatter.js"; import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.js"; @@ -17,108 +25,6 @@ export interface PromptTemplate { filePath: string; // Absolute path to the template file } -/** - * Parse command arguments respecting quoted strings (bash-style) - * Returns array of arguments - */ -export function parseCommandArgs(argsString: string): string[] { - const args: string[] = []; - let current = ""; - let inQuote: string | null = null; - - for (let i = 0; i < argsString.length; i++) { - const char = argsString[i]; - - if (inQuote) { - if (char === inQuote) { - inQuote = null; - } else { - current += char; - } - } else if (char === '"' || char === "'") { - inQuote = char; - } else if (/\s/.test(char)) { - if (current) { - args.push(current); - current = ""; - } - } else { - current += char; - } - } - - if (current) { - args.push(current); - } - - return args; -} - -function parseSafeNonNegativeInteger(raw: string): number | undefined { - const parsed = Number(raw); - return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; -} - -/** - * Substitute argument placeholders in template content - * Supports: - * - $1, $2, ... for positional args - * - $@ and $ARGUMENTS for all args - * - ${@:N} for args from Nth onwards (bash-style slicing) - * - ${@:N:L} for L args starting from Nth - * - * Note: Replacement happens on the template string only. Argument values - * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. - */ -export function substituteArgs(content: string, args: string[]): string { - let result = content; - - // Replace $1, $2, etc. with positional args FIRST (before wildcards) - // This prevents wildcard replacement values containing $ patterns from being re-substituted - result = result.replace(/\$(\d+)/g, (_, num) => { - const parsed = parseSafeNonNegativeInteger(num); - if (parsed === undefined || parsed <= 0) { - return ""; - } - const index = parsed - 1; - return args[index] ?? ""; - }); - - // Replace ${@:start} or ${@:start:length} with sliced args (bash-style) - // Process BEFORE simple $@ to avoid conflicts - result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr, lengthStr) => { - const parsedStart = parseSafeNonNegativeInteger(startStr); - if (parsedStart === undefined) { - return ""; - } - let start = parsedStart - 1; // Convert to 0-indexed (user provides 1-indexed) - // Treat 0 as 1 (bash convention: args start at 1) - if (start < 0) { - start = 0; - } - - if (lengthStr) { - const length = parseSafeNonNegativeInteger(lengthStr); - if (length === undefined) { - return ""; - } - return args.slice(start, start + length).join(" "); - } - return args.slice(start).join(" "); - }); - - // Pre-compute all args joined (optimization) - const allArgs = args.join(" "); - - // Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode) - result = result.replace(/\$ARGUMENTS/g, allArgs); - - // Replace $@ with all args joined (existing syntax) - result = result.replace(/\$@/g, allArgs); - - return result; -} - function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null { try { const rawContent = readFileSync(filePath, "utf-8");