# Prompt Sources — onde vive o prompt de um agente

> **Scope:** stacks=["dotnet"]
> **Layer:** 1 (on-keyword)
> **Keywords:** prompt sources, prompts in db, agent prompts, prompt versioning, prompt hot-reload, editable instructions, prompt management, prompt por tenant, spec publicado, reconciliação de prompt
> **Read by Claude in:** implement (quando as instruções de um agente precisam viver fora do código) e troubleshoot (quando a versão que rodou não é a que se esperava)

**Verified against:** Microsoft.Agents.AI 1.20.0 + Microsoft.Extensions.AI 10.9.0 (ai-pin 2026-09-08). **Verificação documental**, sem cláusula `provado por` — o ai-kit não traz loader de prompt, e os dois mecanismos descritos aqui são destilados de três produtos em produção. As **quatro** superfícies de API que este standard cita foram medidas por reflexão, e vivem em **três assemblies diferentes** — o carimbo diz onde remedir, então o nome importa: `IChatClient.AsAIAgent(instructions:, name:)` é `Microsoft.Extensions.AI.ChatClientExtensions`, em **`Microsoft.Agents.AI` 1.20.0** (medida em 2026-09-08); `AIAgent.RunAsync` (as oito sobrecargas) e `AIAgent.CreateSessionAsync` são de `Microsoft.Agents.AI.AIAgent`, na assembly **`Microsoft.Agents.AI.Abstractions` 1.20.0** — **remedidas** em 2026-09-09, porque a leitura de 2026-09-08 estava errada no *quê*, e a de 2026-09-09 estava errada no *onde* (as duas retificações estão no callout abaixo); e `builder.AddAIAgent` (§Keyed singleton) é `Microsoft.Agents.AI.Hosting.HostApplicationBuilderAgentExtensions` — cinco sobrecargas, na assembly **`Microsoft.Agents.AI.Hosting` 1.20.0.0**, que vem do pacote **`Microsoft.Agents.AI.Hosting` `1.20.0-preview.260831.1`**: ele é **prerelease** e está **FORA do `ai-pin.json`** (apurado em `scripts/api-probe/companions.json`, 2026-09-08) — quem adotar esta seção registra a dependência prerelease em `decisions.md`, como manda `ai-agents-production`. **Duas ressalvas de escopo**, porque um carimbo que não as diz promete mais do que mediu: `Microsoft.Agents.AI.Abstractions` e `Microsoft.Extensions.AI.Abstractions` são nomes de **assembly** (elas viajam dentro dos pacotes `Microsoft.Agents.AI` e `Microsoft.Extensions.AI`), não ids de pacote; e `IMemoryCache`/`DbContext`, usados no padrão de cache e na §Keyed singleton, são superfícies de ASP.NET Core e EF Core — **fora do pin, não medidas aqui, verificação documental**. Last-verified: 2026-09-08.

---

## As duas fontes legítimas

Este standard nasceu conhecendo **um** mecanismo (a tabela versionada) e hoje conhece **dois**. A
troca de nome deste arquivo foi a consequência disso, não a causa: o assunto nunca foi "banco", foi
**de onde vem o texto de `Instructions`** (ver a nota "Estado do rename" adiante).

| Fonte | Forma | Quando cabe |
|---|---|---|
| **Banco versionado** | Tabela `agent_prompts` com `agent_key` + `version` + `is_active`; publica-se por INSERT + flip, nunca UPDATE. É o que as seções seguintes descrevem em detalhe | **Single-tenant**: um produto, um prompt por agente, editável sem deploy |
| **Repo (YAML) → composição → spec publicado** | Uma moldura de produto com buracos de política + um YAML por tenant que os preenche → um compositor determinístico → um blob renderizado publicado numa coluna `jsonb` → lido em runtime | **Produto × tenant**: a mesma moldura serve N clientes, cada um com política própria |

**A regra de escolha, em uma frase:**

> Se o texto varia por cliente, o prompt **não é um registro** — é a **saída de uma composição**, e o
> que se versiona é a **fonte**, não o resultado.

