Context Engineering

Context Engineering with a Streaming Database

Context engineering decides what a model sees on each turn, in what shape, how large and how fresh. When the facts change by the second, that is a data problem. This guide gives a method: layers with budgets, views that enforce them, one document per turn, a lookup tool for depth, and a log that says whether a change helped. With complete SQL and Python.

Last reviewed 2026-11-09. Covers RisingWave 2.6 and later.

Companion guide: Real-Time Context for AI Agents →

5
Context Layers
Instructions, live state, knowledge, memory, tool results, each with a budget
1 query
Per Turn
The whole live layer as one JSON document from one consistent snapshot
Seconds
Freshness
Views update as events arrive; every layer carries its own timestamp
Logged
Every Turn
Tokens, latency and outcome per context version, aggregated in views

The Problem

What goes wrong with agent context, and why is it a data problem?

Models are now good enough that most agent failures in production are context failures. The model was told something stale, or two things that were true at different moments, or everything about a customer when it needed five facts, or raw events when it needed a total. Each of these is a decision about data: what to include, how to shape it, how current it must be, and how big it may be.

The term context engineering names that decision as a discipline. It is wider than prompt engineering, which concerns the wording of instructions, and wider than retrieval, which concerns one layer. For an agent acting on a live business, the layer that is hardest to get right is live state: the order, the flight, the account, the queue, as of now. That layer comes from databases, and the way the database serves it determines whether the context is fresh, consistent and bounded or none of the three.

The table lists the common failures and how the usual remedies compare with maintaining the context as views in a streaming database.

FailureUsual causeContext as streaming views
Stale fact in the promptNightly or hourly load; cache filled by a jobView maintained from the event stream; timestamp per layer
Inconsistent facts across the promptSeveral tool calls at different momentsOne query over one snapshot
Prompt grows with the dataRaw rows or whole histories pasted inBudget enforced by the view: counts, Top-N, windows
Model does the arithmeticRaw events in, totals out of the modelTotals, flags and rankings computed in SQL
Rare question inflates every promptEverything included just in caseSmall default context plus a lookup tool
Nobody knows if a change helpedNo per-turn recordTurn log table and per-version views

The Layers

What are the layers of agent context, and which should a database own?

A useful way to budget context is to name its layers and give each a source, a shape, a freshness target and a token budget. Five layers cover most agents. Instructions are static text under version control. Retrieved knowledge comes from documents through vector search. Memory is what the agent itself wrote. Tool results are what the model chooses to fetch during the turn. Live state is everything the business currently knows about the entity in front of the agent, and it is the layer this page is about.

LayerSourceShapeFreshnessBudget
InstructionsPrompt repository, versionedTextPer releaseFixed, 500 to 1,500 tokens
Live stateStreams and CDC through materialized viewsJSON with counts, latest-N, windowsSeconds1,000 to 2,000 tokens, enforced in the views
Retrieved knowledgeDocuments and policies through vector searchA few chunks with provenanceSeconds to minutes1,000 to 2,000 tokens, LIMIT in the query
MemoryTable the agent writes, view that summarisesLast N notes, promises, preferencesImmediate200 to 500 tokens
Tool resultsLookups the model chooses to makeWhatever the tool returnsAt call timeHeadroom; keep tools narrow

The database owns live state and memory outright and can own retrieval too (see the real-time RAG pipeline guide). What makes a streaming database the right owner of live state is that a materialized view fixes all four properties at definition time. Its source is the stream, so freshness is seconds. Its shape is the SELECT, so the model sees totals and flags rather than rows. Its size is bounded by the grouping, Top-N and windows in the definition, so the budget holds as the data grows. And every view is read from the same snapshot, so the facts in one context document agree with each other.

The Method

An airline disruption agent: budgets, views, one query, a lookup tool and a log

