Date: 2026-05-03
This paper proposes a two-layer prompt architecture inspired by DSPy's
"programming, not prompting" framing, but designed for Tainie's existing stack
rather than as a DSPy dependency. The first layer is a general-purpose
prompt() template function for Pydantic AI users. It treats Python 3.14
t-strings as structured prompt templates, preserves the boundary between
author-written text and runtime values, and renders safe system prompts,
instructions, tool descriptions, examples, and schema notes.
The second layer is Tainie-specific. It extends the same prompt IR into a local agent compiler that understands left-hand-side (LHS) code intelligence, domain knowledge packs, Themester fixtures, tool capability manifests, eval rubrics, episode capture, and future code mode. The goal is not prettier prompt strings. The goal is prompt construction with invariants: trust boundaries, provenance, tool coupling, stable cache prefixes, schema-aware output contracts, and fixture-measurable changes. A key Tainie-specific extension is component-style prompt composition, borrowing from tdom's component syntax: large agent prompts can decompose into helpers with typed props, nested children, dependency-aware rendering, and testable responsibilities.
PEP 750 t-strings evaluate to string.templatelib.Template instead of str.
Each interpolation remains visible to the template processor as an object with
the runtime value, source expression, conversion, and format specification. That
is the property normal f-strings lack: a library can inspect and transform
runtime values before combining them with author-controlled literal text.
DSPy is useful inspiration because it separates task contracts, modules, examples, metrics, and optimization from hand-authored prompt strings. Tainie does not need to adopt DSPy to borrow that lesson. Tainie already has a Pydantic AI runtime, typed outputs, local tools, an eval harness, and a research roadmap around LHS, Themester fixtures, constrained output, episode capture, and code mode. A t-string prompt DSL can become the small compiler layer that joins those pieces.
Pydantic AI gives Python applications a typed runtime for agents, dependencies,
tools, and structured output. What it does not currently provide is a
Python-native prompt construction surface with explicit trust boundaries. A
general prompt() function can fill that gap without competing with Pydantic
AI's agent model.
The basic API should be small:
from pydantic_prompt import prompt
system_prompt = prompt(t"""
You are a support assistant.
User account:
{account_summary:trusted_text}
User request:
{user_request:untrusted_text}
Return:
{SupportOutcome:schema}
""")The function accepts a Template and returns a Prompt object. The object can
render to text for Agent(system_prompt=...), provide a debug tree for tests,
and expose metadata about dynamic slots, schemas, trust levels, and prompt
budget.
The portable layer should avoid a large grammar. It can start with four concepts:
Prompt: a validated prompt artifact.TextPart: literal author-controlled text from the t-string.Slot: one interpolation, including value, expression, conversion, and format specification.RenderContext: target-specific settings such as output mode, available tools, prompt budget, and redaction policy.
The format specification is the first policy channel:
prompt(t"Read file {path:path} and answer {question:untrusted_text}")
prompt(t"Use this schema: {TaskOutcome:schema}")
prompt(t"Tool available: {rank_relevant_files:tool}")
prompt(t"Evidence:\n{retrieval_packet:evidence}")This keeps the authoring model familiar. The t-string still looks like Python, but each dynamic slot is handled by a named policy.
The portable prompt() function should ship with a conservative policy set:
| Policy | Purpose |
|---|---|
:trusted_text |
Insert maintained application text with normalization only. |
:untrusted_text |
Fence or quote user-controlled text so it cannot masquerade as instructions. |
:path |
Validate a path-like value and render it without newline or placeholder hazards. |
:identifier |
Accept only Python-identifier-shaped values. |
:json |
Render JSON with stable ordering and no trailing prose. |
:schema |
Render a Pydantic model or JSON schema summary. |
:tool |
Render a tool name or tool description from a registered manifest. |
:evidence |
Render retrieved evidence with source labels and boundaries. |
:example |
Render a labeled example with explicit input/output sections. |
Applications can register more policies. A policy should be a small function
from Slot and RenderContext to a rendered fragment plus metadata. Policies
may reject values. Rejection should be a feature, not an exception path to hide:
prompt construction is where many injection and placeholder bugs are cheapest
to catch.
Every interpolation should be classified as trusted, untrusted, schema, tool,
evidence, or example material. If a slot has no policy, prompt() should fail
closed unless the caller opts into a permissive compatibility mode.
This is the core safety improvement over f-strings. With an f-string, the following loses the boundary immediately:
f"User said: {user_request}"With t-strings, prompt() can preserve the fact that user_request was a
runtime value and apply an untrusted rendering policy:
prompt(t"User said:\n{user_request:untrusted_text}")The rendered prompt can use fences, indentation, XML-like tags, or provider specific message parts. The important invariant is not the surface syntax. The invariant is that user text cannot silently become instruction text.
The general-purpose package should integrate with Pydantic AI in three places.
First, it should render system prompts:
agent = Agent(
model,
output_type=TaskOutcome,
system_prompt=system_prompt.render("pydantic_ai_text"),
)Second, it should render output contract notes from Pydantic models. Pydantic AI already owns actual output validation. The prompt DSL should not pretend that schema prose is enforcement. Its job is to make schema hints consistent with the runtime contract and to snapshot them.
Third, it should render tool descriptions from the same source of truth as tool registration. A prompt should not mention a tool merely because its name appears in a string constant. It should mention a tool because a capability manifest says the tool is available in this run.
The reusable prompt() layer should include validation passes that are useful
to any Pydantic AI application:
- no unresolved Jinja or brace placeholders unless explicitly escaped;
- no unclassified interpolation slots;
- no
:toolslots for unavailable tools; - no raw untrusted text in instruction positions;
- schema references must point to Pydantic models, dataclasses, or JSON Schema;
- prompt rendering must be deterministic for snapshot tests;
- prompt budget estimates must identify the largest dynamic blocks.
These passes make prompt authoring feel closer to type-checked application
code. They also make prompt diffs reviewable because a Prompt can report a
tree diff instead of only a giant string diff.
The first general-purpose experiment should stay deliberately small:
- Implement
prompt(template: Template) -> Prompt. - Support literal text,
:trusted_text,:untrusted_text,:path, and:schema. - Render plain text suitable for Pydantic AI
system_prompt. - Add snapshot tests for deterministic output.
- Demonstrate one rejected placeholder-collision or prompt-injection case that an f-string version would silently accept.
If this experiment does not improve safety or readability, the idea is not yet worth a larger library. If it works, tool manifests and examples become the next portable features.
Tainie can go beyond a general prompt() helper because its problem space is
more constrained. It is a local coding assistant for Themester-style Python web
development. It has a known runtime, known eval harness, known code-intelligence
direction, and known domain fixture plan. That lets Tainie compile prompts from
typed local knowledge rather than merely render text.
The Tainie layer should produce a PromptProgram, not just a Prompt:
TAINIE_SYSTEM = tainie_prompt(t"""
<system>
<identity>Tainie is a local coding assistant for Python web projects.</identity>
<capabilities>{capabilities:capability_manifest}</capabilities>
<domain-pack name="themester-tdom">
{tdom_conventions:domain_pack}
</domain-pack>
<evidence-agenda>
{task:task_entities}
</evidence-agenda>
<output>{TaskOutcome:schema}</output>
</system>
""")The XML-like text is not required as an external standard. It is a readable authoring convention for a tree that Tainie owns. The key result is a prompt IR that can feed the current Pydantic AI loop, the eval harness, future episode capture, and eventual code mode.
tdom's most important lesson for this DSL is not that prompts should look like
HTML. The lesson is that t-string templates can compose Python objects through
component syntax. In tdom, a component receives props and nested children, then
returns a Node. The prompt DSL can mirror that strategy: a prompt component
receives typed props and nested prompt children, then returns a PromptNode.
This turns one large system prompt into small helpers:
@prompt_component
@dataclass(kw_only=True)
class ToolGuidance:
manifest: CapabilityManifest
children: PromptNode | None = None
def __call__(self) -> PromptNode:
return prompt(t"""
<tool-guidance>
{self.manifest:capability_manifest}
{self.children}
</tool-guidance>
""")
@prompt_component
@dataclass(kw_only=True)
class EvidenceAgenda:
task: str
retrieval_enabled: bool
def __call__(self) -> PromptNode:
return prompt(t"""
<evidence-agenda>
Task entities: {self.task:task_entities}
Retrieval enabled: {self.retrieval_enabled:bool}
</evidence-agenda>
""")An authoring template could then compose helpers:
tainie_prompt(t"""
<system>
<{Identity} />
<{ToolGuidance} manifest={capabilities}>
<{OneToolPerTurnRule} />
</{ToolGuidance}>
<{DomainPackBlock} pack={themester_pack} />
<{EvidenceAgenda} task={task} retrieval_enabled={retrieval_enabled} />
<{OutputContract} model={TaskOutcome} />
</system>
""")This is the "de-composition" move. Instead of asking one prompt file to hold identity, tool policy, retrieval posture, domain conventions, examples, output schema, and cache layout, each helper owns one part. The top-level prompt becomes composition, not concatenation.
The tdom analogy suggests several design rules:
- Prefer dataclass prompt components for anything with props, branching, derived state, or dependency needs.
- Use function prompt components only for tiny static rules.
- Pass nested content as children rather than as an opaque
children=string. - Let components return
PromptNodeobjects, not rendered strings. - Make component props typed, validated, and snapshot-testable.
- Keep component names semantic:
ToolGuidance,EvidenceAgenda,DomainPackBlock,OutputContract,CodeModeStubs.
This component layer is more than style. It lets Tainie test helpers
individually. EvidenceAgenda can have unit tests for task-entity extraction.
ToolGuidance can fail when its manifest and prompt text disagree.
DomainPackBlock can verify provenance and fixture coverage. CodeModeStubs
can render the same capability manifest into Python stubs and run type checks
against accepted episode examples.
Prompt components also create a natural extension point for dependency-aware rendering. A render context can provide the current workspace, active tool manifest, retrieval index, domain pack registry, eval fixture, and provider profile. Components can request those dependencies explicitly rather than reading global state. That mirrors the tdom/Hopscotch direction while staying small enough for a research spike.
PEP 750 does not make interpolation slots statically typed by itself. A type
checker sees a t-string as Template, not as a generic Template[str, Path, TaskOutcome] shape. The prompt DSL should be honest about that limit. The
opportunity is to add type safety around the template at the boundaries where
Python, Pydantic, ty, and the prompt compiler already have leverage.
The first layer is component prop typing. Prompt components should be ordinary typed Python objects:
@prompt_component
@dataclass(kw_only=True)
class OutputContract:
model: type[BaseModel]
mode: Literal["tool", "native", "prompted"] = "tool"
def __call__(self) -> PromptNode:
return prompt(t"<output>{self.model:schema}</output>")Passing an instance where a model class is required, or a misspelled output mode, becomes a normal ty error before prompt rendering. This is stronger than trying to infer all types from template text.
The second layer is policy/value compatibility. Each slot policy can declare the Python types it accepts:
| Policy | Accepted value shape |
|---|---|
:schema |
type[BaseModel], dataclass type, or JSON schema object |
:tool |
ToolCapability or callable present in the capability manifest |
:path |
Path or validated relative-path wrapper |
:evidence |
EvidencePacket or sequence of evidence records |
:domain_pack |
DomainPack with provenance and fixture coverage |
:episode |
EpisodeCapsule admitted by data gates |
This is runtime validation, but it is still type safety in the practical application sense: bad values fail at prompt construction, before model calls or eval runs. The failures can also be covered by unit tests and snapshot tests.
The third layer is generated type stubs. The prompt package can ship .pyi
stubs or a small mypy/ty-friendly API so component authors get accurate types
for PromptNode, Prompt, RenderContext, SlotPolicy[T], and
CapabilityManifest. Tainie can go further by generating stubs from its active
capability manifest. Those stubs serve two consumers:
- human-authored prompt components, checked by ty;
- future code-mode examples, checked before execution.
The fourth layer is schema consistency. If a prompt component renders
TaskOutcome as an output contract, the same TaskOutcome type should be the
Pydantic AI output_type. The compiler can compare prompt metadata against the
agent contract:
Prompt output schema: TaskOutcome
Agent output_type: TaskOutcome
Status: match
If the prompt says one schema and the agent validates another, compilation should fail in strict eval mode. This does not force the model to comply, but it prevents the application from giving contradictory contracts.
The fifth layer is typed manifests for tools, LHS operations, validators, and domain packs. A capability should not be just a name and prose description. It should include argument and return models. For example:
class RankRelevantFilesArgs(BaseModel):
task: str
limit: int = 5
class RankedFile(BaseModel):
path: str
score: float
matched_terms: list[str]
evidence: strThe same models can generate Pydantic AI tool schemas, prompt descriptions, code-mode stubs, eval trace validation, and documentation snippets. That reduces the number of places a tool contract can drift.
The sixth layer is type-aware prompt tests. A component test should assert not only rendered text, but also prompt metadata:
- slot policy names;
- accepted value types;
- active output schema;
- required capabilities;
- generated stub signatures;
- validation warnings.
This is where the DSL can promote type safety beyond the Python compiler. It turns prompt shape into a tested artifact.
The design should avoid overclaiming. It cannot make natural language itself type-safe, and it cannot statically prove that every interpolation policy matches the value inside arbitrary t-string syntax. But it can make prompt assembly type-aware, fail-fast, and tied to the same Pydantic and ty contracts that Tainie already uses.
Tainie's roadmap already includes prompt/tool coupling checks. The prompt DSL should make those checks structural.
A block such as:
< tool - guidance
requires = {rank_relevant_files: tool} >
Use
rank_relevant_files
before
broad
source
reads
when
the
task
names
labels,
routes, files, symbols, or ambiguous
components.
< / tool - guidance >should compile only when rank_relevant_files is present in the run's
capability manifest. If retrieval tools are disabled, the block is absent or
compilation fails in strict mode. This prevents the exact contamination class
seen in earlier eval work: prompt instructions that refer to tools unavailable
to the agent.
This same mechanism should apply to LHS tools, file write tools, verifier tools, and future code-mode functions. The prompt should be a projection of the active runtime, not a wish list.
LHS started as a way to answer code-intelligence questions cheaply through
ty server, such as symbol search and hover/type queries. In a prompt DSL,
LHS can play a second role: it can produce structured evidence packets that the
prompt compiler understands.
For example:
packet = lhs.rank_relevant_files(task)
prompt(t"""
<retrieval-evidence>
{packet:evidence}
</retrieval-evidence>
""")The :evidence policy can preserve path, score, matched terms, snippet, and
retrieval method. The rendered text remains readable to the model, but the
prompt metadata remains readable to the eval harness. That matters because
Tainie's acceptance authority should remain verifiers, rubrics, and expected
outcomes, not retrieval scores. The DSL can make retrieval evidence inspectable
without laundering it into a false quality score.
The next step is an evidence agenda. Given a task, task entities, and active domain packs, the prompt compiler can propose which evidence categories should be present before editing:
- source candidates from lexical retrieval;
- symbol/type candidates from
ty server; - domain convention docs;
- fixture or example references;
- verifier commands and expected hidden-test boundaries.
The agent still decides what to read and edit. The compiler simply makes the expected evidence posture explicit and measurable.
Themester knowledge should not be one long system prompt. It should be a set of domain packs with provenance, version, examples, validators, and fixture coverage.
A domain pack might contain:
- durable rules, such as tdom typed-attribute conventions;
- project-local docs and examples to retrieve;
- known stale APIs and preferred replacements;
- canonical good and bad examples;
- validators that can check mechanical properties;
- fixture ids that exercise the convention;
- prompt snippets gated by the current task and available tools.
The prompt DSL can include a pack with:
prompt(t"""
<domain-pack name={pack.name:identifier} version={pack.version:identifier}>
{pack:domain_pack}
</domain-pack>
""")The compiler can then validate that the included pack has a source path, a stable version, and at least one test or fixture reference before it is admitted to strict eval runs. This keeps domain knowledge out of vague model memory and inside auditable local artifacts.
Punie lessons identified a useful result shape for deterministic validators: domain, validity, issues, parse errors, rule ids, severity, messages, and suggestions. Tainie's prompt DSL can connect those validators to the prompt rules that motivate them.
For example, a prompt rule about tdom interpolated attributes can carry
validator="tdom.typed_attributes". The eval harness can later report:
Prompt rule included: tdom.typed_attributes
Validator result: failed
Issue: component prop count received quoted string "5" instead of count={5}
This closes a loop that ordinary prompt strings cannot close. The prompt tells the model what convention matters, the validator checks the convention, and the fixture records whether the rule helped.
Code mode changes how the model acts. It asks the model to generate Python code that calls external functions, rather than JSON tool calls one turn at a time. The prompt DSL can reduce the migration cost because the same capability manifest can render two different runtimes.
For the current Pydantic AI tool loop, a capability might render as natural language tool guidance:
Use find_symbols(query=...) for literal symbol searches.
For code mode, the same capability can render as typed function stubs:
def find_symbols(query: str) -> list[SymbolResult]: ...
def hover_type(path: str, line: int, character: int) -> HoverResult: ...
def rank_relevant_files(task: str, limit: int = 5) -> list[RankedFile]: ...This is a major Tainie-specific advantage. LHS tools, retrieval tools, file operations, and verifier operations can all share one capability manifest. Different render targets decide whether the model sees tool descriptions, function stubs, few-shot JSON tool calls, or code-mode examples.
The prompt compiler can also validate code-mode examples before they enter the flywheel:
- every called function exists in the capability manifest;
- arguments type-check against stubs;
- result-field access matches declared return models;
- generated code avoids unsupported sandbox features;
- verifier and hidden-test outcomes justify inclusion as an accepted episode.
This connects prompt authoring, tool registration, LHS, code mode, and episode capture through one typed surface.
Tainie should not paste raw accepted traces into prompts. Accepted runs can be lucky, noisy, or educational for the wrong reason. The DSL should introduce episode capsules: compressed, typed examples admitted only after gates pass.
An episode capsule should record:
- fixture id and task;
- active domain packs and capability manifest;
- evidence read before editing;
- relevant tool calls or code-mode function calls;
- files changed;
- verifier and rubric outcomes;
- final
TaskOutcome; - reason the episode is safe to use as an example.
The prompt DSL can render a capsule differently depending on context:
- as a short few-shot example for the current tool loop;
- as a code-mode training pair;
- as a doc example in a domain pack;
- as a counterexample if the episode failed for a useful reason.
This borrows the useful part of DSPy's example/metric loop without importing an optimizer. Tainie's evaluator decides which episodes are trusted. The prompt compiler decides how to render them.
Local oMLX performance makes prompt layout matter. The prompt compiler should separate stable and dynamic material:
- stable prefix: identity, core rules, output schema, common capability shape;
- semi-stable middle: active domain packs and tool manifests;
- dynamic suffix: user task, retrieval evidence, recent file snippets, selected episode capsules.
This split supports prefix-cache experiments and makes prompt snapshots easier to review. A prompt diff can say:
stable prefix unchanged
domain pack themester-tdom added
retrieval evidence changed for fixture simple_repair_002
That is more useful than comparing full prompt strings.
Every Tainie eval run should be able to identify which prompt program was used. The prompt compiler should emit:
- a prompt hash;
- a stable-prefix hash;
- active rule ids;
- active domain pack ids;
- active capability ids;
- dynamic evidence ids;
- rendered token estimate;
- validation warnings.
Grouped eval records can then compare prompt variants without hand inspection. This is the local version of "optimize against metrics": not automatic prompt search first, but prompt changes that are measurable, attributable, and reversible.
The full architecture has two packages or layers:
prompt() portable layer
Template -> Prompt -> RenderedPrompt
policies: trust, schema, path, tool, evidence, example
targets: plain text, Pydantic AI system prompt, debug snapshot
tainie_prompt() project layer
Template -> PromptProgram -> RenderedPrompt + PromptMetadata
policies: capability_manifest, domain_pack, task_entities, episode_capsule
components: Identity, ToolGuidance, EvidenceAgenda, DomainPackBlock,
OutputContract, CodeModeStubs, EpisodeCapsuleBlock
targets: Pydantic AI tool loop, eval snapshot, code-mode stubs, training pair
The portable layer should remain useful to any Pydantic AI user. The Tainie layer can be opinionated because it serves a local coding agent with a known evaluation harness. Component rendering should happen before target rendering: component templates produce a prompt tree, validation passes check the tree, and render targets lower the tree into strings, tool guidance, code-mode stubs, or training examples.
The first research questions are deliberately practical:
- Can a small
prompt()function preserve current Tainie prompt bytes while adding slot policies and snapshot tests? - Does explicit
:untrusted_textor:pathhandling catch a realistic bug that the current string assembly misses? - Can a capability manifest eliminate prompt/tool leakage in eval baselines?
- Can LHS retrieval evidence be rendered once for the model and recorded once for metrics without duplicating logic?
- Can tdom-style prompt components decompose the system prompt into helpers that are easier to test, diff, and reuse than rule-string concatenation?
- Can typed component props, policy/value checks, schema consistency checks, and generated capability stubs catch prompt bugs before model execution?
- Can a Themester domain pack improve first-read quality or hidden-test pass rate on at least one fixture group?
- Can the same capability manifest render both Pydantic AI tool guidance and code-mode function stubs?
- Do prompt hashes and active rule ids make grouped eval changes easier to interpret?
The safest sequence is incremental:
- Build the portable
prompt()experiment in Tainie first, but keep its API general. - Port
src/tainie/agent/prompts.pywith byte-identical snapshots. - Add strict slot policies for untrusted text, paths, schemas, and tools.
- Add a tiny prompt component model with dataclass helpers, nested children, and component-level snapshot tests.
- Add type-safety checks for component props, policy/value compatibility, and prompt-vs-agent output schema consistency.
- Introduce a Tainie capability manifest and compile prompt/tool guidance from it.
- Connect LHS retrieval evidence to the prompt DSL as structured evidence.
- Define one Themester domain pack for a narrow tdom convention and tie it to a fixture or validator.
- Add prompt metadata to grouped eval records.
- When code mode resurfaces, render the same capability manifest as typed stubs and validate episode examples against those stubs.
The main risk is building a decorative syntax instead of a compiler with invariants. The project should reject any prompt DSL feature that does not improve one of safety, evidence quality, eval attribution, cache behavior, or code-mode readiness.
A second risk is over-centralizing domain knowledge in prompts. The Themester knowledge rule still applies: exact project truth belongs in retrieval, source reads, tests, and deterministic tools. Prompt rules should describe durable habits and evidence posture, not replace inspection.
A third risk is pretending type hints enforce model behavior. Pydantic AI validates outputs at runtime. The prompt DSL should render helpful schema descriptions and keep them consistent, but enforcement remains in Pydantic AI, constrained decoding, validators, and verifiers.
The promising idea is not "use t-strings for prompts" in the narrow sense. The promising idea is to make prompts compiled, typed, inspectable artifacts.
For general Pydantic AI users, a prompt() function can provide safe t-string
rendering, slot policies, schema rendering, tool-aware prompt text, and
snapshotable prompt output.
For Tainie, the same foundation can become a local prompt compiler. It can bind system prompts to actual capabilities, connect LHS evidence to eval records, package Themester conventions as auditable domain packs, prepare code-mode function stubs from the same tool manifest, and turn accepted episodes into gated prompt or training examples. That is a distinctive path: not an optimizer framework, not a pile of strings, but a prompt DSL whose claims can be checked by the same fixtures that define success.
- PEP 750 - Template Strings
- DSPy
- DSPy Signatures
- DSPy Optimizers
- DSPy Metrics
- Pydantic AI Agents
- Pydantic AI Output
- Tainie t-string research,
RESEARCH_TSTRINGS.mdat the repository root - Tainie LHS architecture
- Tainie Themester knowledge