Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions genkit-tools/common/src/server/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const TOOLS_SERVER_ROUTER = (manager: BaseRuntimeManager) =>
model: input.model.replace('/model/', ''),
config: input.config,
tools: input.tools?.map((toolDefinition) => toolDefinition.name),
use: input.use,
};
return fromMessages(frontmatter, input.messages);
}),
Expand Down
2 changes: 2 additions & 0 deletions genkit-tools/common/src/types/apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
InferenceDatasetSchema,
} from './eval';
import { LogRecordSchema } from './log';
import { MiddlewareRefSchema } from './middleware';
import {
GenerationCommonConfigSchema,
MessageSchema,
Expand Down Expand Up @@ -170,6 +171,7 @@ export const CreatePromptRequestSchema = z.object({
messages: z.array(MessageSchema),
config: GenerationCommonConfigSchema.passthrough().optional(),
tools: z.array(ToolDefinitionSchema).optional(),
use: z.array(MiddlewareRefSchema).optional(),
});

export type CreatePromptRequest = z.infer<typeof CreatePromptRequestSchema>;
Expand Down
2 changes: 2 additions & 0 deletions genkit-tools/common/src/types/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
//

import { z } from 'zod';
import { MiddlewareRefSchema } from './middleware';
import { GenerationCommonConfigSchema } from './model';

/**
Expand All @@ -30,6 +31,7 @@ export const PromptFrontmatterSchema = z.object({
variant: z.string().optional(),
model: z.string().optional(),
tools: z.array(z.string()).optional(),
use: z.array(MiddlewareRefSchema).optional(),
candidates: z.number().optional(),
config: GenerationCommonConfigSchema.passthrough().optional(),
input: z
Expand Down
117 changes: 101 additions & 16 deletions genkit-tools/common/src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,115 @@ export function fromMessages(
frontmatter: PromptFrontmatter,
messages: MessageData[]
): string {
let renderedMessages = '';
const cleanFrontmatter = cleanupFrontmatter(frontmatter);
const { rendered: renderedMessages, anyOmitted } = renderMessages(messages);

const header = `---
${stringify(cleanFrontmatter, {
collectionStyle: 'block',
aliasDuplicateObjects: false,
}).trim()}
---`;

if (anyOmitted) {
return (
`${header}

{{! Some advanced message types, such as tool requests/responses, have been omitted from the history. See comments inline for more details. }}

${renderedMessages}`.trimEnd() + '\n'
);
}

return (
`${header}

${renderedMessages}`.trimEnd() + '\n'
);
}

/**
* Renders an array of message data into a Dotprompt template string.
*/
function renderMessages(messages: MessageData[]): {
rendered: string;
anyOmitted: boolean;
} {
let anyOmitted = false;
let rendered = '';

messages.forEach((message) => {
renderedMessages += `{{role "${message.role}"}}\n`;
renderedMessages += message.content.map(partToString);
renderedMessages += '\n\n';
const hasToolRequest = message.content.some((p) => 'toolRequest' in p);
const hasToolResponse = message.content.some((p) => 'toolResponse' in p);
const hasSupportedPart =
message.content.length === 0 ||
message.content.some((p) => 'text' in p || 'media' in p);
const hasUnsupportedPart = message.content.some(
(p) => !('text' in p) && !('media' in p)
);

if (hasToolRequest || hasToolResponse || !hasSupportedPart) {
anyOmitted = true;
let reason = 'unsupported content';
if (hasToolRequest) {
reason = 'toolRequest';
} else if (hasToolResponse) {
reason = 'toolResponse';
}
rendered += `{{! message with role "${message.role}" omitted (${reason}). }}\n\n`;
} else {
if (hasUnsupportedPart) {
anyOmitted = true;
}
rendered += `{{role "${message.role}"}}\n`;
rendered += message.content.map(partToString).join('');
rendered += '\n\n';
}
});

return `---
${stringify(frontmatter)}
---
return { rendered, anyOmitted };
}

/**
* Removes empty arrays, empty objects, and null/undefined values from the
* frontmatter to ensure the generated YAML is clean and idiomatic.
*/
function cleanupFrontmatter(frontmatter: PromptFrontmatter): any {
return recursiveCleanup(frontmatter) || {};
}

${renderedMessages}`;
function recursiveCleanup(val: any): any {
if (Array.isArray(val)) {
const cleaned = val
.map(recursiveCleanup)
.filter((v) => v !== undefined && v !== null);
return cleaned.length > 0 ? cleaned : undefined;
}
if (val !== null && typeof val === 'object' && !(val instanceof Date)) {
const cleaned: any = {};
let hasProps = false;
for (const key in val) {
const v = recursiveCleanup(val[key]);
if (v !== undefined && v !== null) {
cleaned[key] = v;
hasProps = true;
}
}
Comment thread
MichaelDoyle marked this conversation as resolved.
return hasProps ? cleaned : undefined;
}
return val === null || val === undefined ? undefined : val;
}

function partToString(part: Part): string {
if (part.text) {
if ('text' in part && part.text !== undefined) {
return part.text;
} else if (part.media) {
} else if ('media' in part && part.media !== undefined) {
return `{{media url:${part.media.url}}}`;
} else if (part.toolRequest) {
return '<< tool request omitted >>';
} else if (part.toolResponse) {
return '<< tool response omitted >>';
} else {
return '';
}

const type =
Object.keys(part).find(
(k) => k !== 'metadata' && part[k as keyof Part] !== undefined
) || 'unknown';
return `{{! ${type} part omitted }}`;
}
133 changes: 129 additions & 4 deletions genkit-tools/common/tests/utils/prompt_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,141 @@ describe('fromMessages', () => {
'model: googleai/gemini-pro\n' +
'config:\n' +
' temperature: 0.5\n' +
'\n' +
'---\n' +
'\n' +
'{{role "user"}}\n' +
'Who are you?\n' +
'\n' +
'{{role "model"}}\n' +
'I am Oz -- the Great and Powerful.,' +
'{{media url:https://example.com/image.jpg}}\n' +
'\n';
'I am Oz -- the Great and Powerful.{{media url:https://example.com/image.jpg}}\n';
expect(fromMessages(frontmatter, messages)).toStrictEqual(expected);
});

it('handles toolRequest by omitting the entire message', () => {
const frontmatter: PromptFrontmatter = {
model: 'googleai/gemini-pro',
use: [{ name: 'test-middleware', config: { foo: 'bar' } }],
};
const messages: MessageData[] = [
{
role: 'user',
content: [
{ text: 'Hello' },
{ reasoning: 'Thinking...' } as any,
{ toolRequest: { name: 'myTool' } } as any,
],
},
];

const expected =
'---\n' +
'model: googleai/gemini-pro\n' +
'use:\n' +
' - name: test-middleware\n' +
' config:\n' +
' foo: bar\n' +
'---\n' +
'\n' +
'{{! Some advanced message types, such as tool requests/responses, have been omitted from the history. See comments inline for more details. }}\n' +
'\n' +
'{{! message with role "user" omitted (toolRequest). }}\n';

expect(fromMessages(frontmatter, messages)).toStrictEqual(expected);
});

it('omits messages entirely composed of unsupported parts', () => {
const frontmatter: PromptFrontmatter = { model: 'model' };
const messages: MessageData[] = [
{
role: 'model',
content: [
{ toolResponse: { name: 'myTool', output: 'result' } } as any,
],
},
];

const expected =
'---\n' +
'model: model\n' +
'---\n' +
'\n' +
'{{! Some advanced message types, such as tool requests/responses, have been omitted from the history. See comments inline for more details. }}\n' +
'\n' +
'{{! message with role "model" omitted (toolResponse). }}\n';

expect(fromMessages(frontmatter, messages)).toStrictEqual(expected);
});

it('omits messages composed of other unsupported parts with "unsupported content" reason', () => {
const frontmatter: PromptFrontmatter = { model: 'model' };
const messages: MessageData[] = [
{
role: 'model',
content: [{ reasoning: 'Thinking...' } as any],
},
];

const expected =
'---\n' +
'model: model\n' +
'---\n' +
'\n' +
'{{! Some advanced message types, such as tool requests/responses, have been omitted from the history. See comments inline for more details. }}\n' +
'\n' +
'{{! message with role "model" omitted (unsupported content). }}\n';

expect(fromMessages(frontmatter, messages)).toStrictEqual(expected);
});

it('handles mixed support messages without toolRequest by commenting parts', () => {
const frontmatter: PromptFrontmatter = { model: 'model' };
const messages: MessageData[] = [
{
role: 'user',
content: [
{ text: 'Here is data: ' },
{ data: { foo: 'bar' } } as any,
{ text: ' and more text.' },
],
},
];

const expected =
'---\n' +
'model: model\n' +
'---\n' +
'\n' +
'{{! Some advanced message types, such as tool requests/responses, have been omitted from the history. See comments inline for more details. }}\n' +
'\n' +
'{{role "user"}}\n' +
'Here is data: {{! data part omitted }} and more text.\n';

expect(fromMessages(frontmatter, messages)).toStrictEqual(expected);
});

it('recursively cleans empty objects and arrays from frontmatter', () => {
const frontmatter: any = {
model: 'googleai/gemini-pro',
use: [
{
name: 'fallback',
config: {},
},
],
tools: [],
config: {
safetySettings: [],
},
};
const messages: any[] = [];

const expected =
'---\n' +
'model: googleai/gemini-pro\n' +
'use:\n' +
' - name: fallback\n' +
'---\n';

expect(fromMessages(frontmatter, messages)).toStrictEqual(expected);
});
});
Loading