Lastro: o primeiro mecanismo tem **dois exemplares em produção** (num deles, 36 migrations de prompt,
a maior com 47.283 bytes); o segundo tem **um**, com blobs renderizados de 8.727 e 12.526 caracteres
para dois tenants do mesmo produto.

**O erro que a fronteira evita** é tentar espremer o segundo caso no primeiro: uma linha de
`agent_prompts` por tenant. Funciona com dois tenants e falha no décimo, porque a regra comum passa a
existir em N cópias e a próxima edição corrige N-1 delas.

---

## Por que prompts no DB

Hardcoded instructions in source code mean every prompt tweak requires a full build and deploy cycle. With prompts in the database the team can tune agent behavior without touching code, pushing a commit, or triggering CI.

Key benefits:

| Benefit | Explanation |
|---------|-------------|
| **No deploy to tune** | Product/ML team edits prompts via a backoffice or SQL — the agent picks up the change on next load |
| **Versioning and rollback** | Every version is kept as an immutable row; rollback = flip `is_active` |
| **A/B testing** | Two rows for the same `agent_key`, activate one; compare metrics; flip back if needed |
| **Audit trail** | `created_at` per version gives a full timeline of what prompt was active when |

The agent receives its `instructions:` value from the repository at construction time — not from a string literal in code.

---

## O pattern

```
┌─────────────────┐       ┌──────────────────┐       ┌──────────────────────┐
│  Agent Handler  │──────▶│ PromptRepository │──────▶│  agent_prompts table │
│  (constructs    │       │ (IMemoryCache    │       │  (PostgreSQL)        │
│   AIAgent)      │◀──────│  TTL: 5 min)     │◀──────│                      │
└─────────────────┘       └──────────────────┘       └──────────────────────┘
```

1. The handler (or DI factory) calls `IPromptRepository.GetActivePromptAsync(agentKey, ct)`.
2. `PromptRepository` checks `IMemoryCache`. On a hit, returns the cached string. On a miss, queries the DB for the single row where `agent_key = @key AND is_active = true`, caches it with a short TTL (e.g. 5 minutes), and returns it.
3. The handler constructs the agent: `chatClient.AsAIAgent(instructions: prompt, name: "MyAgent")`.
4. **Agents are cheap to construct** — recreate on every request (or per scoped lifetime). Do NOT attempt to mutate a running agent's instructions.

---

## Schema da tabela

The `agent_prompts` table stores one row per prompt version. See the full migration in:

```
templates/code/sql/ai-agents/agent-prompts-migration.sql.template
```

Column summary:

| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` | PK, `gen_random_uuid()` |
| `agent_key` | `text NOT NULL` | Logical identifier, e.g. `"proposal-agent"` |
| `version` | `int NOT NULL` | Monotonically increasing per `agent_key` |
| `content` | `text NOT NULL` | The full system-prompt text |
| `is_active` | `boolean NOT NULL DEFAULT false` | Only one row active per `agent_key` |
| `created_at` | `timestamptz NOT NULL DEFAULT now()` | Immutable audit timestamp |

### Unique-partial-index trick

```sql
CREATE UNIQUE INDEX ix_agent_prompts_active
    ON {{SCHEMA}}.agent_prompts (agent_key)
    WHERE is_active;