The scenario is an airline whose agent helps passengers when flights are delayed or cancelled. Flight status events stream from operations through Kafka. Flights, bookings and passengers live in PostgreSQL. The agent must know the passenger's trip, whether it is disrupted, what the best alternatives are, how bad things are at the airport, and what it promised the passenger earlier. The live layer has a budget of 1,500 tokens. Everything runs on a single-node RisingWave started with the Docker command from the companion guide.

Step 1. Sources

Status events are an append-only topic with a watermark. Reservations arrive as change streams, so a rebooking in the operational system is an update to one row here within a second or two.

SQL (RisingWave)
-- Scenario: an airline disruption agent helping passengers whose flights change.
CREATE SOURCE flight_events (
  flight_id VARCHAR, flight_date DATE, status VARCHAR, gate VARCHAR,
  est_departure TIMESTAMPTZ, delay_minutes INT, reason VARCHAR, event_ts TIMESTAMPTZ,
  WATERMARK FOR event_ts AS event_ts - INTERVAL '30 seconds'
) WITH (connector = 'kafka', topic = 'flight_events',
        properties.bootstrap.server = 'broker:9092')
FORMAT PLAIN ENCODE JSON;

CREATE SOURCE res_pg WITH (
  connector = 'postgres-cdc', hostname = 'pg', port = '5432',
  username = 'rw', password = '...', database.name = 'reservations', schema.name = 'public'
);

CREATE TABLE flights (flight_id VARCHAR, flight_date DATE, origin VARCHAR, destination VARCHAR,
                      sched_departure TIMESTAMPTZ, seats_available INT,
                      PRIMARY KEY (flight_id, flight_date))
  FROM res_pg TABLE 'public.flights';

CREATE TABLE bookings (pnr VARCHAR PRIMARY KEY, passenger_id BIGINT, flight_id VARCHAR,
                       flight_date DATE, cabin VARCHAR, status VARCHAR, booked_at TIMESTAMPTZ)
  FROM res_pg TABLE 'public.bookings';

CREATE TABLE passengers (passenger_id BIGINT PRIMARY KEY, name VARCHAR, tier VARCHAR,
                         language VARCHAR, needs_assistance BOOLEAN)
  FROM res_pg TABLE 'public.passengers';

Step 2. Context objects with the budget built in

Each view is one object in the context document and its definition fixes its size. flight_now is one row per flight by group Top-N. passenger_trip is one row per booking with a disrupted flag computed in SQL, so the model never has to infer it. rebooking_options ranks alternatives and keeps three per disrupted flight. airport_disruption_30m is one row per airport per sliding window. passenger_memory keeps the last five notes. Whatever volume the airline runs, the live layer for one passenger is a few hundred tokens.

SQL (RisingWave)
-- Context object 1: current status per flight. Budget: one row. Latest event per key.
CREATE MATERIALIZED VIEW flight_now AS
SELECT flight_id, flight_date, status, gate, est_departure, delay_minutes, reason,
       event_ts AS status_as_of
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY flight_id, flight_date ORDER BY event_ts DESC) AS rn
      FROM flight_events) t
WHERE rn = 1;

-- Context object 2: the passenger's trip. Budget: one row per booking, joined and flagged.
CREATE MATERIALIZED VIEW passenger_trip AS
SELECT b.pnr, b.passenger_id, p.name, p.tier, p.language, p.needs_assistance,
       b.flight_id, b.flight_date, b.cabin, b.status AS booking_status,
       f.origin, f.destination, f.sched_departure,
       n.status AS flight_status, n.gate, n.est_departure, n.delay_minutes, n.reason,
       n.status_as_of,
       (n.status IN ('cancelled', 'diverted') OR n.delay_minutes >= 120) AS disrupted
FROM bookings b
JOIN passengers p ON p.passenger_id = b.passenger_id
JOIN flights f ON f.flight_id = b.flight_id AND f.flight_date = b.flight_date
LEFT JOIN flight_now n ON n.flight_id = b.flight_id AND n.flight_date = b.flight_date
WHERE b.status = 'confirmed';

