Skip to content

Enforce presence of <tool> name attribute #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 10, 2025
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "promptl-ai",
"version": "0.4.6",
"version": "0.4.7",
"author": "Latitude Data",
"license": "MIT",
"description": "Compiler for PromptL, the prompt language",
Expand Down
1 change: 1 addition & 0 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default [
'yaml',
'crypto',
'zod',
'fast-sha256',
],
},
{
Expand Down
26 changes: 26 additions & 0 deletions src/compiler/base/nodes/tags/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,4 +268,30 @@ describe('message contents', async () => {
},
])
})
it('parse <tool> tag', async () => {
const prompt = `
<tool name="tool1" id="1">Tool 1</tool>
<tool name="tool2" id="2">Tool 2</tool>
`
const result = await render({
prompt: removeCommonIndent(prompt),
parameters: {},
adapter: Adapters.default,
})
expect(result.messages.length).toBe(2)
expect(result.messages).toEqual([
{
toolId: '1',
toolName: 'tool1',
role: 'tool',
content: [{ type: 'text', text: 'Tool 1' }],
},
{
toolId: '2',
toolName: 'tool2',
role: 'tool',
content: [{ type: 'text', text: 'Tool 2' }],
},
])
})
})
6 changes: 6 additions & 0 deletions src/compiler/base/nodes/tags/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,14 @@ function buildMessage<R extends MessageRole>(
baseNodeError(errors.toolMessageWithoutId, node)
}

if (attributes.name === undefined) {
baseNodeError(errors.toolMessageWithoutName, node)
}

message.toolId = String(attributes.id)
message.toolName = String(attributes.name)
delete message['id']
delete message['name']
}

return message
Expand Down
26 changes: 26 additions & 0 deletions src/compiler/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,4 +725,30 @@ describe('syntax errors', async () => {

expect(metadata.errors.length).toBe(0)
})

it('throw error if tool does not have id', async () => {
const prompt = removeCommonIndent(`
<tool>Tool 1</tool>
`)

const metadata = await scan({ prompt })

expect(metadata.errors).toEqual([
new CompileError('Tool messages must have an id attribute'),
])
})

it('throw error if tool does not have name', async () => {
const prompt = removeCommonIndent(`
<tool id="1">Tool 1</tool>
`)

const metadata = await scan({ prompt })

expect(metadata.errors).toEqual([
new CompileError(
'Tool messages must have a name attribute equal to the tool name used in tool-call',
),
])
})
})
5 changes: 5 additions & 0 deletions src/compiler/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,11 @@ export class Scan {
return
}

if (role === MessageRole.tool && !attributes.has('name')) {
this.baseNodeError(errors.toolMessageWithoutName, node)
return
}