```

This index enforces at the database level that only one row per `agent_key` can have `is_active = true`. An attempt to activate a second row for the same key will fail with a unique-constraint violation — no application logic needed to guard this invariant.

---

## O repository

Reference implementation:

```
templates/code/dotnet/ai-agents/PromptRepository.cs.template
```

Interface contract:

```csharp
public interface IPromptRepository
{
    /// <summary>
    /// Returns the content of the currently active prompt for the given agent key.
    /// Throws <see cref="InvalidOperationException"/> if no active prompt exists.
    /// </summary>
    Task<string> GetActivePromptAsync(string agentKey, CancellationToken ct = default);
}
```

The concrete `PromptRepository`:

- Receives `DbContext` and `IMemoryCache` via primary constructor.
- Uses a cache key of `$"agent_prompt:{agentKey}"` with a sliding expiration of 5 minutes.
- On a cache miss, queries `AgentPrompts.Where(p => p.AgentKey == agentKey && p.IsActive)` via EF Core.
- Throws `InvalidOperationException($"No active prompt found for agent '{agentKey}'.")` when no row matches — fail fast so misconfiguration is immediately visible.
- Does NOT catch DB exceptions — let the caller decide how to handle transient failures.

DI registration (in `Program.cs` or a bootstrap extension):

```csharp
builder.Services.AddMemoryCache();
builder.Services.AddScoped<IPromptRepository, PromptRepository>();
```

---

## Integração com o agente

### Scoped construction (recommended)

```csharp
public sealed class ProposalAgentHandler(
    IPromptRepository promptRepo,
    ModelRegistry modelRegistry)
{
    public async Task<string> RunAsync(string userMessage, CancellationToken ct)
    {
        var prompt = await promptRepo.GetActivePromptAsync("proposal-agent", ct);

        var agent = modelRegistry
            .GetChatClient("text-default")
            .AsAIAgent(instructions: prompt, name: "ProposalAgent");

        AgentSession session = await agent.CreateSessionAsync(ct);
        var response = await agent.RunAsync(userMessage, session, cancellationToken: ct);
        return response.Text;
    }
}
```

### Hot-reload semantics

When a new prompt is saved with `is_active = true`:

1. The old active row is set to `is_active = false` (see §Versionamento).
2. The cache entry expires within the configured TTL (5 min by default).
3. The next agent construction after expiry picks up the new prompt automatically.

**Do NOT mutate a running agent.** Agents are lightweight value-like objects in MAF — recreate on each scoped lifetime instead of attempting to patch `instructions` on a live instance.

> **Conferência de 2026-09-08, reconferida em 2026-09-09.** A extensão usada por este padrão —
> `AsAIAgent(this IChatClient chatClient, string? instructions, string? name, string? description,
> IList<AITool>? tools, …)` — está intacta, assim como a sobrecarga que recebe
> `ChatClientAgentOptions`. Ela é `Microsoft.Extensions.AI.ChatClientExtensions`, e a assembly é
> **`Microsoft.Agents.AI` 1.20.0** — namespace e assembly divergem aqui, e é o **segundo** nome que
> diz onde remedir. **E o nome do tipo, sozinho, é ambíguo:** existe um
> `Microsoft.Extensions.AI.ChatClientExtensions` em **duas** assemblies — esta e
> `Microsoft.Extensions.AI.Abstractions` —, e o `dump` do probe resolve **uma** delas e nunca mostra
> a colisão. `AsAIAgent` está na de `Microsoft.Agents.AI`; citar o tipo sem a assembly manda o
> próximo re-verificador ao arquivo errado com o nome certo. Medido: sete parâmetros, nesta ordem. **O argumento genérico `AITool` NÃO é
> coberto por este carimbo:** o `dump` do `api-probe` imprime `ParameterType.Name`, que apaga
> argumentos genéricos (ele mede ``IList`1 tools``); a aridade e a ordem são medidas, o `AITool` é
> documental. **A mesma ressalva vale para o `ChatMessage`** de `IEnumerable<ChatMessage>` no
> parágrafo seguinte: o dump emite `` IEnumerable`1 ``, e os **16** call sites de `AIAgent.RunAsync`
> no ai-kit passam **todos** uma `string` — nada nesta árvore mede aquele token.
>
> E há um **segundo** limite deste aparelho, que esta ressalva não cobre e por isso está dito aqui:
> o dump **não mede opcionalidade de parâmetro**. A afirmação *"`AgentSession` não é obrigatória"*,
> abaixo, não se apoia no dump — apoia-se no ai-kit **compilando e rodando** a chamada sem sessão,
> que é outro instrumento.
>
> **Correção de 2026-09-09, medida por reflexão sobre `Microsoft.Agents.AI.Abstractions` 1.20.0** —
> que é onde `AIAgent` vive, e **não** `Microsoft.Agents.AI`, como esta linha dizia até a
> reconferência. O exemplo acima
> chamava `agent.RunAsync(userMessage, ct)`, que **não compila**. A razão, medida: das oito
> sobrecargas de `AIAgent.RunAsync`, **nenhuma aceita um `CancellationToken` como segundo parâmetro
> posicional** — a posição 2 é sempre `AgentSession`, `JsonSerializerOptions` ou `AgentRunOptions`.
> Nas **seis** que recebem a mensagem primeiro — três tipos de mensagem (`string`, `ChatMessage` e
> `IEnumerable<ChatMessage>`, este último **documental**, pela ressalva acima), cada um com e sem
> `JsonSerializerOptions` — a posição 2 é a `AgentSession`, logo o token só chega **nomeado**.
>
> **`AgentSession` NÃO é obrigatória** — e este é o ponto em que uma leitura apressada erra: duas das
> oito sobrecargas a recebem na **primeira** posição, e o próprio ai-kit compila e roda
> `await agent.RunAsync("sem sessao", cancellationToken: Ct)`
> (`tests/Morph.AiKit.Tests/ConversationStoreTests.cs:338`, teste
> `ARoundWithoutAnExplicitSessionStillHasAConversationOfItsOwn`), caso em que o agente cria uma
> sessão **efêmera** para a volta. Passar a sessão explicitamente é escolha de **desenho**, não
> exigência do SDK: sem ela a conversa nasce com id próprio a cada turno, que é o oposto do que este
> standard quer. As duas afirmações comportamentais desta linha são as que o teste **assere**
> (`Assert.Single(store.ConversationIds)` na linha 343, para a primeira volta; e
> `Assert.Equal(2, store.ConversationIds.Count)` na **351**, que é quem prova que a volta seguinte
> **não herda** a conversa) — não leitura de assinatura. O `Assert.DoesNotContain` da 352 assere
> outra coisa: que a mensagem anterior não foi **reenviada** ao provedor.
>
> O carimbo anterior *"nada a corrigir de API neste standard"* estava errado quanto a esta linha;
> ele é anterior a esta feature, e está retificado aqui em vez de repetido.
>
> **Estado do rename:** feito. Este arquivo **é** o antigo `prompts-in-db.md`, renomeado para
> `prompt-sources.md` e generalizado — "prompt vem de uma fonte, e banco é uma delas". O id do
> registry mudou junto, de `ai-agents-prompts-in-db` para `ai-agents-prompt-sources`, e os seis
> referenciadores do framework foram atualizados no mesmo movimento.

### Keyed singleton (long-lived, explicit refresh)

For agents registered as keyed singletons (e.g. via `builder.AddAIAgent`), hot-reload requires explicit invalidation. In that pattern, call `IMemoryCache.Remove($"agent_prompt:{key}")` from an admin endpoint or background job after saving a new version, then recreate the agent in the factory. This is more complex; prefer scoped construction unless the agent carries expensive state (e.g. an embedded tool pipeline that is costly to rebuild).

---

## Versionamento

**Rule: never UPDATE the `content` column.** The content of a prompt row is immutable once written.

To release a new prompt version:

```sql
-- 1. Deactivate the current active version
UPDATE {{SCHEMA}}.agent_prompts
   SET is_active = false
 WHERE agent_key = 'proposal-agent' AND is_active = true;

