GitHub user rosemarYuan edited a discussion: [Discussion]Memory Oobservation 
Capability Design

<html><head></head><body><h2>Introduction</h2>
<p>This doc introduces Memory Observability for Flink Agents — the ability to 
observe how an agent's memory changes as the agent runs.</p>
<p>The framework already provides event based observability 
(<code>EventLogger</code> / <code>EventListener</code>), but memory reads and 
writes do not produce events today, so memory operations are invisible to 
it.</p>
<p>This doc proposes to close that gap by reusing the existing event-based 
observability: the framework records each memory operation as it happens, and 
at the action execution boundary converts the records into events, so that all 
existing tooling (logging, listening, routing, action triggering) works for 
memory out of the box.</p>
<p>The sections below will cover:</p>
<ul>
<li>The motivation and design goals.</li>
<li>The API design: configuration options and the memory event format.</li>
<li>How events are generated internally, and how the <code>value</code> payload 
is encoded.</li>
<li>Rebuilding the full Short-Term / Sensory Memory state from the event 
log.</li>
</ul>
<h2>1. Motivation</h2>
<h3>Background</h3>
<p>Flink Agents provides an event-based observability stack: users can record 
and export framework events with <code>EventLogger</code> / 
<code>EventListener</code>. Meanwhile, the framework defines three types of 
memory with the following operations:</p>
<ul>
<li>Sensory Memory / Short-Term Memory (STM): Read / Write</li>
<li>Long-Term Memory (LTM): Add / Delete / Get / Search</li>
</ul>
<h3>Problem</h3>
<p>Users can observe an agent's run results through events. But for agents that 
use memory, there is currently no way to observe how memory evolves inside the 
agent.</p>
<h3>Goal</h3>
<p>Use the existing event-based observability and turn memory operations into 
memory-events. Concretely: record each memory operation synchronously as it 
happens, then convert those records into events at the action boundary. 
Existing observability tooling then applies to memory unchanged.</p>
<ul>
<li>Keep the existing event observation and routing logic untouched.</li>
<li>Optionally support per-memory-type, per-operation observation.</li>
</ul>
<pre><code class="language-plaintext">// Goal: N memory operations inside one 
action → a set of subscribable memory-events

user action :  memory operations ──▶ action boundary
            [recorded synchronously]       │
                                           ▼
                        [converted into N types of memory events]
                                           │
              ┌────────────────────────────┼────────────────────────────┐
              ▼                            ▼                            ▼
        EventLogger                 EventListener            routing + action 
triggering
</code></pre>
<p>Target usage:</p>
<pre><code class="language-java">// Enable memory observability
AgentConfiguration config = new AgentConfiguration();
config.set(MemoryEventOptions.MEMORY_GENERATE_EVENT, true);

// From now on, the memory operations of every action are emitted as
// _&lt;scope&gt;_&lt;operation&gt;_event at the action boundary. For example, 
the user
// can subscribe to short-term-memory writes of the user.id field:
@Action("type == '_short_term_write_event' &amp;&amp; 'user.id' in value")
public void onIdChanged(Event event, RunnerContext ctx) { ... }
</code></pre>
<h2>2. API Design</h2>
<h3>2.1 Configuration Options</h3>
<p>By default, memory observability generates a set of commonly useful events. 
Without any configuration, users can observe the high-value memory operations: 
short-term writes, sensory writes, long-term updates, long-term gets, and 
long-term searches.</p>
<p>Users who need to adjust the observation scope can control whether each type 
of memory operation generates events via <code>AgentConfiguration</code>. The 
framework provides a master switch and per-operation switches:</p>
<ul>
<li><code>memory.generate-event</code>: the master switch. It has no default 
value; when a per-operation switch is not configured, the resolution falls back 
to the master switch's configured value.</li>
<li><code>memory.generate-event.&lt;scope&gt;-&lt;operation&gt;</code>: 
per-operation switches, each controlling whether one specific kind of memory 
operation generates events.</li>
</ul>

Option key | Default behavior (when neither the per-operation switch nor the 
master switch is configured) | Description
-- | -- | --
memory.generate-event | — (no default value) | Master switch for memory events. 
Acts as the fallback when a per-operation switch is not configured; if the 
master switch is not configured either, the operation's own default behavior 
applies
memory.generate-event.short-term-write | record | Whether to record short-term 
memory writes
memory.generate-event.short-term-read | do not record | Whether to record 
short-term memory reads
memory.generate-event.sensory-write | record | Whether to record sensory memory 
writes
memory.generate-event.sensory-read | do not record | Whether to record sensory 
memory reads
memory.generate-event.long-term-update | record | Whether to record long-term 
memory updates (both writes and deletes)
memory.generate-event.long-term-get | record | Whether to record long-term 
memory gets
memory.generate-event.long-term-search | record | Whether to record long-term 
memory searches


<p>Since the nested-map encoding cannot distinguish writing multiple leaf 
values field by field from writing a whole map at once, we choose the 
dotted-key encoding:</p>
<pre><code class="language-java">// ①  STM write — writes the two leaves 
user.tier and user.address.city
{ "type": "_short_term_write_event", "id": "&lt;uuid&gt;", "key": "user-42",
  "value": { "user.tier": "gold",           // add value
             "user.address.city": {
                  "Shanghai": 1201,
                  "Beijing" : 2021 }        // add map
                  } }

