Research: an OpenAI Agents SDK adapter (issue #249)
Feeds the ADR that fixes mnem-openai-agents's contract, the same way
issue #55 fed ADR-0016 for mnem-mcp and mnem-langgraph. Read against the
real SDK source (openai/openai-agents-python, MIT, PyPI openai-agents,
v0.22.3 at research time), not its docs pages, matching the discipline
docs/comparisons.md (#237) used for Memoria and ByteRover.
Two questions:
- What is the SDK's own pluggable memory primitive, and what shape does it require?
- Does that shape match Mnemosyne's node model well enough to be a thin
wrapper, the way
BaseStoredid for LangGraph - or does it need a different answer?
1. The SDK's memory primitive: Session
src/agents/memory/session.py defines a Protocol (and an ABC twin for
internal use) with exactly four methods:
class Session(Protocol):
session_id: str
session_settings: SessionSettings | None
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]: ...
async def add_items(self, items: list[TResponseInputItem]) -> None: ...
async def pop_item(self) -> TResponseInputItem | None: ...
async def clear_session(self) -> None: ...
This is a first-class parameter on the main run loop, not a side extension:
Runner.run(agent, prompt, session=session) (src/agents/run.py, every
run / run_sync / run_streamed overload). Passing a Session is how an
agent gets automatic conversation-history management at all; the SDK ships
one reference implementation, SQLiteSession (:memory: by default, or a
file path), and a compaction-wrapper Session for long-running
conversations. This is the exact role BaseStore plays for LangGraph and the
tool surface plays for MCP - the one seam a third-party memory backend is
meant to fill.
The item type. TResponseInputItem = ResponseInputItemParam, a type
alias into OpenAI's Responses API - a tagged union covering a chat message,
a function (tool) call, a function-call output, a reasoning item, an MCP
list-tools/approval item, and a handful of others (src/agents/items.py).
It is a raw, ordered transcript entry, not a belief with a stable
identity. Nothing in the type carries an id of its own; identity is
positional.
Session's shape is a flat, ordered, position-addressed list.
get_items(limit) returns the last N items in chronological order.
add_items appends. pop_item removes and returns the single most recent
item. clear_session empties it. There is no key, no namespace, no
concept of "this entry versus that entry" beyond order - it is closer to a
JSONL append log (the exact thing docs/benchmark.md's Baseline B
represents) than to a keyed store.
session_settings. One field today, limit: int | None, resolved
per-call or from the session's default. Room for more later
(SessionSettings is a pydantic.dataclasses.dataclass), but nothing else
is defined yet.
2. The shape mismatch, and how the LangGraph adapter's precedent answers it
Mnemosyne's node model is a keyed map: id -> MemoryNode, read as
working_memory() -> BTreeMap<String, MemoryNode> (checkout.rs), each
write a commit. BTreeMap ordering is lexicographic by id string, not
insertion order - "item:10" sorts before "item:2". The one place
Mnemosyne stores things in a true chronological order is the commit graph
itself: Store::log(start, first_parent, limit) walks commits newest
first, deterministic by record time then id (log.rs).
So a Session item has no natural Mnemosyne node id (an OpenAI message or
tool call is not "a belief with a stable identity" the way a LangGraph
(namespace, key) pair is), and Session's list semantics (pop_item,
positional get_items(limit)) map onto Mnemosyne's commit history, not
onto its keyed node space, the reverse of how the LangGraph adapter works.
ADR-0016 already answered a structurally similar problem for LangGraph by
not forcing every LangGraph concept through BaseStore alone: BaseStore
is implemented for drop-in compatibility, and branch / switch / why /
history are extra methods on the same adapter class, outside the
interface, for the differentiator surface. That precedent - implement the
framework's protocol narrowly and put Mnemosyne's actual value (branch,
merge, blame, bisect) on the adapter object itself, not squeezed through the
foreign interface - is the strongest candidate answer here too, since
Session's four methods have no way to express a branch, a conflict, or a
blame result at all.
Two live design questions this doesn't settle, both real forks with different downstream code:
- What is a Session item's Mnemosyne node id? A synthetic, sortable key
(e.g. a zero-padded or numeric-suffixed
id, sinceBTreeMapordering is lexicographic and"item:10"would otherwise sort before"item:2") makes each transcript entry its own node - individually blameable, at the cost of one node per turn in a possibly long-running conversation. Storing the whole item list as one node's content (one commit peradd_itemscall) is far cheaper and simpler, but thenblame/bisectonly resolve to "this commit changed the transcript," not to a specific message - a real loss of the thing that makes Mnemosyne worth adopting overSQLiteSessionat all. - Does
get_items/pop_itemread from the keyed node space (via a synthetic id scheme) or fromStore::log(walking commits, since that is Mnemosyne's one truly-ordered primitive)? These are different implementations with different failure modes -logis already ordered and paginated (limit) for free, but conflates "one commit" with "one Session item" in a way that may not surviveremember_many-style batching or a future merge into the session's history.
3. A second seam: RunHooks / AgentHooks, for provenance
src/agents/lifecycle.py defines RunHooksBase and AgentHooksBase, async
callback classes an agent or a Runner.run call can be given, covering
on_llm_start/end, on_agent_start/end, on_handoff, and - the relevant
pair - on_tool_start / on_tool_end. This is a second, independent
integration point, orthogonal to Session: MCP's precedent
(remember's source argument) and LangGraph's precedent (provenance
lifted from a reserved _meta key, ADR-0016) both had to invent a carrier
for provenance because their host interfaces don't have one either. Here,
on_tool_end receives the tool's name and output directly, which is a
cleaner, unprompted way to populate Provenance.source / observation
automatically for any node written as a result of that tool call - worth
deciding whether the adapter wires this by default or leaves it to the
caller, the same kind of call ADR-0016 made explicit for _meta.
Recommendation
Implement Session for drop-in compatibility (so Runner.run(agent, prompt, session=MnemosyneSession(store)) works with zero other changes to an
existing OpenAI Agents SDK program), backed by a synthetic per-item node id
scheme rather than one node per whole transcript, so blame and bisect
stay meaningful per message - matching packages/mnem-mcp's existing
precedent of "one write, one commit" rather than batching for throughput.
Put branch / switch / merge / why / bisect on the adapter class
directly, outside the Session protocol, exactly as ADR-0016 did for
BaseStore. Wire on_tool_end as an optional AgentHooks subclass the
adapter ships, off by default, that auto-populates provenance - documented,
not silent, matching the _meta precedent.
This is a recommendation, not a decision: the id-scheme and ordering-source questions in section 2, and the hooks default in section 3, are exactly the kind of fork a grilling should pressure-test rather than settle unilaterally in a research doc.
Open questions for the grilling
- Node id scheme for a
Sessionitem: synthetic per-item id (one node per message/tool-call/tool-output) versus one node peradd_itemsbatch. If per-item, what does the id look like, and does it survivepop_itemcleanly (does popping tombstone the node, or does it need a real delete)? - Ordering source: reconstruct
get_itemsfrom the keyed node space (via the id scheme) or fromStore::log? Doesclear_sessionmean "tombstone every node in this session's namespace" or "branch to a fresh empty history"? - Namespacing: one Mnemosyne store per
session_id(mirroringmnem-mcp's one-store-per-process rule), or one store with an id-prefix namespace per session (mirroring the LangGraph adapter's namespace-as-prefix)? - Hooks default: does the adapter's
AgentHooksprovenance auto-capture ship on by default, or opt-in? MCP made provenance a first-class tool argument; LangGraph made it an opt-in_metakey. Which precedent does this adapter follow? - Package name and scope:
packages/mnem-openai-agents/, PyPI name, and whetherSessionSettings.limitneeds any Mnemosyne-side equivalent (a boundedget_itemsis a paginatedlog/working_memoryread, not a new primitive) or is just passed through.
Sources
openai/openai-agents-python(cloned at research time, MIT, PyPIopenai-agentsv0.22.3):src/agents/memory/session.py,sqlite_session.py,session_settings.py,src/agents/items.py,src/agents/lifecycle.py,src/agents/run.py,examples/memory/*.py.- ADR-0016 (the MCP/ LangGraph adapter contract precedent this research follows).
crates/mnem-store/src/checkout.rs(working_memory'sBTreeMapordering),crates/mnem-store/src/log.rs(Store::log's chronological order).