-- 2. Insert the new version (version = previous max + 1)
INSERT INTO {{SCHEMA}}.agent_prompts (agent_key, version, content, is_active)
VALUES (
    'proposal-agent',
    (SELECT COALESCE(MAX(version), 0) + 1 FROM {{SCHEMA}}.agent_prompts WHERE agent_key = 'proposal-agent'),
    'Your new system prompt text here...',
    true
);
```

This keeps a complete, auditable history. Rollback = deactivate the current row and reactivate any previous row.

---

## Fonte 2: YAML por tenant

Quando o mesmo produto atende clientes com política própria, o prompt deixa de ser um registro
editável e passa a ser **o resultado de uma composição**. A cadeia, em quatro etapas, cada uma com
um dono claro:

```
moldura de produto (repo, versionada)          ← a regra que vale para todos, com buracos nomeados
        +
YAML por tenant (repo, versionado)             ← só a política daquele cliente preenche os buracos
        ↓
compositor determinístico                      ← puro, sem I/O; entrada igual, saída igual
        ↓
spec publicado (coluna jsonb)                  ← o blob renderizado, lido em runtime
```

**As quatro regras que fazem isso funcionar:**

1. **O que se versiona é a fonte.** A moldura e o YAML vivem no repositório, com histórico, revisão e
   `blame`. O blob publicado é **derivado**; editá-lo à mão é editar um artefato de build.
2. **A moldura tem buracos nomeados, não texto opcional.** Um buraco que o tenant não preenche é um
   erro de publicação, não um parágrafo que some silenciosamente.
3. **O compositor é determinístico e puro.** Mesma moldura + mesmo YAML = mesmo byte. Sem isso, a
   seção `## Reconciliação` abaixo é impossível.