-- Context object 3: rebooking options. Budget: top 3 per disrupted flight, ranked in SQL.
CREATE MATERIALIZED VIEW rebooking_options AS
SELECT * FROM (
  SELECT d.flight_id AS from_flight, d.flight_date AS from_date,
         a.flight_id AS alt_flight, a.flight_date AS alt_date,
         COALESCE(an.est_departure, a.sched_departure) AS alt_departure,
         a.seats_available,
         ROW_NUMBER() OVER (PARTITION BY d.flight_id, d.flight_date
                            ORDER BY COALESCE(an.est_departure, a.sched_departure)) AS rn
  FROM flights d
  JOIN flight_now dn ON dn.flight_id = d.flight_id AND dn.flight_date = d.flight_date
  JOIN flights a ON a.origin = d.origin AND a.destination = d.destination
                AND a.sched_departure > d.sched_departure
                AND a.sched_departure < d.sched_departure + INTERVAL '12 hours'
  LEFT JOIN flight_now an ON an.flight_id = a.flight_id AND an.flight_date = a.flight_date
  WHERE dn.status IN ('cancelled', 'diverted')
    AND a.seats_available > 0
    AND COALESCE(an.status, 'scheduled') NOT IN ('cancelled', 'diverted')
) t WHERE rn <= 3;

-- Context object 4: how bad is it right now at the airport? Budget: one row, sliding 30 minutes.
CREATE MATERIALIZED VIEW airport_disruption_30m AS
SELECT f.origin AS airport, window_start, window_end,
       COUNT(*) FILTER (WHERE e.status = 'cancelled')        AS cancellations,
       COUNT(*) FILTER (WHERE e.delay_minutes >= 60)         AS long_delays,
       MAX(e.delay_minutes)                                   AS worst_delay
FROM HOP(flight_events, event_ts, INTERVAL '5 minutes', INTERVAL '30 minutes') e
JOIN flights f ON f.flight_id = e.flight_id AND f.flight_date = e.flight_date
GROUP BY f.origin, window_start, window_end;

-- Context object 5: memory. The agent writes; a view keeps the last five per passenger.
CREATE TABLE agent_notes (
  note_id BIGINT PRIMARY KEY, passenger_id BIGINT, kind VARCHAR, note VARCHAR, created_at TIMESTAMPTZ
);
CREATE MATERIALIZED VIEW passenger_memory AS
SELECT passenger_id, ARRAY_AGG(kind || ': ' || note ORDER BY created_at DESC) AS notes
FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY passenger_id ORDER BY created_at DESC) AS rn
      FROM agent_notes) t
WHERE rn <= 5
GROUP BY passenger_id;

CREATE INDEX passenger_trip_by_pnr ON passenger_trip (pnr);
CREATE INDEX rebooking_by_flight ON rebooking_options (from_flight, from_date);
CREATE INDEX disruption_by_airport ON airport_disruption_30m (airport);

Step 3. One context document per turn

The context query returns one JSON value from one snapshot. Rebooking options are included only when the trip is disrupted, which is the first budgeting rule applied at read time. The freshness object carries the timestamp of the newest status event and the lag to now, so the model and the operator both know how current the context is. The now() call is in the read query, where it belongs; streaming views compute from event time.

SQL (RisingWave): context.sql
-- context.sql: the live layer as one JSON document, from one snapshot. $1 = pnr.
SELECT jsonb_build_object(
  'trip',        to_jsonb(t) - 'passenger_id',
  'rebooking',   CASE WHEN t.disrupted THEN (
                   SELECT jsonb_agg(jsonb_build_object(
                            'flight', r.alt_flight, 'departs', r.alt_departure,
                            'seats', r.seats_available))
                   FROM rebooking_options r
                   WHERE r.from_flight = t.flight_id AND r.from_date = t.flight_date)
                 ELSE NULL END,
  'airport',     (SELECT to_jsonb(a) - 'airport' FROM airport_disruption_30m a
                   WHERE a.airport = t.origin
                   ORDER BY a.window_end DESC LIMIT 1),
  'memory',      COALESCE(m.notes, ARRAY[]::VARCHAR[]),
  'freshness',   jsonb_build_object(
                   'flight_status_as_of', t.status_as_of,
                   'generated_at', now(),
                   'status_lag_seconds', EXTRACT(EPOCH FROM now() - t.status_as_of))
) AS context
FROM passenger_trip t
LEFT JOIN passenger_memory m ON m.passenger_id = t.passenger_id
WHERE t.pnr = $1;