// ②  STM read — reads user.address.city
{ "type": "_short_term_read_event", "id": "&lt;uuid&gt;", "key": "user-42",
  "value": { "user.address.city": "SF" } }

// ③  LTM update — key = "memorySet.itemId", value = content; null = delete
{ "type": "_long_term_update_event", "id": "&lt;uuid&gt;", "key": "user-42",
  "value": {
    "user_prefs.m_8f3": "user prefers email notifications",   // add
    "profile.m_a01":    null                                  // delete
  } }

// ④  LTM get (by id)
{ "type": "_long_term_get_event", "id": "&lt;uuid&gt;", "key": "user-42",
  "value": {
    "user_prefs.m_8f3": "user prefers email notifications",
    "user_prefs.m_a01": "user is located in SF"
  } }

// ⑤  LTM search — not dotted-key encoded; keeps the query → ordered result 
list form
{ "type": "_long_term_search_event", "id": "&lt;uuid&gt;", "key": "user-42",
  "value": {
    "refund policy": [
      { "id": "p_01", "value": "7-day no-questions-asked refund", "score": 0.92 
},
      { "id": "p_02", "value": "customized items are non-refundable", "score": 
0.81 }
    ]
  } }
</code></pre>
<h2>4. Rebuilding Short-Term / Sensory Memory State from the Event Log</h2>
<p>Besides observing memory at runtime and triggering new actions, there is a 
second class of demand: during log-based troubleshooting, reconstructing the 
memory state at a given moment from the event log.</p>
<p><strong>The basic idea:</strong> since the framework records every memory 
update from job start, applying the updates in order on top of a known initial 
state restores the state at any given moment. Feasibility differs per memory 
type, though:</p>
<ul>
<li><strong>LTM:</strong> user-written data goes through LLM processing 
(compaction, merging, deletion), so the state cannot be rebuilt by applying 
updates to an initial state.</li>
<li><strong>Sensory Memory:</strong> automatically cleared at the end of every 
agent run, so applying the <code>write</code> records of the current run is 
sufficient.</li>
<li><strong>STM:</strong> its content keeps evolving for the lifetime of the 
job. Replaying from job start makes the rebuild cost grow linearly with job 
age, so periodic full snapshots are needed as rebuild starting points.</li>
</ul>
<p>The rest of this section discusses when to record the full snapshots and how 
the rebuild works.</p>
<h3>4.1 When to Record Full Memory Snapshots</h3>
<p>The rebuild needs periodic full STM snapshots as starting points, i.e. 
recording the complete STM state per key every so often.</p>
<p><strong>Chosen: record at agent-run begin.</strong> Whenever an input for 
some key arrives and an agent run is about to start, record a full STM snapshot 
for that key. Concretely, when an <code>InputEvent</code> arrives, the 
framework emits an <code>AgentRunBeginEvent</code> marking the start of the 
agent run. The event carries the full STM as of the input's arrival, to be used 
for state reconstruction from the event log. The memory payload format is the 
same as the <code>value</code> structure in §3.2.</p>
<p><strong>Rejected alternative:</strong> record at checkpoint time. On 
checkpoint completion (<code>notifyCheckpointComplete</code>), iterate over all 
keys held by the subtask via <code>applyToAllKeys</code> and record a full STM 
snapshot per key. This was rejected because the checkpointing path already 
carries a lot of responsibilities.</p>
<p><strong>Usage:</strong></p>
<pre><code class="language-plaintext">// Enable AgentRunBeginEvent
config.set(MemoryEventOptions.AGENT_RUN_BEGIN_EVENT, true);
</code></pre>
<p><strong>Configuration:</strong></p>
<pre><code class="language-plaintext">// AgentRunBeginEvent is a run-lifecycle 
event. It is not governed by the
// memory event master switch, and is recorded by default.
public static final ConfigOption&lt;Boolean&gt; AGENT_RUN_BEGIN_EVENT =
        new ConfigOption&lt;&gt;("agent-run.begin-event", Boolean.class, true);
</code></pre>
<h3>4.2 How the Rebuild Works</h3>
<p>Since every agent run starts with a full STM snapshot, the rebuild needs 
neither a standalone command-line tool nor replaying long changelogs merged 
across runs. To rebuild the state of a key at time t:</p>
<ol>
<li>Locate, in the event log, the latest full snapshot of that key before t 
(i.e. the <code>AgentRunBeginEvent</code> of the run containing t).</li>
<li>Starting from that snapshot, apply the 
<code>_short_term_write_event</code>s of that run before t in order — this 
yields the state at t. If t is exactly a run boundary, the snapshot itself is 
the answer.</li>
</ol>
<p>The rebuild cost is proportional to the number of operations within a single 
run and does not grow with job age.</p>
<p>Likewise, since Sensory Memory always starts empty, applying the 
<code>write</code> records of the current run reconstructs its state at any 
moment.</p>
<h2>Future Works</h2>
<ul>
<li>A safety valve for large <code>value</code> payloads (e.g. size limits / 
truncation).</li>
<li>Optionally recording the previous value alongside the new value for richer 
diffs.</li>
<li>Recording full snapshots every N runs (counter-based) instead of every run, 
to reduce snapshot overhead.</li>
<li>An <code>AgentRunEndEvent</code> complementing 
<code>AgentRunBeginEvent</code>.</li>
</ul></body></html>

GitHub link: https://github.com/apache/flink-agents/discussions/876

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: [email protected]

Reply via email to