4. **A publicação é um passo explícito**, com quem publicou e quando. Publicar não é salvar.

**Quando NÃO usar este mecanismo:** produto single-tenant. A cadeia de quatro etapas paga por si
quando existe variação real por cliente; num produto só, ela é uma indireção que transforma "editar o
prompt" em "editar, compor e publicar".

**Onde este mecanismo é fraco, e é honesto dizer:** editar prompt passa a exigir um ciclo de repo
(commit, revisão, publicação). O ganho de "tunar sem deploy" da fonte 1 **desaparece** — e trocá-lo
por consistência entre tenants é uma decisão de produto, não uma melhoria automática.

---

## Reconciliação

**Versionar e publicar não prova que a versão ativa foi a que rodou.**

É a dor que este standard não cobria e que custou caro: num produto do acervo, versões v7 e v8 de
prompt foram **ignoradas em 6 de 9 execuções** — o registro dizia que existiam, a execução usava
outra coisa, e ninguém tinha como saber sem reproduzir manualmente.

O buraco é estrutural, e vale para as duas fontes: entre "qual versão está ativa" e "qual texto foi
enviado" há um cache, um deploy, uma cópia de configuração ou uma etapa de publicação — e cada um
deles pode estar servindo algo antigo.

**A exigência, em duas linhas:**

- **O registro de execução carrega a versão efetiva.** Não a versão ativa no momento da consulta: a
  que foi usada naquele turno. Um campo, gravado junto com o resultado.
- **No caminho composto, ele carrega também o hash do blob renderizado.** Versão sozinha não basta
  quando o blob é derivado de duas fontes: o hash é o que amarra o texto enviado à composição que o
  gerou.

**As duas técnicas de prova que o campo já usa**, e nenhuma delas exige infraestrutura nova:

| Técnica | O que prova | Onde vive |
|---|---|---|
| **Equivalência por byte-identidade** entre a composição da fonte e o blob publicado | Que a publicação não divergiu da fonte versionada | Teste, sobre o compositor puro |
| **Prompt como texto**: no máximo **um** teste de estrutura (chaves + ausência de instrução revogada), ver `ai-agents-testing-ai` | Que as chaves batem, e que a instrução revogada não voltou — nunca presença de conteúdo que o modelo "deveria" seguir | Teste, sobre a migration ou o YAML |

A segunda asserção — **ausência** — é a que ninguém escreve e a que mais pega regressão: uma
instrução removida do prompt em produção continua viva no registro antigo, e o rollback a traz de
volta sem que ninguém note.

O detalhamento dessas duas técnicas está em `ai-agents-testing-ai`; a metade de produção da
rastreabilidade (hash em span, prompt cru só em harness) está em `ai-agents-observability-patterns`.

---

## Anti-patterns

