diff --git a/CLAUDE.md b/CLAUDE.md index 33094d46..ed48143b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -554,7 +554,7 @@ Typed arrays: `let nums: array = [1, 2, 3];` --- -## Standard Library (42 modules) +## Standard Library (43 modules) Import with `@stdlib/` prefix: ```hemlock @@ -592,6 +592,7 @@ import { TcpStream, UdpSocket } from "@stdlib/net"; | `os` | platform, arch, cpu_count, hostname | | `path` | File path manipulation | | `process` | fork, exec, wait, kill | +| `prompt` | LLM prompt templating, chat formats, conversation builder | | `random` | Random number generation | | `regex` | compile, test (POSIX ERE) | | `retry` | Retry logic with backoff | @@ -979,7 +980,7 @@ make parity - Manual memory management with `talloc()` and `sizeof()` - Async/await with true pthread parallelism - Atomic operations for lock-free concurrent programming -- 42 stdlib modules (+ arena, assert, semver, toml, retry, iter, random, shell, termios, vector) +- 43 stdlib modules (+ arena, assert, semver, toml, retry, iter, random, shell, termios, vector, prompt) - FFI for C interop with `export extern fn` for reusable library wrappers - FFI struct support in compiler (pass C structs by value) - FFI pointer helpers (`ptr_null`, `ptr_read_*`, `ptr_write_*`) diff --git a/stdlib/docs/prompt.md b/stdlib/docs/prompt.md new file mode 100644 index 00000000..afbd0746 --- /dev/null +++ b/stdlib/docs/prompt.md @@ -0,0 +1,329 @@ +# @stdlib/prompt - LLM Prompt Templating + +## Overview + +The prompt module provides utilities for building multi-turn LLM conversations, applying chat templates, and managing message arrays. It uses `@stdlib/jinja` under the hood for template rendering. + +## Quick Start + +```hemlock +import { system, user, format_chat } from "@stdlib/prompt"; + +let msgs = [ + system("You are a helpful assistant."), + user("What is 2 + 2?") +]; + +let prompt = format_chat(msgs, "chatml"); +print(prompt); +// <|im_start|>system +// You are a helpful assistant.<|im_end|> +// <|im_start|>user +// What is 2 + 2?<|im_end|> +// <|im_start|>assistant +``` + +## API Reference + +### Message Constructors + +#### `message(role: string, content: string): object` + +Create a message with any role. + +```hemlock +let msg = message("system", "You are helpful."); +print(msg.role); // "system" +print(msg.content); // "You are helpful." +``` + +#### `system(content: string): object` + +Create a system message. + +```hemlock +let msg = system("You are a coding assistant."); +``` + +#### `user(content: string): object` + +Create a user message. + +```hemlock +let msg = user("Write a function to add two numbers."); +``` + +#### `assistant(content: string): object` + +Create an assistant message. + +```hemlock +let msg = assistant("Here's a function that adds two numbers..."); +``` + +#### `tool(content: string, name?: string): object` + +Create a tool/function result message with optional tool name. + +```hemlock +let msg = tool("42", "calculator"); +print(msg.role); // "tool" +print(msg.name); // "calculator" +``` + +### Template Formatting + +#### `format_chat(messages: array, template_name: string): string` + +Format a message array with a named built-in template. + +**Parameters:** +- `messages` - Array of message objects (each with `.role` and `.content`) +- `template_name` - One of: `chatml`, `llama3`, `alpaca`, `vicuna`, `zephyr`, `gemma`, `plain` + +**Returns:** Formatted prompt string + +```hemlock +import { system, user, assistant, format_chat } from "@stdlib/prompt"; + +let msgs = [ + system("You are helpful."), + user("Hi!"), + assistant("Hello!"), + user("How are you?") +]; + +// ChatML format +print(format_chat(msgs, "chatml")); + +// Llama 3 format +print(format_chat(msgs, "llama3")); + +// Plain text format +print(format_chat(msgs, "plain")); +``` + +#### `apply_template(template: string, messages: array, context?: object): string` + +Apply a custom Jinja template to a message array. The `messages` variable is automatically available in the template. Additional variables can be passed via `context`. + +```hemlock +import { system, user, apply_template } from "@stdlib/prompt"; + +let tmpl = "{% for msg in messages %}[{{ msg.role|upper }}] {{ msg.content }}\n{% endfor %}"; +let msgs = [system("Be brief."), user("Hello!")]; + +print(apply_template(tmpl, msgs)); +// [SYSTEM] Be brief. +// [USER] Hello! + +// With extra context +let tmpl2 = "Model: {{ model }}\n{% for msg in messages %}{{ msg.content }}\n{% endfor %}"; +print(apply_template(tmpl2, msgs, { model: "gpt-4" })); +``` + +### Conversation Builder + +#### `conversation(template_name?: string): object` + +Create a conversation builder for incremental message construction. Defaults to `"chatml"` template. + +**Methods:** +- `add_system(content)` - Add a system message (returns self for chaining) +- `add_user(content)` - Add a user message (returns self for chaining) +- `add_assistant(content)` - Add an assistant message (returns self for chaining) +- `add_message(role, content)` - Add a message with any role (returns self for chaining) +- `format()` - Format all messages using the configured template +- `format_with(template)` - Format using a custom Jinja template +- `count()` - Get the number of messages +- `last_message()` - Get the last message object (or null) +- `reset()` - Remove all messages (returns self for chaining) +- `by_role(role)` - Get messages filtered by role + +```hemlock +import { conversation } from "@stdlib/prompt"; + +let conv = conversation("chatml"); +conv.add_system("You are a math tutor."); +conv.add_user("What is calculus?"); +conv.add_assistant("Calculus is the study of continuous change..."); +conv.add_user("Can you give an example?"); + +print(conv.count()); // 4 +print(conv.last_message().role); // "user" +print(conv.format()); // Full ChatML-formatted prompt + +// Filter by role +let user_msgs = conv.by_role("user"); +print(user_msgs.length); // 2 +``` + +### Utility Functions + +#### `templates(): array` + +List all available built-in template names. + +```hemlock +import { templates } from "@stdlib/prompt"; + +for (name in templates()) { + print(name); +} +// chatml, llama3, alpaca, vicuna, zephyr, gemma, plain +``` + +#### `get_template_source(name: string): string` + +Get the raw Jinja template string for a built-in template. Useful for inspecting or modifying templates. + +```hemlock +import { get_template_source } from "@stdlib/prompt"; + +let tmpl = get_template_source("chatml"); +print(tmpl); +``` + +## Built-in Templates + +### ChatML (`chatml`) + +Used by OpenAI, Qwen, Yi, and many other models. + +``` +<|im_start|>system +{content}<|im_end|> +<|im_start|>user +{content}<|im_end|> +<|im_start|>assistant +``` + +### Llama 3 (`llama3`) + +Used by Meta's Llama 3 and Llama 3.1 models. + +``` +<|start_header_id|>system<|end_header_id|> + +{content}<|eot_id|><|start_header_id|>user<|end_header_id|> + +{content}<|eot_id|><|start_header_id|>assistant<|end_header_id|> + +``` + +### Alpaca (`alpaca`) + +Instruction-following format used by Stanford Alpaca. + +``` +### Instruction: +{system content} + +### Input: +{user content} + +### Response: +``` + +### Vicuna (`vicuna`) + +Used by Vicuna/LMSys models. + +``` +{system content} + +USER: {user content} +ASSISTANT: +``` + +### Zephyr (`zephyr`) + +Used by HuggingFace Zephyr (Mistral-based). + +``` +<|system|> +{content}<|endoftext|> +<|user|> +{content}<|endoftext|> +<|assistant|> +``` + +### Gemma (`gemma`) + +Used by Google Gemma models. System messages are formatted as user turns. + +``` +user +{content} +model +``` + +### Plain (`plain`) + +Simple text format with no special tokens, useful for debugging. + +``` +[system]: {content} +[user]: {content} +[assistant]: +``` + +## Examples + +### Multi-turn Conversation + +```hemlock +import { conversation } from "@stdlib/prompt"; + +let conv = conversation("chatml"); +conv.add_system("You are a Python expert."); + +// Simulate a multi-turn conversation +conv.add_user("How do I read a file in Python?"); +conv.add_assistant("Use open() with a context manager:\n```python\nwith open('file.txt') as f:\n content = f.read()\n```"); +conv.add_user("What about binary files?"); + +print(conv.format()); +``` + +### Custom Template for Tool Use + +```hemlock +import { system, user, tool, apply_template } from "@stdlib/prompt"; + +let tmpl = `{%- for msg in messages -%} +{%- if msg.role == 'system' -%} +<|system|>{{ msg.content }} +{%- elif msg.role == 'user' -%} +<|user|>{{ msg.content }} +{%- elif msg.role == 'tool' -%} +<|tool|>[{{ msg.name }}] {{ msg.content }} +{%- endif -%} +{%- endfor -%} +<|assistant|>`; + +let msgs = [ + system("You can use tools."), + user("What is 6 * 7?"), + tool("42", "calculator") +]; + +print(apply_template(tmpl, msgs)); +``` + +### Comparing Template Formats + +```hemlock +import { system, user, format_chat, templates } from "@stdlib/prompt"; + +let msgs = [ + system("You are helpful."), + user("Hello!") +]; + +for (name in templates()) { + print("=== " + name + " ==="); + print(format_chat(msgs, name)); + print(""); +} +``` diff --git a/stdlib/prompt.hml b/stdlib/prompt.hml new file mode 100644 index 00000000..8ceffaa7 --- /dev/null +++ b/stdlib/prompt.hml @@ -0,0 +1,244 @@ +// @stdlib/prompt - LLM prompt templating and conversation management +// +// Provides utilities for building multi-turn LLM conversations, +// applying chat templates, and managing system/user/assistant messages. +// +// Usage: +// import { message, system, user, assistant, format_chat, apply_template } from "@stdlib/prompt"; +// +// let msgs = [system("You are helpful."), user("Hello!")]; +// let prompt = format_chat(msgs, "chatml"); +// print(prompt); +// +// Built-in templates: chatml, llama3, alpaca, vicuna, zephyr, gemma, plain +// +// Custom templates use Jinja syntax via @stdlib/jinja. + +import { render } from "@stdlib/jinja"; + +// ============================================================================ +// Message Constructors +// ============================================================================ + +// Create a message with a given role and content +export fn message(role, content): object { + if (typeof(role) != "string") { + throw "message() role must be a string"; + } + if (typeof(content) != "string") { + content = "" + content; + } + return { role: role, content: content }; +} + +// Create a system message +export fn system(content): object { + return message("system", content); +} + +// Create a user message +export fn user(content): object { + return message("user", content); +} + +// Create an assistant message +export fn assistant(content): object { + return message("assistant", content); +} + +// Create a tool message with optional tool name +export fn tool(content, name?: null): object { + let msg = message("tool", content); + if (name != null) { + msg["name"] = name; + } + return msg; +} + +// ============================================================================ +// Built-in Chat Templates (Jinja syntax) +// ============================================================================ + +// ChatML format (OpenAI, Qwen, Yi, etc.) +let CHATML = "{% for msg in messages %}<|im_start|>{{ msg.role }}\n{{ msg.content }}<|im_end|>\n{% endfor %}<|im_start|>assistant\n"; + +// Llama 3 / Llama 3.1 format +let LLAMA3 = "{% for msg in messages %}<|start_header_id|>{{ msg.role }}<|end_header_id|>\n\n{{ msg.content }}<|eot_id|>{% endfor %}<|start_header_id|>assistant<|end_header_id|>\n\n"; + +// Alpaca format (instruction-following) +let ALPACA_TMPL = "{% for msg in messages %}{% if msg.role == 'system' %}### Instruction:\n{{ msg.content }}\n\n{% elif msg.role == 'user' %}### Input:\n{{ msg.content }}\n\n{% elif msg.role == 'assistant' %}### Response:\n{{ msg.content }}\n\n{% endif %}{% endfor %}### Response:\n"; + +// Vicuna format +let VICUNA = "{% for msg in messages %}{% if msg.role == 'system' %}{{ msg.content }}\n\n{% elif msg.role == 'user' %}USER: {{ msg.content }}\n{% elif msg.role == 'assistant' %}ASSISTANT: {{ msg.content }}\n{% endif %}{% endfor %}ASSISTANT:"; + +// Zephyr format (Mistral) +let ZEPHYR = "{% for msg in messages %}<|{{ msg.role }}|>\n{{ msg.content }}<|endoftext|>\n{% endfor %}<|assistant|>\n"; + +// Gemma format +let GEMMA = "{% for msg in messages %}{% if msg.role == 'user' %}user\n{{ msg.content }}\n{% elif msg.role == 'assistant' %}model\n{{ msg.content }}\n{% elif msg.role == 'system' %}user\n{{ msg.content }}\n{% endif %}{% endfor %}model\n"; + +// Plain text format (no special tokens) +let PLAIN = "{% for msg in messages %}[{{ msg.role }}]: {{ msg.content }}\n{% endfor %}[assistant]:"; + +fn get_template(name): string { + if (name == "chatml") { + return CHATML; + } + if (name == "llama3") { + return LLAMA3; + } + if (name == "alpaca") { + return ALPACA_TMPL; + } + if (name == "vicuna") { + return VICUNA; + } + if (name == "zephyr") { + return ZEPHYR; + } + if (name == "gemma") { + return GEMMA; + } + if (name == "plain") { + return PLAIN; + } + throw "Unknown chat template: " + name + ". Available: chatml, llama3, alpaca, vicuna, zephyr, gemma, plain"; +} + +// ============================================================================ +// Template Formatting +// ============================================================================ + +// Format a message array with a named built-in template +// Parameters: +// messages: array of message objects (each with .role and .content) +// template_name: string - one of: chatml, llama3, alpaca, vicuna, zephyr, gemma, plain +// Returns: formatted prompt string +export fn format_chat(messages, template_name): string { + if (typeof(messages) != "array") { + throw "format_chat() messages must be an array"; + } + if (typeof(template_name) != "string") { + throw "format_chat() template_name must be a string"; + } + let tmpl = get_template(template_name); + return render(tmpl, { messages: messages }); +} + +// Apply a custom Jinja template to a message array +// Parameters: +// template: string - Jinja template string +// messages: array of message objects +// context: object - additional variables available in the template (optional) +// Returns: rendered string +export fn apply_template(template, messages, context?: null): string { + if (typeof(template) != "string") { + throw "apply_template() template must be a string"; + } + if (typeof(messages) != "array") { + throw "apply_template() messages must be an array"; + } + let ctx = {}; + if (context != null) { + if (typeof(context) != "object") { + throw "apply_template() context must be an object"; + } + let keys = context.keys(); + for (key in keys) { + ctx[key] = context[key]; + } + } + ctx["messages"] = messages; + return render(template, ctx); +} + +// ============================================================================ +// Conversation Builder +// ============================================================================ + +// Create a conversation builder for incremental message construction +// Parameters: +// template_name: string - built-in template name (default: "chatml") +// Returns: conversation object with methods +export fn conversation(template_name?: "chatml"): object { + let msgs: array = []; + let tmpl_name = template_name; + + let conv = { + messages: msgs, + template_name: tmpl_name + }; + + // Add methods after conv is declared so closures can reference it + conv["add_system"] = fn(content) { + msgs.push(system(content)); + return conv; + }; + + conv["add_user"] = fn(content) { + msgs.push(user(content)); + return conv; + }; + + conv["add_assistant"] = fn(content) { + msgs.push(assistant(content)); + return conv; + }; + + conv["add_message"] = fn(role, content) { + msgs.push(message(role, content)); + return conv; + }; + + conv["format"] = fn(): string { + return format_chat(msgs, tmpl_name); + }; + + conv["format_with"] = fn(custom_template): string { + return apply_template(custom_template, msgs); + }; + + conv["count"] = fn(): i32 { + return msgs.length; + }; + + conv["last_message"] = fn() { + if (msgs.length == 0) { + return null; + } + return msgs[msgs.length - 1]; + }; + + conv["reset"] = fn() { + while (msgs.length > 0) { + msgs.pop(); + } + return conv; + }; + + conv["by_role"] = fn(role): array { + let filtered: array = []; + for (msg in msgs) { + if (msg.role == role) { + filtered.push(msg); + } + } + return filtered; + }; + + return conv; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +// List all available built-in template names +export fn templates(): array { + return ["chatml", "llama3", "alpaca", "vicuna", "zephyr", "gemma", "plain"]; +} + +// Get the raw Jinja template string for a built-in template +export fn get_template_source(name): string { + return get_template(name); +} diff --git a/tests/parity/modules/stdlib_prompt.expected b/tests/parity/modules/stdlib_prompt.expected new file mode 100644 index 00000000..35b76b5b --- /dev/null +++ b/tests/parity/modules/stdlib_prompt.expected @@ -0,0 +1,44 @@ +system +You are helpful. +user +Hello! +assistant +Hi there! +custom +test +tool +result +calc +[system]: Be brief. +[user]: Hi! +[assistant]: +<|im_start|>system +Be brief.<|im_end|> +<|im_start|>user +Hi!<|im_end|> +<|im_start|>assistant + +(system) Be brief. +(user) Hi! + +Model: test-model +Be brief. +Hi! + +4 +user +Follow-up? +2 +[system]: System prompt. +[user]: Question? +[assistant]: Answer. +[user]: Follow-up? +[assistant]: +0 +7 +chatml +plain +true +tool +data +done diff --git a/tests/parity/modules/stdlib_prompt.hml b/tests/parity/modules/stdlib_prompt.hml new file mode 100644 index 00000000..480340bf --- /dev/null +++ b/tests/parity/modules/stdlib_prompt.hml @@ -0,0 +1,93 @@ +// Test @stdlib/prompt module + +import { message, system, user, assistant, tool, format_chat, apply_template, conversation, templates, get_template_source } from "@stdlib/prompt"; + +// === Message constructors === + +let sys = system("You are helpful."); +print(sys.role); +print(sys.content); + +let usr = user("Hello!"); +print(usr.role); +print(usr.content); + +let ast = assistant("Hi there!"); +print(ast.role); +print(ast.content); + +let msg = message("custom", "test"); +print(msg.role); +print(msg.content); + +let tl = tool("result", "calc"); +print(tl.role); +print(tl.content); +print(tl.name); + +// === format_chat with plain template === + +let msgs = [system("Be brief."), user("Hi!")]; +let plain = format_chat(msgs, "plain"); +print(plain); + +// === format_chat with chatml === + +let chatml = format_chat(msgs, "chatml"); +print(chatml); + +// === apply_template with custom template === + +let custom_tmpl = "{% for msg in messages %}({{ msg.role }}) {{ msg.content }}\n{% endfor %}"; +let custom = apply_template(custom_tmpl, msgs); +print(custom); + +// === apply_template with extra context === + +let ctx_tmpl = "Model: {{ model_name }}\n{% for msg in messages %}{{ msg.content }}\n{% endfor %}"; +let ctx_result = apply_template(ctx_tmpl, msgs, { model_name: "test-model" }); +print(ctx_result); + +// === conversation builder === + +let conv = conversation("plain"); +conv.add_system("System prompt."); +conv.add_user("Question?"); +conv.add_assistant("Answer."); +conv.add_user("Follow-up?"); + +print(conv.count()); +print(conv.last_message().role); +print(conv.last_message().content); + +// by_role +let user_msgs = conv.by_role("user"); +print(user_msgs.length); + +// format +let formatted = conv.format(); +print(formatted); + +// reset +conv.reset(); +print(conv.count()); + +// === templates list === + +let tmpl_list = templates(); +print(tmpl_list.length); +print(tmpl_list[0]); +print(tmpl_list[6]); + +// === get_template_source === + +let src = get_template_source("plain"); +print(src.contains("msg.role")); + +// === tool message without name === + +let tl2 = tool("data"); +print(tl2.role); +print(tl2.content); + +print("done");