Step 4. The turn, with budget enforcement and a lookup tool

The application fetches the document, checks its size with the token-counting endpoint and drops the least important layers in a fixed order if it exceeds the budget. It is a safety net: the views keep the document small by construction, and the counter tells you when a definition has drifted. The system prompt explains the freshness field. One lookup tool gives the model depth on demand, with the deeper views named in its description so the model knows what it can ask for.

Python (psycopg 3 + Claude API)
import json, re, time, psycopg, anthropic

rw  = psycopg.connect("postgresql://disruption_agent@rw:4566/dev", autocommit=True)
llm = anthropic.Anthropic()
CONTEXT_SQL = open("context.sql").read()

def bind(sql: str) -> str:
    """The SQL files use Postgres $n placeholders; psycopg wants %(name)s."""
    return re.sub(r"\$(\d+)", r"%(p\1)s", sql)
CONTEXT_VERSION = "v3"           # bump when a view or the query changes
LIVE_BUDGET_TOKENS = 1500

SYSTEM = """You are an airline disruption agent. <context> holds the passenger's trip, rebooking
options ranked by departure time, airport conditions and notes from earlier conversations.
Every fact has a timestamp under freshness; if status_lag_seconds exceeds 120, say the status
may be out of date. Use the lookup tool only for details not in the context."""

LOOKUP_TOOL = {
    "name": "lookup",
    "description": "Read more detail from live views. kind is one of: all_options (every rebooking "
                   "option, not just the top 3), flight_history (last 20 status events for a flight), "
                   "other_bookings (the passenger's other confirmed trips).",
    "input_schema": {"type": "object",
                     "properties": {"kind": {"type": "string"}, "key": {"type": "string"}},
                     "required": ["kind", "key"]},
}

def fetch_context(pnr: str) -> dict:
    with rw.cursor() as cur:
        cur.execute(bind(CONTEXT_SQL), {"p1": pnr})
        row = cur.fetchone()
    return row[0] if row else {}

def fit_budget(ctx: dict) -> dict:
    """Drop the least important layer until the live layer fits. Order matters."""
    for layer in ("airport", "memory", "rebooking"):
        tokens = llm.messages.count_tokens(
            model="claude-sonnet-5-5", system=SYSTEM,
            messages=[{"role": "user", "content": json.dumps(ctx, default=str)}]).input_tokens
        if tokens <= LIVE_BUDGET_TOKENS:
            break
        ctx.pop(layer, None)
    return ctx

def answer(pnr: str, question: str) -> str:
    t0 = time.time()
    ctx = fit_budget(fetch_context(pnr))
    resp = llm.messages.create(
        model="claude-sonnet-5-5", max_tokens=700, system=SYSTEM, tools=[LOOKUP_TOOL],
        messages=[{"role": "user",
                   "content": f"<context>{json.dumps(ctx, default=str)}</context>\n\n{question}"}])
    log_turn(pnr, resp, time.time() - t0, ctx)
    return resp.content[0].text if resp.stop_reason == "end_turn" else handle_tool_call(resp, pnr, question)

The lookup tool is a dictionary of parameterised SELECTs over the same views and sources. Nothing here is free-form SQL from the model: the model chooses a kind and a key, and the application owns the query. Memory is a plain INSERT; the view picks it up on the next barrier, so a note written in this turn is in the context of the next.

