[api][runtime][python] Add memory events for agent memory observability - #887
[api][runtime][python] Add memory events for agent memory observability#887rosemarYuan wants to merge 4 commits into
Conversation
6eff697 to
37fe38f
Compare
zxs1633079383
left a comment
There was a problem hiding this comment.
I took a light design pass, mainly around whether the new memory events pollute user payloads or collide with the event-log lineage direction.
The current shape looks good to me:
- Memory observations are emitted as separate typed events (
_short_term_write_event,_long_term_search_event, etc.) rather than mutating the triggering user event or putting framework fields into user-owned event attributes. - The memory event payload is scoped under
attributes: { key, value }, so the observation payload is explicit and reconstructable without changing the user event schema. - The action-finish flush boundary is a good place to fold per-action memory records into net-effect events, and the suppression for memory-triggered actions avoids recursive observation noise.
AgentRunBeginEventis kept as a lifecycle/reconstruction anchor rather than overloading memory events themselves.
One thing I would keep intentionally separate from this PR: lineage/run identity fields from #841. This PR should probably continue to avoid adding runId, sourceEventId, emittingAction, etc. directly into the memory event value payload. If/when the event-log lineage schema lands, those fields should sit in event context/envelope metadata, not inside the user-facing memory observation value map. The docs already point in that direction by describing key/value as the memory event attributes, so this is more of a boundary note than a blocker.
|
Thanks for the design review @zxs1633079383 — agreed, and that is exactly the boundary I want to keep. For this PR, the memory event payload under On the lineage direction, we are aligned. If/when the event-log lineage schema lands, those fields should be carried as event-log metadata/envelope-level information and apply uniformly to logged events, memory events included. The exact carrier shape, for example whether it is a separate Same principle you described: memory events expose memory observation data; lineage stays outside the user-facing event payload. |
weiqingy
left a comment
There was a problem hiding this comment.
Thanks for taking this on — the ordering in flushMemoryObservation is a nice call: draining the LTM buffer unconditionally before the suppressed/disabled early returns (RunnerContextImpl.java:226-243) is what stops a suppressed action from leaking records into the next same-key action. A few questions inline, plus one design-level one up top.
-
agent-run.begin-eventdefaulting totrue(MemoryEventOptions.java:58) carries two coupled costs that might be worth weighing together, since they share that one root. First, every input record now pays a fullMapState.entries()scan of the key's short-term memory inmaybeEmitAgentRunBeginEvent(ActionExecutionOperator.java:299-317) plus anAgentRunBeginEventallocation carrying the whole STM snapshot — even when the EventLog is disabled and nothing subscribes to consume it. On a RocksDB backend that's a state-backend iterator per record, not a cheap in-memory read. Second, because that scan reads every entry on every input and STM state is TTL-configured for read-refresh, the PR flips the repo-wide default ofshort-term-memory.state-ttl.update-typefromON_READ_AND_WRITEtoON_CREATE_AND_WRITE(AgentExecutionOptions.java:63-67) so expiry can still fire. That flip is global and unconditional — disablingagent-run.begin-eventdoesn't restore the old read-refresh semantics, so a user who enables STM TTL and never touches memory events still sees changed expiry behavior.Was defaulting the begin-event to
false— or gating emission on the EventLog being configured — considered? Either would drop the per-record scan for everyone not reconstructing STM from the log, and would make the global TTL default flip unnecessary, while still letting the feature be turned on when someone wants log-based reconstruction. I can also see the counter-argument: if the snapshot only starts when you enable it, there's no reconstruction anchor for runs that happened before you flipped it on, which is a real reason to keep it on by default. So this is genuinely a question about which default serves the common case better, not a claim that on-by-default is wrong — curious how you weighed the two. -
docs/content/docs/operations/configuration.md:128introduces its table as "the list of all built-in core configuration options", but the family this PR adds isn't in it — the 8memory.generate-event[.*]keys andagent-run.begin-eventare absent (grepping the page forbegin-event/agent-runreturns nothing). They're documented in the newmemory_events.md, so this is a completeness gap on the page that advertises itself as exhaustive, not a docs hole. Of the omitted keys,agent-run.begin-eventis the one a reader is most likely to come to this page looking for — its default is exactly what makes the TTL default flip in item (1) necessary. Adding the 9 keys to the table (or softening the "all" claim) would close it.
| } else if ("DELETE".equals(op)) { | ||
| updates.put(set + "." + id, null); | ||
| } else if ("DELETE_SET".equals(op)) { | ||
| updates.put(set, null); |
There was a problem hiding this comment.
DELETE_SET adds set -> null but doesn't purge the earlier set + "." + id entries folded in above, so ADD s.m1=v then DELETE_SET s within one action yields {"s.m1": v, "s": null}. A log consumer reconstructing state from this event would see s.m1=v as a live member even though the same event's s -> null says the set — and that member with it — was deleted. It's observability-only, but the emitted value misreports the member. Purging the set + "." prefix entries when folding in a DELETE_SET would keep the reconstruction accurate.
There was a problem hiding this comment.
Hi @weiqingy, sorry for the delayed response. I was working on the Action Condition changes last week, so I didn’t get a chance to address this PR sooner. I’ve now addressed all the review comments and added the corresponding tests. Thanks again for the thorough review.
For begin-event, I hadn’t fully considered the cost you pointed out: enabling it by default makes every input scan the full STM state, even when log-based reconstruction is not needed. Given that overhead, I changed begin-event to be opt-in (false by default) and restored the original ON_READ_AND_WRITE TTL default. Users who need an STM reconstruction anchor can still enable the event explicitly.
There was a problem hiding this comment.
Thanks for working through all of these, they all look resolved to me. Left one optional nit on the drain-logging thread, otherwise nothing further from me.
…-safe LTM buffer, observation context)
37fe38f to
163266d
Compare
163266d to
b2bb753
Compare
wenjin272
left a comment
There was a problem hiding this comment.
Thanks for taking this on @rosemarYuan. I left some comments.
| } | ||
| longTermMemory = new Mem0LongTermMemory(pythonResourceAdapter, (PyObject) pyLtm); | ||
| // The Python runner context drains LTM observation records at action finish. | ||
| pythonRunnerContext.setLongTermMemory(longTermMemory); |
There was a problem hiding this comment.
With this wiring, could we remove the existing Python-side context switch in PythonActionExecutor.executePythonFunction()?
ActionTaskContextManager.createAndSetRunnerContext() calls RunnerContextImpl.switchActionContext() before every action execution or coroutine resume. Now that PythonRunnerContextImpl holds this Mem0LongTermMemory wrapper, that call
already switches the same Python Mem0 instance using the same key and also forwards the observation flags.
The following invocation in executePythonFunction() therefore appears redundant and switches the LTM context a second time for the initial Python action:
interpreter.invoke(
FLINK_RUNNER_CONTEXT_SWITCH_ACTION_CONTEXT,
pythonRunnerContext,
hashOfKey);
Could we remove this invocation, the now-unused hashOfKey parameter, and the corresponding Python helper so that LTM context switching has a single lifecycle path?
|
|
||
| /** Returns the logical String key for Java and PyFlink keyed streams, if supported. */ | ||
| @Nullable | ||
| private String resolveEventKeyText(Object key) { |
There was a problem hiding this comment.
Could we avoid restricting framework memory events to String-keyed streams?
Flink guarantees that the current key is non-null, so for Java inputs we can derive the event key text directly with:
String.valueOf(key)For PyFlink inputs, getCurrentKey() returns a single-field Row produced by KeyByKeySelector. We should first unwrap that field:
- If an explicit
key_typewas configured, the field is already the corresponding Java value and can useString.valueOf(logicalKey). - If no
key_typewas configured, PyFlink usesPICKLED_BYTE_ARRAY; thebyte[]should be decoded to the original Python key and then converted withstr().
The current implementation accepts only logical String keys, so common keys such as Long or Integer silently disable all framework memory events and AgentRunBeginEvent. Could we instead support the string representation of
any logical Flink key and document that the event's key attribute is an observational representation rather than a routing key?
| contextManager.createAndSetRunnerContext( | ||
| actionTask, | ||
| key, | ||
| resolveEventKeyText(key), |
There was a problem hiding this comment.
Could we avoid resolving eventKeyText inside processActionTaskForKey()?
The event key is invariant for the entire run, but this method is invoked once for every action task and coroutine/continuation resume. For PyFlink keys, resolveEventKeyText() may need to unwrap the key Row, decode pickle data,
and invoke the Python interpreter, so repeatedly resolving the same key is unnecessary.
Could we resolve it once in processEvent() when an InputEvent actually begins processing, then pass it to both tryEmitAgentRunBeginEvent() and the action-task processing chain?
tryProcessActionTaskForKey(key, eventKeyText);
processActionTaskForKey(key, eventKeyText);When additional action tasks or continuations are scheduled, they can forward the same value. For checkpoint recovery, tryResumeProcessActionTasks() can resolve it once per restored processing key before submitting the first
mailbox task.
This would avoid repeated cross-language key decoding without introducing an additional cache or checkpointed state.
| String actionName, | ||
| MemoryContext memoryContext, | ||
| String ltmPartitionKey, | ||
| @Nullable String eventKeyText, |
There was a problem hiding this comment.
Could we use a single resolved key here instead of passing both ltmPartitionKey and eventKeyText?
Both values are derived from the same Flink key and identify the same run context. If the logical key is resolved and converted to text once, that value could be used for both the LTM context and framework events:
switchActionContext(
actionName,
memoryContext,
contextKey,
observationSuppressed);This would simplify the contract and avoid using key.hashCode() as a separate LTM identity, which introduces an additional collision risk.
|
|
||
| @override | ||
| def switch_context(self, key: str) -> None: | ||
| def switch_context( |
There was a problem hiding this comment.
The signature here is inconsistent with InternalBaseLongTermMemory.switch_context(), which only declares the key parameter. Could we keep the parent and subclass signatures aligned?
| enabled: Whether search observation is configured. | ||
| """ | ||
| try: | ||
| if not enabled or getattr(self.ctx, "_j_runner_context", None) is None: |
There was a problem hiding this comment.
Is this check necessary? Mem0LongTermMemory is created with the FlinkRunnerContext constructed by create_flink_runner_context(), where _j_runner_context is always initialized with the Java runner context. Therefore, it cannot be None on this path.
| observationAllowed | ||
| && memoryEventSettings.generate( | ||
| MemoryEventSettings.MemoryOp.LONG_TERM_SEARCH); | ||
| this.ltmObservationEnabled = |
There was a problem hiding this comment.
The per-operation observation settings are resolved from the AgentPlan and remain constant for the lifetime of the job. With the logical key always available, the only action-specific state here is observationSuppressed.
Could we configure the update/get/search settings once when initializing the LTM, and make switchActionContext() only switch the key and suppression state? This would separate job-level configuration from action-level context switching.
Linked issue: #886
Purpose of change
Follow-up to #876, #886 .This change makes agent memory activity observable through the existing event infrastructure.
Emission model.
valueis a dot-key flat map describing the operation's net effect. Within one action, the last read/write for the same path wins.Recovery semantics. Generation remains consistent with the framework's existing delivery and durability guarantees:
durable_executeis not recorded again when its result is replayed from state. Generated events describe operations that actually executed in the current attempt.Tests
Coverage targets the design-critical paths:
MemoryEventWireFormatTestin Java andtest_memory_event_wire_format.pyin Python pin the same attribute JSON, preventing SDK drift.TestMemoryObservationFlushcovers emit-once-per-finished-action behavior, no emission on unfinished/failed drains, config gating, suppression awareness, and drain-then-discard semantics. A suppressed or disabled action still drains the long-term buffer, so records cannot leak into the next same-key action.TestMemoryEventSettingscovers all three resolution levels, including the case where the master switch is off but an operation-specific sub-key is explicitly on.TestMemoryEventBuildercovers empty paths, single paths, nested paths, last-wins behavior, and long-term add/delete/get/search value shapes.ActionStateSerdeTestverifies that memory events round-trip through the@classmixin, so they can be persisted and replayed with the action's output events.AgentRunBeginEventEmissionTest,AgentRunBeginEventTest, andtest_run_eventcover snapshot content, per-key isolation, and the guarantee that the snapshot is taken before the run's own writes.test_mem0_op_recordingandtest_mem0_recording_hookcover thread-safe buffering and drain-by-key behavior, including re-buffering of non-matching keys.ShortTermMemoryTTLIntegrationTestpins the newON_CREATE_AND_WRITEdefault.memory_event_logging_test.pyexercises the full emit → Event Log path.API
Java:
org.apache.flink.agents.api.event.MemoryEventorg.apache.flink.agents.api.event.ShortTermWriteEventorg.apache.flink.agents.api.event.ShortTermReadEventorg.apache.flink.agents.api.event.SensoryWriteEventorg.apache.flink.agents.api.event.SensoryReadEventorg.apache.flink.agents.api.event.LongTermUpdateEventorg.apache.flink.agents.api.event.LongTermGetEventorg.apache.flink.agents.api.event.LongTermSearchEventorg.apache.flink.agents.api.event.AgentRunBeginEventEventType.*constantsorg.apache.flink.agents.api.configuration.MemoryEventOptionsPython:
flink_agents.api.events.memory_eventflink_agents.api.events.run_eventflink_agents.api.core_options.MemoryEventOptionsThe new memory-event surface — event classes,
EventType.*constants, andMemoryEventOptions— does not depend on any long-term-memory backend.The observation recording seam is a runtime-side default-method extension point:
InternalBaseLongTermMemory.drainObservationRecordsJson, whose default implementation returns[]. A future pure-Java backend can therefore implement its own recording without another API change.Configuration. Memory event generation is controlled per operation kind. Configuration is resolved in the following order:
memory.generate-eventmaster switch.The built-in defaults are:
The master switch and operation-specific switches intentionally use
nulldefaults, so the built-in defaults remain reachable.agent-run.begin-eventhas its own switch, defaults to on, and is independent of thememory.generate-eventmaster switch.Notable behavior change.
short-term-memory.state-ttl.update-typechanges fromON_READ_AND_WRITEtoON_CREATE_AND_WRITE.ON_READ_AND_WRITE, that scan would refresh every entry's TTL and prevent expiry. Users who require read-refresh TTL semantics can set the option back explicitly, or disableagent-run.begin-event. See the documentation's Semantics & Caveats section for details.Documentation
doc-neededdoc-not-neededdoc-includedDocumentation is included in `docs/content/docs/development/memory/memory_events.mdThis change also adds notes to the sensory/short-term memory page and the configuration reference.