if (this.accumulatedToolCalls.length > 0) {
this.accumulatedToolCalls.forEach((toolCallNode) => {
this.baseNodeError(errors.invalidToolCallPlacement, toolCallNode)
Expand Down
5 changes: 5 additions & 0 deletions src/error/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ export default {
code: 'tool-message-without-id',
message: 'Tool messages must have an id attribute',
},
toolMessageWithoutName: {
code: 'tool-message-without-name',
message:
'Tool messages must have a name attribute equal to the tool name used in tool-call',
},
toolCallWithoutName: {
code: 'tool-call-without-name',
message: 'Tool calls must have a name attribute',
Expand Down
3 changes: 2 additions & 1 deletion src/providers/anthropic/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,15 @@ describe('Anthropic adapter', async () => {

it('adapts tool messages', async () => {
const prompt = removeCommonIndent(`
<tool id="1234">
<tool id="1234" name="temperature">
17ºC
</tool>
`)

const { messages } = await render({ prompt, adapter: Adapters.anthropic })
expect(messages).toEqual([
{
toolName: 'temperature',
role: MessageRole.user,
content: [
{
Expand Down
209 changes: 109 additions & 100 deletions src/providers/anthropic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,39 +84,46 @@ export const AnthropicAdapter: ProviderAdapter<AnthropicMessage> = {
toPromptl(
anthropicConversation: ProviderConversation<AnthropicMessage>,
): PromptlConversation {
const toolRequestsNamesById: Map<string, string> = new Map()
const { system: systemPrompt, ...restConfig } =
anthropicConversation.config as {
system:
| undefined
| string
| (AnthropicTextContent | AnthropicImageContent | AnthropicDocumentContent)[]
| undefined
| string
| (
| AnthropicTextContent
| AnthropicImageContent
| AnthropicDocumentContent
)[]
[key: string]: unknown
}

const systemMessages: PromptlSystemMessage[] = systemPrompt
? [
{
role: MessageRole.system,
content: Array.isArray(systemPrompt)
? systemPrompt.map((c) => {
if (c.type === AnthropicContentType.image) {
return toPromptlImage(c)
}
if (c.type === AnthropicContentType.document) {
return toPromptlFile(c)
}
return c as unknown as PromptlMessageContent
})
: [{ type: ContentType.text, text: systemPrompt }],
},
]
{
role: MessageRole.system,
content: Array.isArray(systemPrompt)
? systemPrompt.map((c) => {
if (c.type === AnthropicContentType.image) {
return toPromptlImage(c)
}
if (c.type === AnthropicContentType.document) {
return toPromptlFile(c)
}
return c as unknown as PromptlMessageContent
})
: [{ type: ContentType.text, text: systemPrompt }],
},
]
: []

return {
config: restConfig,
messages: [
...systemMessages,
...anthropicConversation.messages.map(anthropicToPromptl).flat(),
...anthropicConversation.messages
.map(anthropicToPromptl(toolRequestsNamesById))
.flat(),
],
}
},
Expand Down Expand Up @@ -149,7 +156,7 @@ function toPromptlImage(
}

function toAnthropicFile(
fileContent: PromptlFileContent,
fileContent: PromptlFileContent,
): AnthropicTextContent | AnthropicDocumentContent {
const { file, mimeType, ...rest } = fileContent

Expand Down Expand Up @@ -254,88 +261,90 @@ function promptlToAnthropic(message: PromptlMessage): AnthropicMessage {
throw new Error(`Unsupported message role: ${message.role}`)
}

function anthropicToPromptl(message: AnthropicMessage): PromptlMessage[] {
const messageContent: AnthropicMessageContent[] =
typeof message.content === 'string'
? [{ type: AnthropicContentType.text, text: message.content }]
: message.content
const anthropicToPromptl =
(toolRequestsNamesById: Map<string, string>) =>
(message: AnthropicMessage): PromptlMessage[] => {
const messageContent: AnthropicMessageContent[] =
typeof message.content === 'string'
? [{ type: AnthropicContentType.text, text: message.content }]
: message.content

if (message.role === MessageRole.assistant) {
return [
{
...message,
content: messageContent.map((c) => {
if (c.type === AnthropicContentType.image) return toPromptlImage(c)
if (c.type === AnthropicContentType.document) return toPromptlFile(c)
if (c.type === AnthropicContentType.tool_use) {
return {
type: ContentType.toolCall,
toolCallId: c.id,
toolName: c.name,
toolArguments: c.input,
} as PromptlToolCallContent
}
return c as unknown as PromptlMessageContent
}),
},
]
}
if (message.role === MessageRole.assistant) {
return [
{
...message,
content: messageContent.map((c) => {
if (c.type === AnthropicContentType.image) return toPromptlImage(c)
if (c.type === AnthropicContentType.document)
return toPromptlFile(c)
if (c.type === AnthropicContentType.tool_use) {
toolRequestsNamesById.set(c.id, c.name)
return {
type: ContentType.toolCall,
toolCallId: c.id,
toolName: c.name,
toolArguments: c.input,
} as PromptlToolCallContent
}
return c as unknown as PromptlMessageContent
}),
},
]
}

if (message.role === MessageRole.user) {
const { userMessage, toolMessages } = messageContent.reduce(
(
acc: {
userMessage: PromptlUserMessage
toolMessages: PromptlToolMessage[]
},
c,
) => {
if (c.type === AnthropicContentType.tool_result) {
const toolResponseContent = c.content
? Array.isArray(c.content)
? c.content.map((cc) => {
if (cc.type === AnthropicContentType.image)
return toPromptlImage(cc)
return cc as unknown as PromptlMessageContent
})
: [
{
type: ContentType.text,
text: c.content!,
} as PromptlTextContent,
]
: []
if (message.role === MessageRole.user) {
const { userMessage, toolMessages } = messageContent.reduce(
(
acc: {
userMessage: PromptlUserMessage
toolMessages: PromptlToolMessage[]
},
c,
) => {
if (c.type === AnthropicContentType.tool_result) {
const toolResponseContent = c.content
? Array.isArray(c.content)
? c.content.map((cc) => {
if (cc.type === AnthropicContentType.image)
return toPromptlImage(cc)
return cc as unknown as PromptlMessageContent
})
: [
{
type: ContentType.text,
text: c.content!,
} as PromptlTextContent,
]
: []

acc.toolMessages.push({
...message,
role: MessageRole.tool,
toolId: c.tool_use_id,
content: toolResponseContent,
})
}
else if (c.type === AnthropicContentType.image) {
acc.userMessage.content.push(toPromptlImage(c))
}
else if (c.type === AnthropicContentType.document) {
acc.userMessage.content.push(toPromptlFile(c))
}
else {
acc.userMessage.content.push(c as unknown as PromptlMessageContent)
}
return acc
},
{
userMessage: { ...message, role: MessageRole.user, content: [] },
toolMessages: [],
},
)
acc.toolMessages.push({
...message,
role: MessageRole.tool,
toolId: c.tool_use_id,
toolName: toolRequestsNamesById.get(c.tool_use_id) ?? '',
content: toolResponseContent,
})
} else if (c.type === AnthropicContentType.image) {
acc.userMessage.content.push(toPromptlImage(c))
} else if (c.type === AnthropicContentType.document) {
acc.userMessage.content.push(toPromptlFile(c))
} else {
acc.userMessage.content.push(c as unknown as PromptlMessageContent)
}
return acc
},
{
userMessage: { ...message, role: MessageRole.user, content: [] },
toolMessages: [],
},
)

return [
...toolMessages,
...(userMessage.content.length ? [userMessage] : []),
]
}
return [
...toolMessages,
...(userMessage.content.length ? [userMessage] : []),
]
}

//@ts-expect-error — There are no more supported roles. Typescript knows it and is yelling me back
throw new Error(`Unsupported message role: ${message.role}`)
}
//@ts-expect-error — There are no more supported roles. Typescript knows it and is yelling me back
throw new Error(`Unsupported message role: ${message.role}`)
}
3 changes: 2 additions & 1 deletion src/providers/openai/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('OpenAI adapter', async () => {

it('adapts tool messages', async () => {
const prompt = removeCommonIndent(`
<tool id="1234">
<tool id="1234" name="temperature">
17ºC
</tool>
`)
Expand All @@ -102,6 +102,7 @@ describe('OpenAI adapter', async () => {
{
role: MessageRole.tool,
tool_call_id: '1234',
toolName: 'temperature',
content: [{ type: 'text', text: '17ºC' }],
},
])
Expand Down
Loading