Python: lookup tool and memory
LOOKUPS = {
    "all_options":    "SELECT alt_flight, alt_departure, seats_available FROM rebooking_options "
                      "WHERE from_flight = split_part($1, '/', 1) AND from_date = split_part($1, '/', 2)::date "
                      "ORDER BY alt_departure",
    "flight_history": "SELECT status, gate, delay_minutes, reason, event_ts FROM flight_events "
                      "WHERE flight_id = split_part($1, '/', 1) AND flight_date = split_part($1, '/', 2)::date "
                      "ORDER BY event_ts DESC LIMIT 20",
    "other_bookings": "SELECT pnr, flight_id, flight_date, origin, destination, flight_status "
                      "FROM passenger_trip WHERE passenger_id = $1::bigint",
}

def lookup(kind: str, key: str) -> str:
    sql = LOOKUPS.get(kind)
    if sql is None:
        return json.dumps({"error": f"unknown kind {kind}"})
    with rw.cursor() as cur:
        cur.execute(bind(sql), {"p1": key})
        cols = [d.name for d in cur.description]
        return json.dumps([dict(zip(cols, r)) for r in cur.fetchall()], default=str)

# Memory is a write to a table; the passenger_memory view picks it up on the next barrier.
def remember(passenger_id: int, kind: str, note: str):
    with rw.cursor() as cur:
        cur.execute("INSERT INTO agent_notes VALUES (%s, %s, %s, %s, now())",
                    (int(time.time() * 1000), passenger_id, kind, note))

Step 5. Measure every turn

The turn log is a table the application writes and the hourly view aggregates by agent and context version. Shipping a context change means bumping the version string; an hour later the view shows average input tokens, latency, tool calls per turn and resolved rate for the old and new versions side by side. The freshness checks are read queries for a monitor to run every minute, and the state-size catalog shows which view to tighten when the live layer starts to grow.

SQL (RisingWave)
-- The application writes one row per turn.
CREATE TABLE turn_log (
  turn_id VARCHAR PRIMARY KEY, agent VARCHAR, context_version VARCHAR,
  pnr VARCHAR, input_tokens INT, output_tokens INT, tool_calls INT,
  latency_ms INT, outcome VARCHAR, logged_at TIMESTAMPTZ
);

-- Cost and behaviour per context version, hourly. Compare v2 with v3 the hour after you ship it.
CREATE MATERIALIZED VIEW context_cost_1h AS
SELECT agent, context_version, window_start, window_end,
       COUNT(*)                                            AS turns,
       AVG(input_tokens)                                   AS avg_input_tokens,
       MAX(input_tokens)                                   AS max_input_tokens,
       AVG(latency_ms)                                     AS avg_latency_ms,
       SUM(tool_calls)::float / COUNT(*)                   AS tool_calls_per_turn,
       COUNT(*) FILTER (WHERE outcome = 'resolved')::float / COUNT(*) AS resolved_rate,
       COUNT(*) FILTER (WHERE outcome = 'corrected')       AS corrections
FROM TUMBLE(turn_log, logged_at, INTERVAL '1 hour')
GROUP BY agent, context_version, window_start, window_end;

-- Freshness SLA: how far behind is the live layer? Read query, run by the monitor every minute.
SELECT MAX(event_ts) AS latest_event, now() - MAX(event_ts) AS lag FROM flight_events;
SELECT MAX(status_as_of) AS latest_status, now() - MAX(status_as_of) AS lag FROM flight_now;

-- Which context objects are big? State per view; a growing one needs a tighter definition.
SELECT * FROM rw_catalog.rw_table_stats ORDER BY total_key_count DESC;

All of the SQL above is documented RisingWave behaviour: see the Top-N by group, time windows, joins and JSON functions pages.

Alternatives

How do the ways of assembling live context compare?

Every approach below can put live facts in front of a model. The columns that separate them are the four properties the method above fixes at definition time: freshness, consistency across the document, whether the token footprint is bounded, and who decides the shape.

