Skip to content

Commit 5aa06a5

Browse files
committed
feat: support @ embed references in WCS prompts
This is useful for supporting the standard that is currently implemented by Gemini CLI, and makes it easy to re-use system prompts, or context files that are originally built for e.g. Gemini CLI
1 parent ee5bafa commit 5aa06a5

File tree

7 files changed

+68
-18
lines changed

7 files changed

+68
-18
lines changed

examples/environments/remote_env/fake-executor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ export class FakeRemoteExecutor implements Executor {
8585
};
8686
}
8787

88+
async executeProjectTests() {
89+
return null;
90+
}
91+
8892
async shouldRepairFailedBuilds() {
8993
// Some environments have a builtin retry loop as part of initial generation.
9094
// In those cases, you may want to skip retrying.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Also use ABC.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Use XYZ.
2+
3+
@./other-file-2.md

examples/environments/remote_env/system-instructions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ Follow instructions below CAREFULLY:
22

33
- Code MUST be implemented in my super secret framework.
44
- Put the component code inside `src/app/app.ts`
5+
6+
@./other-file.md

runner/configuration/environment.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {readdirSync, readFileSync, statSync} from 'fs';
2-
import {basename, dirname, extname, join, resolve} from 'path';
2+
import {basename, extname, join, resolve} from 'path';
33
import {globSync} from 'tinyglobby';
4+
import {Executor} from '../orchestration/executors/executor.js';
45
import {Rating} from '../ratings/rating-types.js';
56
import {
67
FrameworkInfo,
@@ -13,10 +14,7 @@ import {generateId} from '../utils/id-generation.js';
1314
import {lazy} from '../utils/lazy-creation.js';
1415
import {EnvironmentConfig} from './environment-config.js';
1516
import {MultiStepPrompt} from './multi-step-prompt.js';
16-
import {renderHandlebarsTemplate} from './prompt-templating.js';
17-
import {RunnerName} from '../codegen/runner-creation.js';
18-
import {Executor} from '../orchestration/executors/executor.js';
19-
import {LocalExecutor} from '../orchestration/executors/local-executor.js';
17+
import {renderPromptTemplate} from './prompt-templating.js';
2018

2119
/** Represents a single prompt evaluation environment. */
2220
export class Environment {
@@ -133,8 +131,8 @@ export class Environment {
133131
promptFilePath: string | null,
134132
additionalContext: Record<string, string> = {},
135133
) {
136-
return renderHandlebarsTemplate(content, {
137-
rootDir: promptFilePath ? dirname(promptFilePath) : null,
134+
return renderPromptTemplate(content, {
135+
containingFile: promptFilePath ? promptFilePath : null,
138136
FULL_STACK_FRAMEWORK_NAME: this.fullStackFramework.displayName,
139137
CLIENT_SIDE_FRAMEWORK_NAME: this.clientSideFramework.displayName,
140138
...additionalContext,

runner/configuration/prompt-templating.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,46 @@
1-
import Handlebars from 'handlebars';
21
import {readFileSync} from 'fs';
3-
import path from 'path';
2+
import Handlebars from 'handlebars';
3+
import path, {dirname, resolve} from 'path';
44
import {UserFacingError} from '../utils/errors.js';
55

66
function initializeHandlebars() {
77
Handlebars.registerHelper('neq', (a, b) => a !== b);
8-
Handlebars.registerPartial('embed', (ctx: {rootDir: string | null; file?: string}) => {
8+
Handlebars.registerPartial('embed', (ctx: {containingFile: string | null; file?: string}) => {
99
if (!ctx.file) {
1010
throw new UserFacingError('file= is required');
1111
}
12-
if (!ctx.rootDir) {
13-
throw new UserFacingError('Cannot use `embed` if a rootDir is not specified');
12+
if (!ctx.containingFile) {
13+
throw new UserFacingError('Cannot use `embed` if not `containingFile` is specified');
1414
}
1515

16-
const fullPath = path.join(ctx.rootDir, ctx.file);
17-
const content = readFileSync(fullPath, 'utf8');
16+
const fullPath = path.join(dirname(ctx.containingFile), ctx.file);
17+
let content = readFileSync(fullPath, 'utf8');
18+
content = processAtFileReferencesSync(content, ctx.containingFile);
1819

1920
// Recursively support `embed`.
2021
return Handlebars.compile(content, {strict: true})({
2122
...ctx,
22-
rootDir: path.dirname(fullPath),
23+
containingFile: fullPath,
2324
});
2425
});
2526
}
2627

2728
initializeHandlebars();
2829

29-
/** Renders the given content via Handlebars. */
30-
export function renderHandlebarsTemplate<T extends {rootDir: string | null}>(
30+
/**
31+
* Renders the given prompt template, by supporting:
32+
* - Handlebars builtins.
33+
* - Handlebars `embed` custom partials for including other files.
34+
* - Supporting the ecosystem `@<file-path>` standard for including other files.
35+
*
36+
* If `context` does not specify a `containingFile`, then other files cannot be embedded.
37+
*/
38+
export function renderPromptTemplate<T extends {containingFile: string | null}>(
3139
content: string,
3240
ctx: T,
3341
) {
42+
content = ctx.containingFile ? processAtFileReferencesSync(content, ctx.containingFile) : content;
43+
3444
const template = Handlebars.compile(content, {strict: true});
3545
const contextFiles: string[] = [];
3646
const result = template(ctx, {
@@ -72,3 +82,35 @@ export function renderHandlebarsTemplate<T extends {rootDir: string | null}>(
7282
contextFiles,
7383
};
7484
}
85+
86+
function processAtFileReferencesSync(content: string, containingFile: string): string {
87+
let newContent = content;
88+
let match;
89+
// Match all `@./<file-path>` or `@/<file-path>` occurrences.
90+
// If someone intends to write such text in their prompt, they could overcome this
91+
// by indenting the string, or adding arbitrary characters before front.
92+
const regex = /^@(\.?\/[^\s]+)/gm;
93+
const containingFileDir = dirname(containingFile);
94+
95+
while ((match = regex.exec(newContent)) !== null) {
96+
const filePath = match[1];
97+
const fullPath = resolve(containingFileDir, filePath);
98+
let replacement: string;
99+
try {
100+
replacement = readFileSync(fullPath, 'utf8');
101+
// Note: If we start searching the match start, where the new embedded content begins,
102+
// we can trivially. process nested embeds via the `@` syntax.
103+
} catch (e) {
104+
throw new Error(
105+
`Unexpected error while embedding \`${match[0]}\` reference in ${containingFile}. ` +
106+
`Error: ${e}`,
107+
);
108+
}
109+
110+
newContent =
111+
newContent.substring(0, match.index) +
112+
processAtFileReferencesSync(replacement, fullPath) +
113+
newContent.substring(regex.lastIndex);
114+
}
115+
return newContent;
116+
}

runner/eval-cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ async function handler(cliArgs: Arguments<Options>): Promise<void> {
232232
} else {
233233
console.error(chalk.red('An error occurred during the assessment process:'));
234234
console.error(chalk.red(error));
235-
if ((error as Partial<Error>).stack) {
235+
if (process.env.DEBUG === '1' && (error as Partial<Error>).stack) {
236236
console.error(chalk.red((error as Error).stack));
237237
}
238238
}

0 commit comments

Comments
 (0)