Skip to content

Ground every agent turn in real facts

When an agent asks “what’s the user’s current plan tier?”, a language model will cheerfully make one up. When it asks “what time is it?”, it’ll guess. The fix is not a better prompt — it’s a companion deterministic layer that supplies the ground truth on every turn.

FactResolver is that companion. You implement it, you declare which keys you supply, and Atmosphere prepends the resolved bundle to the system prompt before every dispatch.

Atmosphere ships DefaultFactResolver out of the box. It supplies time.now (UTC ISO-8601) and time.timezone only. With no extra wiring, every turn’s system prompt starts with:

Grounded facts (deterministic, as of this turn):
- time.now: 2026-04-19T18:32:14Z
- time.timezone: UTC

That alone closes the “what year is it?” class of hallucination.

Apps typically want richer facts — user name, locale, plan tier, feature-flag values, recent audit events. Implement FactResolver and supply any subset of keys from FactKeys plus your own app.* keys.

public final class UserProfileFactResolver implements FactResolver {
private final ProfileService profiles;
private final FeatureFlags flags;
private final AuditLog audit;
@Override
public FactBundle resolve(FactRequest req) {
var out = new LinkedHashMap<String, Object>();
if (req.keys().contains(FactKeys.USER_NAME)) {
profiles.lookup(req.userId())
.ifPresent(p -> out.put(FactKeys.USER_NAME, p.name()));
}
if (req.keys().contains(FactKeys.USER_LOCALE)) {
out.put(FactKeys.USER_LOCALE,
profiles.lookup(req.userId())
.map(Profile::locale)
.orElse("en-US"));
}
if (req.keys().contains(FactKeys.USER_PLAN_TIER)) {
out.put(FactKeys.USER_PLAN_TIER,
profiles.lookup(req.userId())
.map(Profile::planTier)
.orElse("free"));
}
// Custom app key — the framework treats unknown keys transparently.
if (req.keys().contains("app.recent_order_id")) {
profiles.lastOrder(req.userId())
.ifPresent(o -> out.put("app.recent_order_id", o.id()));
}
return new FactBundle(out);
}
}

Three ways to install, in priority order:

@Configuration
class FactResolverConfig {
@Bean
FactResolver productionResolver(ProfileService profiles,
FeatureFlags flags,
AuditLog audit) {
return new UserProfileFactResolver(profiles, flags, audit);
}
}

AtmosphereAiAutoConfiguration.atmosphereFactResolverBridge publishes the bean onto framework.properties() under FactResolver.FACT_RESOLVER_PROPERTY; AiEndpointHandler.resolveFactResolver finds it first.

2. ServiceLoader (plain servlet / Quarkus / embedded)

Section titled “2. ServiceLoader (plain servlet / Quarkus / embedded)”

Drop a file at META-INF/services/org.atmosphere.ai.facts.FactResolver containing your resolver’s fully-qualified class name. The handler’s ServiceLoader.load(FactResolver.class).findFirst() picks it up.

FactResolverHolder.install(new UserProfileFactResolver(...));

The process-wide holder is the lowest priority fallback — mostly useful for test setup via @BeforeAll + FactResolverHolder.reset() in @AfterEach.

The resolver’s bundle is rendered as a newline-delimited block and prepended to the system prompt. A request from alice@example.com on a paid tier sees, at the top of the system prompt:

Grounded facts (deterministic, as of this turn):
- time.now: 2026-04-19T18:32:14Z
- time.timezone: UTC
- user.id: alice@example.com
- user.name: Alice Martin
- user.locale: en-US
- user.plan_tier: enterprise
- app.recent_order_id: ord-7821
[then whatever the developer's @AiEndpoint systemPrompt said]

AI Filters & Guardrails

Inspect and block the response path — PII redaction + drift detection. /tutorial/12-ai-filters/