mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-12 20:37:08 -07:00
feat(core): enhance prompter with slots, format control, and multi-contribution support
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Prompt, prompt } from './prompter.js';
|
||||
|
||||
// prompts can be functions that depend on context
|
||||
export const identity = prompt(
|
||||
(ctx) =>
|
||||
`You are Gemini CLI, an ${ctx.preamble?.interactive ? 'interactive' : 'autonomous'} CLI agent.`,
|
||||
);
|
||||
|
||||
// or they can be objects representing an XML or Markdown section
|
||||
export const securityMandates = prompt({
|
||||
tag: 'security',
|
||||
attrs: { importance: 'MAXIMUM' },
|
||||
content: 'Never log or commit secrets. Protect .env and .git folders.',
|
||||
});
|
||||
|
||||
// they can compose together, with individual content parts resolving based
|
||||
// on context and not rendering if conditions aren't met
|
||||
export const subagents = prompt({
|
||||
id: 'subagents',
|
||||
heading: 'Available Subagents',
|
||||
condition: (ctx) => (ctx.subAgents?.length ?? 0) > 0,
|
||||
content: [
|
||||
'Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise.',
|
||||
{
|
||||
tag: 'available_subagents',
|
||||
content: (ctx) =>
|
||||
ctx.subAgents?.map((s) => `- ${s.name}: ${s.description}`).join('\n') ||
|
||||
'',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const emptySection = prompt({
|
||||
heading: 'This Should Not Render',
|
||||
content: '', // Empty content causes section to be skipped
|
||||
});
|
||||
|
||||
const myPrompt = new Prompt(
|
||||
identity,
|
||||
{
|
||||
id: 'general_guidance',
|
||||
heading: 'General Guidance',
|
||||
content: [
|
||||
'The following sections tell you how to behave. ALWAYS FOLLOW.',
|
||||
securityMandates,
|
||||
],
|
||||
},
|
||||
{ heading: 'Stuff You Can Use', content: subagents },
|
||||
emptySection,
|
||||
);
|
||||
|
||||
// We can add new prompt elements dynamically
|
||||
myPrompt.add('Here is an added note.');
|
||||
|
||||
// We can dynamically contribute to sections by ID, allowing for
|
||||
// post-facto composability when adding new features
|
||||
myPrompt.contribute({
|
||||
general_guidance: 'Be nice to humans.',
|
||||
subagents: 'Consider using the web_search subagent for recent info.',
|
||||
});
|
||||
|
||||
//eslint-disable-next-line no-console
|
||||
console.log(
|
||||
await myPrompt.render({
|
||||
preamble: { interactive: true },
|
||||
subAgents: [{ name: 'foo', description: 'does foo stuff' }],
|
||||
}),
|
||||
);
|
||||
@@ -5,158 +5,225 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Prompt } from './prompter.js';
|
||||
import { renderPrompt, p } from './prompter.js';
|
||||
import type { PromptContent } from './types.js';
|
||||
|
||||
describe('Prompt', () => {
|
||||
it('renders a simple string', async () => {
|
||||
const prompt = new Prompt('Hello world');
|
||||
expect(await prompt.render({})).toBe('Hello world');
|
||||
});
|
||||
type TestContext = { name?: string; shouldRender?: boolean };
|
||||
|
||||
it('renders a function content resolving dynamically', async () => {
|
||||
const prompt = new Prompt<{ name: string }>((ctx) => `Hello ${ctx.name}`);
|
||||
expect(await prompt.render({ name: 'Alice' })).toBe('Hello Alice');
|
||||
});
|
||||
type TestCase = {
|
||||
desc: string;
|
||||
content: PromptContent<TestContext> | Array<PromptContent<TestContext>>;
|
||||
context: TestContext;
|
||||
contributions?:
|
||||
| Record<string, PromptContent<TestContext>>
|
||||
| Array<Record<string, PromptContent<TestContext>>>;
|
||||
expect: string;
|
||||
};
|
||||
|
||||
it('renders an array of contents', async () => {
|
||||
const prompt = new Prompt(['Part 1', 'Part 2']);
|
||||
expect(await prompt.render({})).toBe('Part 1\n\nPart 2');
|
||||
});
|
||||
|
||||
it('renders a section with heading', async () => {
|
||||
const prompt = new Prompt({
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
desc: 'renders a simple string',
|
||||
content: 'Hello world',
|
||||
context: {},
|
||||
expect: 'Hello world',
|
||||
},
|
||||
{
|
||||
desc: 'renders a function content resolving dynamically',
|
||||
content: (ctx) => `Hello ${ctx.name}`,
|
||||
context: { name: 'Alice' },
|
||||
expect: 'Hello Alice',
|
||||
},
|
||||
{
|
||||
desc: 'renders an array of contents with block spacing',
|
||||
content: [['Part 1', 'Part 2']],
|
||||
context: {},
|
||||
expect: 'Part 1\n\nPart 2',
|
||||
},
|
||||
{
|
||||
desc: 'p tag renders inline spacing',
|
||||
content: p`Part ${1} and Part ${2}`,
|
||||
context: {},
|
||||
expect: 'Part 1 and Part 2',
|
||||
},
|
||||
{
|
||||
desc: 'renders a section with heading',
|
||||
content: {
|
||||
heading: 'My Section',
|
||||
content: 'This is the body',
|
||||
});
|
||||
expect(await prompt.render({})).toBe('# My Section\n\nThis is the body');
|
||||
});
|
||||
|
||||
it('renders a section with tag and attrs', async () => {
|
||||
const prompt = new Prompt({
|
||||
},
|
||||
context: {},
|
||||
expect: '# My Section\n\nThis is the body',
|
||||
},
|
||||
{
|
||||
desc: 'renders a section with tag and attrs',
|
||||
content: {
|
||||
tag: 'foo',
|
||||
attrs: { bar: 'baz' },
|
||||
content: 'Hello',
|
||||
});
|
||||
expect(await prompt.render({})).toBe('<foo bar="baz">\nHello\n</foo>');
|
||||
});
|
||||
|
||||
it('allows adding content via .add()', async () => {
|
||||
const prompt = new Prompt('Original');
|
||||
prompt.add('Added later');
|
||||
expect(await prompt.render({})).toBe('Original\n\nAdded later');
|
||||
});
|
||||
|
||||
it('conditionally omits rendering a section based on condition', async () => {
|
||||
const prompt = new Prompt<{ shouldRender: boolean }>(
|
||||
},
|
||||
context: {},
|
||||
expect: '<foo bar="baz">\nHello\n</foo>',
|
||||
},
|
||||
{
|
||||
desc: 'conditionally omits rendering a section based on condition',
|
||||
content: [
|
||||
{
|
||||
heading: 'Conditional',
|
||||
condition: (ctx) => ctx.shouldRender,
|
||||
condition: (ctx) => ctx.shouldRender ?? false,
|
||||
content: 'This might not appear',
|
||||
},
|
||||
'Always appears',
|
||||
);
|
||||
expect(await prompt.render({ shouldRender: false })).toBe('Always appears');
|
||||
expect(await prompt.render({ shouldRender: true })).toBe(
|
||||
'# Conditional\n\nThis might not appear\n\nAlways appears',
|
||||
);
|
||||
});
|
||||
|
||||
it('conditionally omits rendering a section based on an async condition', async () => {
|
||||
const prompt = new Prompt<{ shouldRender: boolean }>(
|
||||
],
|
||||
context: { shouldRender: false },
|
||||
expect: 'Always appears',
|
||||
},
|
||||
{
|
||||
desc: 'conditionally includes rendering a section based on condition',
|
||||
content: [
|
||||
{
|
||||
heading: 'Conditional',
|
||||
condition: async (ctx) => ctx.shouldRender,
|
||||
condition: (ctx) => ctx.shouldRender ?? false,
|
||||
content: 'This might not appear',
|
||||
},
|
||||
'Always appears',
|
||||
);
|
||||
expect(await prompt.render({ shouldRender: false })).toBe('Always appears');
|
||||
expect(await prompt.render({ shouldRender: true })).toBe(
|
||||
'# Conditional\n\nThis might not appear\n\nAlways appears',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows dynamic contributions via .contribute()', async () => {
|
||||
const prompt = new Prompt(
|
||||
],
|
||||
context: { shouldRender: true },
|
||||
expect: '# Conditional\n\nThis might not appear\n\nAlways appears',
|
||||
},
|
||||
{
|
||||
desc: 'conditionally omits rendering a section based on an async condition',
|
||||
content: [
|
||||
{
|
||||
heading: 'Conditional',
|
||||
condition: async (ctx) => ctx.shouldRender ?? false,
|
||||
content: 'This might not appear',
|
||||
},
|
||||
'Always appears',
|
||||
],
|
||||
context: { shouldRender: false },
|
||||
expect: 'Always appears',
|
||||
},
|
||||
{
|
||||
desc: 'allows dynamic contributions via .contribute() to {slot}',
|
||||
content: [
|
||||
{
|
||||
id: 'target',
|
||||
heading: 'Target Section',
|
||||
content: ['Initial content'],
|
||||
content: ['Initial content', { slot: 'target' }],
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
heading: 'Other Section',
|
||||
content: 'Other content',
|
||||
},
|
||||
);
|
||||
|
||||
prompt.contribute({
|
||||
],
|
||||
contributions: {
|
||||
target: 'Contributed content',
|
||||
missing: 'Should be ignored',
|
||||
});
|
||||
|
||||
const result = await prompt.render({});
|
||||
expect(result).toBe(
|
||||
'# Target Section\n\nInitial content\n\nContributed content\n\nOther content',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts single content into an array when contributing', async () => {
|
||||
const prompt = new Prompt({
|
||||
id: 'target',
|
||||
},
|
||||
context: {},
|
||||
expect:
|
||||
'# Target Section\n\nInitial content\n\nContributed content\n\n# Other Section\n\nOther content',
|
||||
},
|
||||
{
|
||||
desc: 'handles slot contribution even when missing',
|
||||
content: {
|
||||
heading: 'Target Section',
|
||||
content: 'Initial content', // String, not array
|
||||
});
|
||||
|
||||
prompt.contribute({
|
||||
target: 'Contributed content',
|
||||
});
|
||||
|
||||
const result = await prompt.render({});
|
||||
expect(result).toBe(
|
||||
'# Target Section\n\nInitial content\n\nContributed content',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips rendering headings and tags if the content is empty', async () => {
|
||||
const prompt = new Prompt(
|
||||
content: ['Initial content', { slot: 'target' }],
|
||||
},
|
||||
context: {},
|
||||
expect: '# Target Section\n\nInitial content',
|
||||
},
|
||||
{
|
||||
desc: 'skips rendering headings and tags if the content is empty',
|
||||
content: [
|
||||
{
|
||||
heading: 'Empty Section',
|
||||
tag: 'empty',
|
||||
content: '', // Empty string
|
||||
content: '',
|
||||
},
|
||||
{
|
||||
heading: 'Empty Array Section',
|
||||
content: [], // Empty array
|
||||
content: [],
|
||||
},
|
||||
{
|
||||
heading: 'Function resolving to empty',
|
||||
content: () => '',
|
||||
},
|
||||
'Visible content',
|
||||
);
|
||||
|
||||
expect(await prompt.render({})).toBe('Visible content');
|
||||
});
|
||||
|
||||
it('handles nested structures properly in contribute', async () => {
|
||||
const prompt = new Prompt({
|
||||
],
|
||||
context: {},
|
||||
expect: 'Visible content',
|
||||
},
|
||||
{
|
||||
desc: 'handles nested structures properly in contribute',
|
||||
content: {
|
||||
heading: 'Outer',
|
||||
content: [
|
||||
{
|
||||
id: 'inner',
|
||||
heading: 'Inner',
|
||||
content: 'Inner content',
|
||||
content: ['Inner content', { slot: 'inner' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
prompt.contribute({
|
||||
},
|
||||
contributions: {
|
||||
inner: 'Injected into inner',
|
||||
});
|
||||
},
|
||||
context: {},
|
||||
expect: '# Outer\n\n## Inner\n\nInner content\n\nInjected into inner',
|
||||
},
|
||||
{
|
||||
desc: 'resolves recursive async functions correctly before filling slots',
|
||||
content: {
|
||||
heading: 'Async Section',
|
||||
content: async () => [
|
||||
'Content from async function',
|
||||
{ slot: 'async_slot' },
|
||||
],
|
||||
},
|
||||
contributions: {
|
||||
async_slot: async () => 'Contributed async content',
|
||||
},
|
||||
context: {},
|
||||
expect:
|
||||
'# Async Section\n\nContent from async function\n\nContributed async content',
|
||||
},
|
||||
{
|
||||
desc: 'collapses 3+ newlines into 2',
|
||||
content: ['First', '\n\n\n\n\n', 'Second'],
|
||||
context: {},
|
||||
expect: 'First\n\nSecond',
|
||||
},
|
||||
{
|
||||
desc: 'does not collapse 3+ newlines inside markdown code fences',
|
||||
content: ['First', '```\n\n\n\n\n```', 'Second'],
|
||||
context: {},
|
||||
expect: 'First\n\n```\n\n\n\n\n```\n\nSecond',
|
||||
},
|
||||
{
|
||||
desc: 'appends multiple contributions to the same slot',
|
||||
content: {
|
||||
heading: 'Multi Section',
|
||||
content: [{ slot: 'multi' }],
|
||||
},
|
||||
contributions: [{ multi: 'First' }, { multi: 'Second' }],
|
||||
context: {},
|
||||
expect: '# Multi Section\n\nFirst\n\nSecond',
|
||||
},
|
||||
{
|
||||
desc: 'appends multiple contributions to an inline slot',
|
||||
content: p`Prefix: ${{ slot: 'inline_multi' }}`,
|
||||
contributions: [{ inline_multi: 'First' }, { inline_multi: 'Second' }],
|
||||
context: {},
|
||||
expect: 'Prefix: FirstSecond',
|
||||
},
|
||||
];
|
||||
|
||||
const result = await prompt.render({});
|
||||
expect(result).toBe(
|
||||
'# Outer\n\n## Inner\n\nInner content\n\nInjected into inner',
|
||||
);
|
||||
describe('renderPrompt', () => {
|
||||
it.each(tests)('$desc', async (test) => {
|
||||
const result = await renderPrompt({
|
||||
content: test.content,
|
||||
contributions: test.contributions,
|
||||
context: test.context,
|
||||
});
|
||||
expect(result).toBe(test.expect);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
*/
|
||||
|
||||
import type { SystemPromptOptions } from 'src/prompts/snippets.js';
|
||||
import type { PromptContent, PromptSection } from './types.js';
|
||||
import type { PromptContent, PromptSlot } from './types.js';
|
||||
|
||||
type BaseContent = string | BaseSection | PromptSlot | BaseContent[];
|
||||
type BaseSection = {
|
||||
heading?: string;
|
||||
tag?: string;
|
||||
attrs?: Record<string, string>;
|
||||
format?: 'inline' | 'block';
|
||||
content: BaseContent;
|
||||
};
|
||||
|
||||
// Helper to stringify XML attributes cleanly
|
||||
function renderAttributes(attrs?: Record<string, string>): string {
|
||||
@@ -15,125 +24,176 @@ function renderAttributes(attrs?: Record<string, string>): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
export class Prompt<C = SystemPromptOptions> {
|
||||
private contents: Array<PromptContent<C>>;
|
||||
export function p<C = SystemPromptOptions>(
|
||||
strings: TemplateStringsArray,
|
||||
...values: Array<PromptContent<C>>
|
||||
): PromptContent<C> {
|
||||
const content = strings.reduce<Array<PromptContent<C>>>(
|
||||
(acc, str, i) => [...acc, str, values[i] ?? ''],
|
||||
[],
|
||||
);
|
||||
return { format: 'inline', content };
|
||||
}
|
||||
|
||||
constructor(...contents: Array<PromptContent<C>>) {
|
||||
this.contents = contents;
|
||||
}
|
||||
export interface RenderPromptOptions<C> {
|
||||
content: PromptContent<C> | Array<PromptContent<C>>;
|
||||
contributions?:
|
||||
| Record<string, PromptContent<C>>
|
||||
| Array<Record<string, PromptContent<C>>>;
|
||||
context: C;
|
||||
options?: { depth?: number };
|
||||
}
|
||||
|
||||
add(content: PromptContent<C>): void {
|
||||
this.contents.push(content);
|
||||
}
|
||||
export async function renderPrompt<C = SystemPromptOptions>({
|
||||
content,
|
||||
contributions,
|
||||
context,
|
||||
options,
|
||||
}: RenderPromptOptions<C>): Promise<string> {
|
||||
const contents = Array.isArray(content) ? content : [content];
|
||||
const _contributions: Record<string, Array<PromptContent<C>>> = {};
|
||||
|
||||
contribute(contributions: Record<string, PromptContent<C>>): void {
|
||||
const traverse = (node: PromptContent<C>) => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(traverse);
|
||||
} else if (typeof node === 'object' && node !== null) {
|
||||
// In this branch, node is a PromptSection<C>
|
||||
const section = node;
|
||||
const contribution = contributions[section.id ?? ''];
|
||||
if (section.id && contribution) {
|
||||
const content = section.content;
|
||||
if (Array.isArray(content)) {
|
||||
content.push(contribution);
|
||||
} else {
|
||||
section.content = [content, contribution];
|
||||
}
|
||||
}
|
||||
traverse(section.content);
|
||||
if (contributions) {
|
||||
const batches = Array.isArray(contributions)
|
||||
? contributions
|
||||
: [contributions];
|
||||
for (const batch of batches) {
|
||||
for (const [slot, c] of Object.entries(batch)) {
|
||||
_contributions[slot] = _contributions[slot] || [];
|
||||
_contributions[slot].push(c);
|
||||
}
|
||||
};
|
||||
|
||||
this.contents.forEach(traverse);
|
||||
}
|
||||
}
|
||||
|
||||
async render(context: C, options?: { depth?: number }): Promise<string> {
|
||||
const parts = await Promise.all(
|
||||
this.contents.map((item) => Prompt.renderContent(context, item, options)),
|
||||
);
|
||||
return parts.filter((part) => part.length > 0).join('\n\n');
|
||||
}
|
||||
|
||||
private static async renderContent<C>(
|
||||
context: C,
|
||||
content: PromptContent<C>,
|
||||
options?: { depth?: number },
|
||||
): Promise<string> {
|
||||
// 1. if function: run function with context and process result
|
||||
if (typeof content === 'function') {
|
||||
const resolved = await content(context);
|
||||
// keep passing options down so depth isn't lost
|
||||
return Prompt.renderContent(context, resolved, options);
|
||||
const resolveToBasic = async (
|
||||
c: PromptContent<C>,
|
||||
): Promise<BaseContent | null> => {
|
||||
if (typeof c === 'function') {
|
||||
const resolved = await c(context);
|
||||
return resolveToBasic(resolved);
|
||||
}
|
||||
|
||||
// 2. if string: simple string append
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
if (
|
||||
typeof c === 'string' ||
|
||||
typeof c === 'number' ||
|
||||
typeof c === 'boolean'
|
||||
) {
|
||||
return String(c);
|
||||
}
|
||||
|
||||
// 3. if array: process and concatenate each item in the array
|
||||
if (Array.isArray(content)) {
|
||||
const parts = await Promise.all(
|
||||
content.map((item) => Prompt.renderContent(context, item, options)),
|
||||
if (Array.isArray(c)) {
|
||||
const resolved = await Promise.all(c.map((item) => resolveToBasic(item)));
|
||||
const filtered = resolved.filter(
|
||||
(item): item is BaseContent => item !== null,
|
||||
);
|
||||
// Filter out empty strings to prevent huge gaps, then separate with double newline
|
||||
return parts.filter((part) => part.length > 0).join('\n\n');
|
||||
if (filtered.length === 0) return null;
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// 4. if object: process as section
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
if (content.condition) {
|
||||
const shouldRender = await content.condition(context);
|
||||
if (!shouldRender) {
|
||||
return '';
|
||||
}
|
||||
if (typeof c === 'object' && c !== null) {
|
||||
if ('slot' in c) {
|
||||
return c;
|
||||
}
|
||||
return Prompt.renderSection(context, content, options);
|
||||
}
|
||||
|
||||
return '';
|
||||
const section = c;
|
||||
if (section.condition) {
|
||||
const shouldRender = await section.condition(context);
|
||||
if (!shouldRender) return null;
|
||||
}
|
||||
const resolvedInner = await resolveToBasic(section.content);
|
||||
if (
|
||||
resolvedInner === null ||
|
||||
resolvedInner === '' ||
|
||||
(Array.isArray(resolvedInner) && resolvedInner.length === 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
heading: section.heading,
|
||||
tag: section.tag,
|
||||
attrs: section.attrs,
|
||||
format: section.format,
|
||||
content: resolvedInner,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolvedContents = await Promise.all(
|
||||
contents.map((c) => resolveToBasic(c)),
|
||||
);
|
||||
|
||||
const resolvedContributions: Record<string, BaseContent[]> = {};
|
||||
for (const [slot, slotContributions] of Object.entries(_contributions)) {
|
||||
const resolved = await Promise.all(
|
||||
slotContributions.map((c) => resolveToBasic(c)),
|
||||
);
|
||||
resolvedContributions[slot] = resolved.filter(
|
||||
(c): c is BaseContent => c !== null,
|
||||
);
|
||||
}
|
||||
|
||||
private static async renderSection<C>(
|
||||
context: C,
|
||||
section: PromptSection<C>,
|
||||
options?: { depth?: number },
|
||||
): Promise<string> {
|
||||
const depth = options?.depth ?? 1;
|
||||
// Standard Markdown headings max out at h6 (######)
|
||||
const headingLevel = Math.min(depth, 6);
|
||||
|
||||
// Pass context and increment depth for nested sections
|
||||
// section.content is an array, which renderPrompt already knows how to handle
|
||||
const innerContent = await Prompt.renderContent(context, section.content, {
|
||||
depth: depth + 1,
|
||||
});
|
||||
|
||||
if (!innerContent) {
|
||||
return '';
|
||||
const formatBasic = (
|
||||
c: BaseContent | null,
|
||||
depth: number,
|
||||
format: 'inline' | 'block',
|
||||
): string => {
|
||||
if (c === null) return '';
|
||||
if (typeof c === 'string') return c;
|
||||
if (Array.isArray(c)) {
|
||||
return c
|
||||
.map((item) => formatBasic(item, depth, format))
|
||||
.join(format === 'inline' ? '' : '\n\n');
|
||||
}
|
||||
if ('slot' in c) {
|
||||
const slotContributions = resolvedContributions[c.slot];
|
||||
if (!slotContributions || slotContributions.length === 0) return '';
|
||||
return formatBasic(slotContributions, depth, format);
|
||||
}
|
||||
|
||||
let result = '';
|
||||
const section = c;
|
||||
const sectionFormat = section.format || 'block';
|
||||
const innerContent = formatBasic(
|
||||
section.content,
|
||||
depth + 1,
|
||||
sectionFormat,
|
||||
).trim();
|
||||
if (!innerContent) return '';
|
||||
|
||||
if (section.heading) {
|
||||
result += `${'#'.repeat(headingLevel)} ${section.heading}\n\n`;
|
||||
}
|
||||
let result = innerContent;
|
||||
|
||||
if (section.tag) {
|
||||
const attrs = renderAttributes(section.attrs);
|
||||
result += `<${section.tag}${attrs}>\n${innerContent}\n</${section.tag}>`;
|
||||
} else {
|
||||
result += innerContent;
|
||||
result = `\n<${section.tag}${attrs}>\n${result}\n</${section.tag}>\n`;
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
if (section.heading) {
|
||||
const headingLevel = Math.min(depth, 6);
|
||||
result = `\n\n${'#'.repeat(headingLevel)} ${section.heading}\n\n${result.trim()}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const parts = resolvedContents
|
||||
.map((c) => formatBasic(c, options?.depth ?? 1, 'block'))
|
||||
.filter((p) => p !== null && p !== '');
|
||||
|
||||
const rawResult = parts.join('\n\n').trim();
|
||||
|
||||
// Normalize newlines: collapse 3+ consecutive newlines into exactly 2
|
||||
// but skip content inside markdown code fences (```)
|
||||
const segments = rawResult.split(/(```[\s\S]*?```)/);
|
||||
return segments
|
||||
.map((segment, index) => {
|
||||
// Even indices are outside code fences, odd indices are inside
|
||||
if (index % 2 === 0) {
|
||||
return segment.replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
return segment;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function prompt(
|
||||
content: PromptContent<SystemPromptOptions>,
|
||||
...content: Array<PromptContent<SystemPromptOptions>>
|
||||
): PromptContent<SystemPromptOptions> {
|
||||
return content;
|
||||
return content.length === 1 ? content[0] : content;
|
||||
}
|
||||
|
||||
@@ -6,20 +6,20 @@
|
||||
|
||||
export type ContextResolver<C, O> = O | ((ctx: C) => O | Promise<O>);
|
||||
|
||||
export type PromptSlot = { slot: string; content?: never };
|
||||
|
||||
export type PromptSection<C> = {
|
||||
/** Unique identifier for the section if it can receive contributions. */
|
||||
id?: string;
|
||||
/** Add a Markdown heading of appropriate level to this section. */
|
||||
heading?: string;
|
||||
/** If supplied, wrap this section in an XML tag. */
|
||||
tag?: string;
|
||||
/** If supplied, add attributes to the XML section tag. */
|
||||
attrs?: Record<string, string>;
|
||||
/** Formatting of the content inside this section. Defaults to 'block'. */
|
||||
format?: 'inline' | 'block';
|
||||
|
||||
/** Condition that must evaluate to true for the section to be rendered. */
|
||||
condition?: (ctx: C) => boolean | Promise<boolean>;
|
||||
|
||||
// Notice we use the generic <C> here so the children know about the context
|
||||
content: PromptContent<C>;
|
||||
};
|
||||
|
||||
@@ -27,5 +27,10 @@ export type PromptSection<C> = {
|
||||
// It wraps your 3 base node shapes (string, section, or array) in the resolver.
|
||||
export type PromptContent<C> = ContextResolver<
|
||||
C,
|
||||
string | PromptSection<C> | Array<PromptContent<C>>
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| PromptSection<C>
|
||||
| PromptSlot
|
||||
| Array<PromptContent<C>>
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user