| Anti-pattern | Por quê é problema | Solução |
|---|---|---|
| Hardcoded prompt string in production code | Every tune requires a build + deploy | Move to `agent_prompts` table |
| `UPDATE agent_prompts SET content = '...'` without a version | Destroys history; no rollback | INSERT new row with version+1, flip `is_active` |
| No cache — DB query on every agent invocation | High latency + DB load under traffic | Use `IMemoryCache` with a short TTL |
| `appsettings.json` prompts | Still requires a deploy to change; no versioning | Move to DB or environment-variable-agnostic store |
| Single global `IMemoryCache` key collisions | Two agents with similar keys overwrite each other | Always prefix: `$"agent_prompt:{agentKey}"` |
| Activating a new row without deactivating the old one | Two active rows for the same key — undefined behavior | The partial unique index prevents this at DB level; always deactivate first in app code |
| Uma linha de `agent_prompts` por tenant | A regra comum passa a existir em N cópias; a próxima edição corrige N-1 | Fonte 2: moldura + YAML por tenant, composto |
| Editar à mão o blob publicado | É artefato derivado; a próxima composição o sobrescreve | Edite a fonte e republique |
| Compositor com I/O ou relógio | Perde o determinismo, e a prova por byte-identidade deixa de ser possível | Compositor puro |
| Buraco de política que some em silêncio quando o tenant não o preenche | O prompt publicado fica com um pedaço a menos e ninguém sabe | Buraco não preenchido é erro de publicação |
| Registrar a execução sem a versão efetiva do prompt | "v7 e v8 ignoradas em 6 de 9 execuções", e sem como descobrir | Grave a versão usada naquele turno |
| No caminho composto, gravar só a versão e não o hash do blob | Versão não identifica um texto derivado de duas fontes | Grave versão **e** hash |
| Mais de um teste de estrutura por fonte de prompt, ou teste de presença de conteúdo | Congela a versão, não o comportamento do modelo | No máximo **um** teste: chaves + **ausência** de instrução revogada (a que o rollback traz de volta) |

---

## Checklist (verifiable by morph-eval)

- [ ] `agent_prompts` table exists with all required columns (`id`, `agent_key`, `version`, `content`, `is_active`, `created_at`)
- [ ] Unique partial index `ix_agent_prompts_active` on `(agent_key) WHERE is_active` is present
- [ ] `IPromptRepository` registered in DI (`AddScoped`)
- [ ] `IMemoryCache` registered (`builder.Services.AddMemoryCache()`)
- [ ] No prompt string literals in production agent construction code
- [ ] `GetActivePromptAsync` throws `InvalidOperationException` (not returns null) when no active prompt found
- [ ] Versionamento pattern followed: INSERT + flip, never UPDATE content
- [ ] At least one seed row per `agent_key` in migrations or seed script
- [ ] A fonte escolhida (banco versionado × composição por tenant) está nomeada no `decisions.md`,
      com a pergunta "o texto varia por cliente?" respondida
- [ ] No caminho composto: a moldura e o YAML estão versionados no repo, e o blob publicado **não** é
      editado à mão
- [ ] O compositor é puro e determinístico (mesma entrada, mesmo byte)
- [ ] Buraco de política não preenchido falha a publicação, em vez de sumir do texto
- [ ] O registro de execução carrega a **versão efetiva** do prompt daquele turno
- [ ] No caminho composto, carrega também o **hash do blob renderizado**
- [ ] Existe teste de equivalência por byte-identidade entre a fonte e o blob publicado
- [ ] Cada fonte de prompt tem **no máximo 1** teste de estrutura (chaves + ausência de instrução
      revogada), ver `ai-agents-testing-ai`

---

## References

- `ai-agents-setup` — how to construct an `AIAgent` via `AsAIAgent(instructions:)`
- `ai-agents-providers-model-registry` — how to obtain the `IChatClient` from the Model Registry
- `ai-agents-agent-spec` — o campo `Instructions` cuja origem este standard governa
- `ai-agents-testing-ai` — as duas técnicas de prova da seção de reconciliação, em detalhe
- `ai-agents-observability-patterns` — a metade de produção da rastreabilidade (hash em span)
- `backend-database-postgresql-database` — naming conventions, timestamptz, uuid PKs, partial indexes

> **Lacuna declarada.** O par compilável deste standard seria um loader de prompt com hash em
> `templates/dotnet/ai-kit/src/Morph.AiKit/`. **Ele ainda não existe** no ai-kit. Por isso o
> cabeçalho declara verificação documental e não carimba `provado por`.

---

*MORPH-SPEC by Polymorphism Tech — ai-agents/prompt-sources.md v2.0 (2026-09-08)*
