AGENTS.md@src/ptc_agent/agent/prompts/templates · git:20260901.8abca61 · 2026-09-01 · sha256 b0eba20c77d83c76
AGENTS.md@src/ptc_agent/agent/prompts/templates git:20260901.8abca61A
Immutable. This exact content is served forever at /api/v1/blob/b0eba20c77d83c76.
# Prompt templates
Jinja2 sources for the PTC, Flash, and subagent system prompts. Rendered by
`PromptLoader` (`../loader.py`); defaults live in `../config/prompts.yaml`.
Preview any surface as it renders at runtime:
```bash
uv run python scripts/utils/render_prompt.py --mode ptc --crawl --count-tokens
uv run python scripts/utils/render_prompt.py --subagent equity-analyst
```
## Two guidance levels: lean and detailed
The service is model-agnostic — the same prompt has to serve frontier models
and small self-hosted ones. Frontier models regress when given procedural
scaffolding they already fold in; a 20b model falls apart without it. So every
template renders at one of two levels, resolved per request (see
`../guidance.py`):
```
user model_preference.prompt_guidance → config.yaml prompt.guidance → models.json prompt_guidance → detailed
```
`detailed` is the fail-safe default: an unannotated or unknown model gets more
scaffolding, never less.
### You never write the lean version
There is one template body. **Lean is what remains when the coaching is
stripped** — it is not a second text to keep in sync. The split is:
| | Goes where | Examples |
|---|---|---|
| **Facts** the model cannot infer | Unconditional | Workspace paths, the `([domain](url))` citation format, that MCP tools exist only as Python imports from `tools.<server_name>`, memory tiers and the 200-line truncation |
| **Coaching** the model may already know | Inside a guidance fence | Worked examples, numbered procedures, rules of thumb, anti-patterns that only restate the rule above them |
A frontier model needs the facts exactly as much as a small one does. Only the
coaching varies. Anti-patterns split on the same test: the `NEVER use these
path patterns` list in `workspace_paths.md.j2` names paths this sandbox does
not have, so it stays unconditional; a "don't do X" that only inverts the
rule above it is coaching.
There is a prior question, though: **a fact about one tool belongs in that
tool's own description, not here.** The model reads both, and the tool
description travels with the tool — a subagent that never binds the tool never
pays for it. A parameter table, an argument's format, a per-tool constraint:
all of that is the docstring's job. What stays in a template is what no single
tool can know, which is how tools compose: sequencing, routing between them,
what to do with a result once you have it.
### The fence rule
**A fence wraps whole blocks — a `##` section, a worked example, a bullet
list. It never splits a sentence or a line.**
```jinja
# Data Processing
**Core principle**: Always dump data first, then process.
Write raw tool output to your task directory before working with it.
{% if guidance | default("detailed") == "detailed" %}
## Dump First, Then Process
1. **Dump raw result** → ...
## Storage and Summary Pattern
```python
result = some_mcp_tool(query="...")
```
{% endif %}
```
**Write the fence exactly that way.** Two details carry weight:
*Compare the level by name, don't branch on a boolean.* `formatter.py` in this
same package calls its MCP tool-exposure mode `"detailed"` too, and a bare
`{% if detailed %}` does not say which of the two axes it gates. Naming the
variable at the fence does.
*Keep the `| default("detailed")`.* House style here is an explicit default at
every use site so a missing context key cannot silently decide. The other flags
default *false* because absent means feature-off; guidance defaults **detailed**
for the same reason that is the fail-safe level — a render path that forgot to
inject the key must get more scaffolding, never less. Without the filter, a
missing key resolves the comparison to false and silently goes lean.
The cost of comparing by name is that a typo in the *value* is a no-op: the
block vanishes from both tiers, and the subset invariant still passes because
lean adds nothing. `TestFenceExpressions` in `test_prompt_guidance.py` scans
every `.j2` for these two properties precisely because nothing else catches it.
Multiple fences per file are fine when the coaching genuinely belongs inline —
an example should stay next to the rule it illustrates, because the detailed
prompt is the product for the models that depend on it. What is not fine is
sentence-level gating: it makes the file unreadable and the rendered output
unpredictable.
When a component is coaching end to end, gate it at the include site in
`system.md.j2` instead of fencing its whole body.
### When the steering inverts
Rarely, a tier needs the opposite instruction rather than less of the same —
frontier models need delegation *damped* where small models need it
encouraged. That is the only case for `{% else %}`. It breaks the subset
guarantee for that block, so it also requires registering the lean-only text
in `EITHER_OR_ALLOWLIST` in
`tests/unit/ptc_agent/agent/prompts/test_lean_subset_invariant.py`, with a note
on why it inverts. The speed bump is deliberate.
### Checking your work
```bash
# What does lean drop?
uv run python scripts/utils/render_prompt.py --mode ptc --diff
# Resolve the level from a real model, as runtime would
uv run python scripts/utils/render_prompt.py --mode ptc --model claude-opus-5
```
`test_lean_subset_invariant.py` enforces the subset property across PTC, Flash,
and all five subagents. If it fails, lean is rendering text detailed never
sees — move it outside the fence, or register it as an either/or.
## Other conventions
- **`agent_preference` is echoed to the model verbatim.**
`components/user_profile.md.j2` prints its keys as user instructions, so a
renderer-consumed knob must not be stored there — it would become prompt
text about itself. `output_format` is the one exception, and it shows the
cost: the loop carries an explicit `if key != "output_format"` so that key
can select a section instead of printing. Model-scoped settings (the guidance level
among them) live in the sibling `model_preference` bucket, which the template
never iterates.
- **The static prompt excludes time and user profile.** Both are appended by
`RuntimeContextMiddleware` after the cache breakpoint. Adding either to a
system template silently breaks prompt caching.