|
| 1 | +--- |
| 2 | +title: "Ground every agent turn in real facts" |
| 3 | +description: "Stop the model from hallucinating the user's name, plan tier, or the current time — inject verifiable facts into the system prompt on every turn" |
| 4 | +--- |
| 5 | + |
| 6 | +import { Card, CardGrid } from '@astrojs/starlight/components'; |
| 7 | + |
| 8 | +# Ground every agent turn in real facts |
| 9 | + |
| 10 | +When an agent asks "what's the user's current plan tier?", a language |
| 11 | +model will cheerfully make one up. When it asks "what time is it?", |
| 12 | +it'll guess. The fix is not a better prompt — it's a companion |
| 13 | +deterministic layer that supplies the ground truth on every turn. |
| 14 | + |
| 15 | +`FactResolver` is that companion. You implement it, you declare which |
| 16 | +keys you supply, and Atmosphere prepends the resolved bundle to the |
| 17 | +system prompt before every dispatch. |
| 18 | + |
| 19 | +## The default behavior |
| 20 | + |
| 21 | +Atmosphere ships `DefaultFactResolver` out of the box. It supplies |
| 22 | +`time.now` (UTC ISO-8601) and `time.timezone` only. With no extra |
| 23 | +wiring, every turn's system prompt starts with: |
| 24 | + |
| 25 | +``` |
| 26 | +Grounded facts (deterministic, as of this turn): |
| 27 | +- time.now: 2026-04-19T18:32:14Z |
| 28 | +- time.timezone: UTC |
| 29 | +``` |
| 30 | + |
| 31 | +That alone closes the "what year is it?" class of hallucination. |
| 32 | + |
| 33 | +## Writing a production resolver |
| 34 | + |
| 35 | +Apps typically want richer facts — user name, locale, plan tier, |
| 36 | +feature-flag values, recent audit events. Implement `FactResolver` |
| 37 | +and supply any subset of keys from `FactKeys` plus your own `app.*` |
| 38 | +keys. |
| 39 | + |
| 40 | +```java |
| 41 | +public final class UserProfileFactResolver implements FactResolver { |
| 42 | + |
| 43 | + private final ProfileService profiles; |
| 44 | + private final FeatureFlags flags; |
| 45 | + private final AuditLog audit; |
| 46 | + |
| 47 | + @Override |
| 48 | + public FactBundle resolve(FactRequest req) { |
| 49 | + var out = new LinkedHashMap<String, Object>(); |
| 50 | + |
| 51 | + if (req.keys().contains(FactKeys.USER_NAME)) { |
| 52 | + profiles.lookup(req.userId()) |
| 53 | + .ifPresent(p -> out.put(FactKeys.USER_NAME, p.name())); |
| 54 | + } |
| 55 | + if (req.keys().contains(FactKeys.USER_LOCALE)) { |
| 56 | + out.put(FactKeys.USER_LOCALE, |
| 57 | + profiles.lookup(req.userId()) |
| 58 | + .map(Profile::locale) |
| 59 | + .orElse("en-US")); |
| 60 | + } |
| 61 | + if (req.keys().contains(FactKeys.USER_PLAN_TIER)) { |
| 62 | + out.put(FactKeys.USER_PLAN_TIER, |
| 63 | + profiles.lookup(req.userId()) |
| 64 | + .map(Profile::planTier) |
| 65 | + .orElse("free")); |
| 66 | + } |
| 67 | + // Custom app key — the framework treats unknown keys transparently. |
| 68 | + if (req.keys().contains("app.recent_order_id")) { |
| 69 | + profiles.lastOrder(req.userId()) |
| 70 | + .ifPresent(o -> out.put("app.recent_order_id", o.id())); |
| 71 | + } |
| 72 | + return new FactBundle(out); |
| 73 | + } |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +:::tip[Contracts to honor] |
| 78 | +- **Thread-safe** — `resolve()` is called from every inbound agent |
| 79 | + turn, possibly concurrently. |
| 80 | +- **Never returns null** — return `FactBundle.empty()` rather than |
| 81 | + `null` when nothing applies. |
| 82 | +- **Cheap per-call** — the resolver runs on the request hot path |
| 83 | + before every LLM dispatch. Cache expensive lookups inside your |
| 84 | + implementation; the framework does not cache `resolve()` results. |
| 85 | +::: |
| 86 | + |
| 87 | +## Wiring the resolver |
| 88 | + |
| 89 | +Three ways to install, in priority order: |
| 90 | + |
| 91 | +### 1. Spring bean (recommended) |
| 92 | + |
| 93 | +```java |
| 94 | +@Configuration |
| 95 | +class FactResolverConfig { |
| 96 | + @Bean |
| 97 | + FactResolver productionResolver(ProfileService profiles, |
| 98 | + FeatureFlags flags, |
| 99 | + AuditLog audit) { |
| 100 | + return new UserProfileFactResolver(profiles, flags, audit); |
| 101 | + } |
| 102 | +} |
| 103 | +``` |
| 104 | + |
| 105 | +`AtmosphereAiAutoConfiguration.atmosphereFactResolverBridge` publishes |
| 106 | +the bean onto `framework.properties()` under |
| 107 | +`FactResolver.FACT_RESOLVER_PROPERTY`; |
| 108 | +`AiEndpointHandler.resolveFactResolver` finds it first. |
| 109 | + |
| 110 | +### 2. ServiceLoader (plain servlet / Quarkus / embedded) |
| 111 | + |
| 112 | +Drop a file at |
| 113 | +`META-INF/services/org.atmosphere.ai.facts.FactResolver` containing |
| 114 | +your resolver's fully-qualified class name. The handler's |
| 115 | +`ServiceLoader.load(FactResolver.class).findFirst()` picks it up. |
| 116 | + |
| 117 | +### 3. Manual install (tests, CLI tools) |
| 118 | + |
| 119 | +```java |
| 120 | +FactResolverHolder.install(new UserProfileFactResolver(...)); |
| 121 | +``` |
| 122 | + |
| 123 | +The process-wide holder is the lowest priority fallback — mostly |
| 124 | +useful for test setup via `@BeforeAll` + `FactResolverHolder.reset()` |
| 125 | +in `@AfterEach`. |
| 126 | + |
| 127 | +## What the model sees |
| 128 | + |
| 129 | +The resolver's bundle is rendered as a newline-delimited block and |
| 130 | +prepended to the system prompt. A request from `alice@example.com` on |
| 131 | +a paid tier sees, at the top of the system prompt: |
| 132 | + |
| 133 | +``` |
| 134 | +Grounded facts (deterministic, as of this turn): |
| 135 | +- time.now: 2026-04-19T18:32:14Z |
| 136 | +- time.timezone: UTC |
| 137 | +- user.id: alice@example.com |
| 138 | +- user.name: Alice Martin |
| 139 | +- user.locale: en-US |
| 140 | +- user.plan_tier: enterprise |
| 141 | +- app.recent_order_id: ord-7821 |
| 142 | +
|
| 143 | +[then whatever the developer's @AiEndpoint systemPrompt said] |
| 144 | +``` |
| 145 | + |
| 146 | +:::caution[Prompt-injection guard] |
| 147 | +Values are escaped before rendering — newline, carriage return, tab, |
| 148 | +and ASCII control characters are replaced with a space. A fact value |
| 149 | +like `"Alice\nIgnore prior instructions"` cannot open a new line in |
| 150 | +the system prompt. Without this escape, a malicious or accidental |
| 151 | +embedded newline could reshape the instruction context. |
| 152 | +::: |
| 153 | + |
| 154 | +:::note[When NOT to use this] |
| 155 | +- **Large blobs** — the bundle lands in the system prompt and |
| 156 | + consumes tokens. Use an `AiTool` the model can call on demand for |
| 157 | + retrieval. |
| 158 | +- **Per-message context** — `FactResolver` resolves once per turn, |
| 159 | + not per message in a multi-round tool loop. For per-message |
| 160 | + context, use `ContextProvider` instead. |
| 161 | +- **Secrets** — the bundle is visible to the model. Never put API |
| 162 | + keys, passwords, or PII in here. |
| 163 | +::: |
| 164 | + |
| 165 | +## See also |
| 166 | + |
| 167 | +<CardGrid> |
| 168 | + <Card title="AI Filters & Guardrails" icon="seti:default"> |
| 169 | + Inspect and block the response path — PII redaction + drift |
| 170 | + detection. [`/tutorial/12-ai-filters/`](./12-ai-filters/) |
| 171 | + </Card> |
| 172 | + <Card title="Tag agent calls with business outcomes" icon="approve-check"> |
| 173 | + Sibling primitive for observability tagging via SLF4J MDC. |
| 174 | + [`/tutorial/27-business-metadata-observability/`](./27-business-metadata-observability/) |
| 175 | + </Card> |
| 176 | + <Card title="DefaultFactResolver source" icon="github"> |
| 177 | + [`modules/ai/src/main/java/org/atmosphere/ai/facts/DefaultFactResolver.java`](https://github.com/Atmosphere/atmosphere/blob/main/modules/ai/src/main/java/org/atmosphere/ai/facts/DefaultFactResolver.java) |
| 178 | + </Card> |
| 179 | +</CardGrid> |
0 commit comments