Prompts
Displace\Infer\Prompt is the input to Model::chat(). It
represents an ordered list of role-tagged messages — system, user,
assistant — that the extension renders into whatever chat-template
format the underlying model expects. You never write <|im_start|> (or
its Llama 3 / Mistral / Gemma equivalent) by hand.
Two-stage construction
A Prompt starts with a factory — either system() or user() —
and grows via with* calls. Each with* returns a new Prompt;
the receiver is never modified.
use Displace\Infer\Prompt;
// Start with a system message:
$p = Prompt::system('You are a helpful assistant.')
->withUser('What is 2+2?');
// Or start with a user message (no system instruction):
$p = Prompt::user('Hello!');
// Multi-turn replays:
$p = Prompt::system('You are a poet.')
->withUser('Write a haiku about Rust.')
->withAssistant("Code runs cold and fast,\nMemory safe by the borrow,\nNo crashes today.")
->withUser('Now translate it to French.');
Direct new Prompt() is refused at runtime:
new Prompt();
// Displace\Infer\InferException: use Displace\Infer\Prompt::system()
// or Prompt::user() to start a prompt
Why immutable?
The shape mirrors DateTimeImmutable. Two practical consequences:
-
A
Promptyou’ve built once is safe to share across multiplechat()calls, hand to a queue worker, or stash in a class property. Nothing downstream can mutate it. -
Branching is free. The multi-turn chat recipe keeps a
$basePromptaround (system-message-only) so/resetcan drop conversation history without re-rendering the system prompt:$base = Prompt::system($systemMessage); $conversation = $base; // … many turns … if ($userTyped === '/reset') { $conversation = $base; // immutable; $base is untouched no // matter how many turns went through it }
Inspecting a Prompt
$p->messages(); // list<Displace\Infer\Message>
$p->count(); // int — number of messages
$p->isEmpty(); // bool
$p->lastRole(); // ?string — role of the most recent message, or null
Each Message is read-only:
foreach ($p->messages() as $msg) {
printf("[%s] %s\n", $msg->role(), $msg->content());
}
// [system] You are a helpful assistant.
// [user] What is 2+2?
role() is always one of 'system', 'user', or 'assistant'.
Method-name discipline on the construction side (withSystem,
withUser, withAssistant) keeps typos from creating fictional roles
at compile time.
Role ordering
ext-infer does not enforce role ordering at construction time. You
can build:
Prompt::user('hi')->withSystem('be terse'); // legal
Prompt::system('a')->withSystem('b'); // also legal
…and they will be rendered as written. Whether the model accepts the
result is a chat-template decision: most modern chat templates require
exactly one leading system message (or none) followed by alternating
user / assistant turns. Build sequences that match that convention
and the chat template will render them; deviate and you may get an
error from
Model::chat() at call time.
Composition patterns
Pre-baked system prompts
If your application has a few stock personalities, define them once:
final class Personas
{
public static function poet(): Prompt
{
return Prompt::system(
'You are a haiku poet. Respond in three lines. ' .
'Five syllables, then seven, then five.'
);
}
public static function reviewer(): Prompt
{
return Prompt::system(
'You review code. Always cite specific line numbers ' .
'and prefer questions over assertions when uncertain.'
);
}
}
$response = $model->chat(Personas::poet()->withUser('Tell me about autumn.'));
Because Prompt is immutable, returning a Prompt from a helper
method is safe — callers can’t mutate the cached base.
Replaying history
When you have stored history (e.g. fetched from a database), rebuild
the Prompt from scratch each turn:
$prompt = Prompt::system($systemMessage);
foreach ($historyFromDb as $row) {
$prompt = match ($row['role']) {
'user' => $prompt->withUser($row['content']),
'assistant' => $prompt->withAssistant($row['content']),
};
}
$prompt = $prompt->withUser($newUserInput);
This is the canonical multi-turn-chat shape. See the multi-turn chat recipe.
Feeding Response::answer() back, not text()
When you append the assistant’s reply to the prompt for the next turn,
use Response::answer() (reasoning stripped), not
Response::text():
$response = $model->chat($prompt);
$prompt = $prompt->withAssistant($response->answer());
// ^^^^^^^^^
// not ->text(), which includes <think>…</think>
Feeding <think> blocks back as conversation history derails reasoning
models — they see their own thoughts in the transcript and get
confused. See Reasoning models.
Next
- Chat completions — feeding a
Promptto the model. Model::raw()— when you want full control over the prompt string instead.