ApproachFreshnessConsistencyToken footprintWho shapes it
Materialized views in a streaming database (RisingWave)SecondsOne snapshot per queryBounded by view definitionsData team, in SQL, versioned
Tool-call assembly at runtime (one tool per source)Current per callNo; each call sees a different momentTool results plus reasoning; grows with callsModel decides per turn
Application cache (Redis) filled by jobsJob intervalPer key, not across keysBounded by the job's outputApplication code
Feature store (Feast, Tecton)Streaming features in seconds; batch features per runPer entityNumeric features; text and lists need extra workML platform team
Vector database onlyIndex lagNo structured stateChunks; no counts or flagsRetrieval team
Raw rows plus model summarisationCurrentDepends on the queryUnbounded; the model pays to summariseNobody; the model improvises

Tool-call assembly deserves a note because it is the default in most agent frameworks. It is right for the long tail of questions, which is why the method keeps a lookup tool. It is wrong for the facts every turn needs, because it spends model reasoning on deciding what to fetch, makes several round trips, and produces a context whose parts were true at different moments. Precomputing the common case and keeping tools for the rare one is the combination.

Design Decisions

Six questions to settle before you build

Budget first or shape first?

Budget first. Decide how many tokens the live layer may use per turn, then design views that cannot exceed it: a latest-per-key view is one row per key, a Top-3 is three rows, a window aggregate is one row. Shape follows from the budget. The reverse order produces views that are correct and prompts that grow until someone notices the bill.

What does the model compute and what does SQL compute?

SQL computes anything with a definition: counts, sums, latest, rank, flags, joins. The model interprets, explains and decides. A disrupted flag computed in the view is right every time and costs nothing; the same judgement made by the model from raw events costs tokens and is occasionally wrong. Keep the model's work to what only a model can do.

Should freshness be visible to the model?

Yes. Return the timestamp of the newest fact in each layer, and the lag between it and now, inside the context. The system prompt tells the model what to do when the lag is large. This turns a silent staleness failure into a visible caveat, and it gives operators a number to alert on.

One context per agent or one shared?

One per agent, built from shared views. The disruption agent and a loyalty agent both read passenger_trip, but they assemble different documents with different budgets. The views are the shared, versioned layer; the context query is per agent. Changing a view then changes every agent that uses it, which is what you want for a fix and what you must test for a redefinition.

Where does memory stop and state begin?

State is what the world says; memory is what the agent said or learned. Keep them in separate tables and views, show both in the context, and let a view join them to surface contradictions: a promise in memory about a flight that state now says is cancelled is exactly the kind of fact an agent should open with.

How do you know a change helped?

By versioning the context and logging turns. Tag every turn with the context version, aggregate tokens, latency, tool calls and outcomes per version, and compare after a few hundred turns. The view in Step 5 is the whole experiment framework; the discipline is bumping the version when anything in the context changes.

When Not to Use This

Three cases where the live layer is not the problem

  • →A chatbot over static documents with no per-user state. Retrieval plus a good system prompt is the whole context; there is no live layer to engineer.
  • →Data that changes daily and is already in a warehouse. Build the context query there; a streaming database adds nothing when the facts do not move between turns.
  • →An agent whose entire state fits in the application's own request: a form, a document, a single record. Pass it in directly; views and budgets are for context that is assembled from many changing sources.

Frequently Asked Questions

What is context engineering?
Why does a database matter for context engineering?
How do I set a token budget for context?
Should I summarise raw rows with the model instead?
How fresh does the context need to be?
What is progressive disclosure in agent context?
How do I know whether a context change helped?
Does this work with LangGraph, the Claude Agent SDK or an MCP host?

Make the live layer a schema, not a scramble

Build the views on RisingWave Cloud or a single Docker container and point your agent at them.

Start Free
The engine for real-time streaming data.
Analyze, search, and decide.
GitHubXLinkedInSlackYouTube
Sign up for our to stay updated.