Grounding
Reduce hallucination by giving the LLM the source material — and recording what it produced vs what it was given. The trace IS the grounding evidence.
Your agent answers a customer's policy question. The customer screenshots the answer and emails your CEO: "I asked your bot whether refunds were available; it said no. Now I'm seeing they ARE available — what's going on?" You go to look. The agent might have hallucinated. Without grounding evidence captured at the moment of the answer, you cannot tell. agentfootprint's typed event stream + memory snapshots make grounding evidence a first-class artifact, not a thing you reconstruct.
What grounding means here
Grounding = constraining the LLM to answer from supplied source material, not from training-data memory. Two complementary mechanisms:
- Source injection — the source material (docs, policy text, retrieved chunks, prior decisions) lands in the system-prompt slot via RAG, Memory, or
defineFact. The LLM sees it as supplied context, above the conversation. - Source observation — the framework records observed injections (
agentfootprint.context.injected), model-call results (agentfootprint.stream.llm_end, with text withheld when an output policy applies), and a delivered answer when there is one (agentfootprint.agent.turn_end). Keep those different observations distinct in an audit.
Together they provide evidence for checking an answer against its observed sources. They do not themselves prove every claim correct or make a withheld model response available in an event-only export.
RAG as grounding
The RAG guide is the standard pattern. defineRAG retrieves top-K matching chunks above a strict threshold; if the threshold is missed, NO chunks inject (the LLM gets no context). This is intentional — garbage low-confidence chunks make hallucination MORE likely, not less.
const docs = defineRAG({
id: 'policy-docs',
store, embedder,
topK: 3, threshold: 0.7, // strict — no chunks if nothing clears 0.7
});
agent.rag(docs);When chunks DO match, they land in the system-prompt slot — one system message carrying every match as a citation block. Observability surfaces show exactly which chunks were retrieved per turn.
Causal memory as grounding for follow-ups
For cross-run grounding (the customer asks "why did you tell me that earlier?"), Causal memory persists snapshots of past runs. New questions cosine-match past queries; matching snapshots inject the stored run. The follow-up answer is grounded in what actually happened, not reconstruction.
This is the differentiator no other framework has. See Memory guide § Causal memory.
Recording what was injected
Subscribe to agentfootprint.context.injected to capture every injection in real time:
agent.on('agentfootprint.context.injected', (e) => {
// e.payload — { source, slot, contentSummary, rawContent?, asRole?, sourceId?, reason, ... }
// source: 'steering' | 'instructions' | 'skill' | 'fact' | 'memory' | 'rag' | ...
// rawContent is the full injected source (may be redacted); contentSummary is always present.
const content = e.payload.rawContent ?? e.payload.contentSummary;
audit.log({ when: Date.now(), source: e.payload.source, content });
});Every flavor of context engineering routes through the SAME event channel — the audit log is uniform across Skill, Steering, Instruction, Fact, Memory, RAG. One event taxonomy for context observability; no per-feature integration glue.
Recording what the LLM produced
agentfootprint.stream.llm_end carries the LLMEndPayload — content, toolCallCount, usage, stopReason, durationMs, iteration. Keep each call's event, rather than selecting the first one: a turn may contain multiple calls.
With output middleware, this event carries contentWithheld: true and empty content: the draft is withheld, not absent. Use agent.turn_end.finalContent for the delivered answer, and distinguish it from raw model output. A refused answer has no turn_end. Raw drafts remain in the run's audit state, which needs separate access and retention protection; do not copy them into a public event stream to replace the withheld field.
Capture the observations available for each run:
For events belonging to one run:
injected_chunks = all context.injected events
model_calls = every stream.llm_end event, keeping iteration and usage:
if contentWithheld is true:
output = { status: 'withheld' } // never infer an empty model answer
otherwise:
output = { status: 'observed', text: content }
delivered_answer = agent.turn_end.finalContent, only if that event exists
evidence = { question, injected_chunks, model_calls, delivered_answer }This record distinguishes available model text from withheld drafts and from the answer actually delivered. Persist it under an appropriate access and retention policy. A missing turn_end means no delivered answer was observed; it does not by itself explain whether the run paused, failed, or the event capture was incomplete. An event-only record cannot reconstruct text that the output policy withheld.
Anti-patterns
- Don't describe an event-only record as complete when response text was withheld or capture was incomplete. Keep that limitation visible rather than treating blank
llm_end.contentas an empty answer. - Don't trust the LLM to cite sources from memory. It will hallucinate URLs / doc IDs. Cite by injecting the source AND requiring the LLM to repeat the injected source ID in its answer (verifiable via comparison).
- Don't mix high-confidence and low-confidence sources without flagging. If RAG returns chunks at score 0.9 and 0.5, the LLM treats them equally. Either threshold strictly OR weight injection role to mark uncertainty.
Next steps
- RAG guide — the strict-threshold retrieval pattern
- Memory guide — Causal memory for cross-run grounding
- Observability guide — the full 59-event taxonomy
Strict output (Instructor-style schema retry)
Wire outputSchema validation INTO the reliability gate so failed validations re-prompt the model within the current turn — without burning a full ReAct loop iteration. New helpers, ephemeral-message handling, and stuck-loop detection.
Names and numbers from evidence
Every number, identifier and name in the final answer must appear in a tool result the run really read. If one does not, the model typed it rather than read it — a deterministic check, no second model.
