From 2b4441816d8719d1fb2c3920c4bbf7d0090acaca Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Tue, 27 Oct 2026 17:34:35 -0400 Subject: [PATCH 1/2] fix(memory): OracleAgentMemoryManager works OCI-only; Oracle notebooks carry their own numbers OracleAgentMemoryManager defaults to oci/cohere.embed-v4.0 and oci/openai.gpt-4.1, but litellm's oci/ driver (which oracleagentmemory's Embedder and Llm wrap) does not read ~/.oci/config, so on any machine that authenticates with an OCI profile or `oci session authenticate` token the manager failed with "Missing required parameters: oci_user, oci_fingerprint, ...". For oci/... specs the manager now derives the litellm kwargs itself: a SecurityTokenSigner or API-key Signer from the profile named by OCI_SESSION_PROFILE / OCI_PROFILE (instance and resource principals via OCI_AUTH_TYPE), plus OCI_REGION and OCI_COMPARTMENT_ID. litellm's own OCI_USER / OCI_KEY_FILE variables, when set, are left in charge; `litellm_kwargs=` overrides everything; `oci_litellm_auth()` is public for callers that build the client directly. The manager passes MemoryExtractionConfig where oracleagentmemory provides it instead of the deprecated inline flag. Notebook 44 is OCI-only by default (LOCUS_OAM_EMBEDDER / LOCUS_OAM_LLM still switch providers), its diagnostic probe uses the same auth, and its docstring, banner, thread ids and table prefix carry its own number. Notebooks 36 to 43 still carried titles, banners, self-filenames and table names from an earlier numbering (02, 03, 04, 05, 06, 07, 09, 10, 12, 37, 80); they now say 36 to 43, and their cross-references point at the current numbers. Verified against a live Autonomous Database 26ai in eu-frankfurt-1 with a session-token profile and no OpenAI key: notebooks 37 to 44 pass (44 recalls Alice's preference in a new thread through Oracle agent memory); the Oracle RAG integration suite passes (6, 1 skip for the OpenAI-embedding variant); unit tests, mypy and ruff clean. Signed-off-by: Federico Kamelhar --- CHANGELOG.md | 15 +++ docs/concepts/memory-manager.md | 4 +- examples/notebook_36_oci_tools.py | 2 +- examples/notebook_37_oracle_store.py | 8 +- examples/notebook_38_oracle_26ai_rag.py | 6 +- examples/notebook_39_oracle_adb_loader.py | 12 +- examples/notebook_40_oracle_indb_chunker.py | 10 +- .../notebook_41_oracle_indb_embeddings.py | 8 +- .../notebook_42_oracle_26ai_checkpointer.py | 8 +- .../notebook_43_oracle_versioned_saver.py | 14 +-- examples/notebook_44_oracle_agent_memory.py | 68 ++++++----- .../memory/managers/oracle_agent_memory.py | 107 +++++++++++++++++- .../test_oracle_agent_memory_oci_auth.py | 82 ++++++++++++++ 13 files changed, 275 insertions(+), 69 deletions(-) create mode 100644 tests/unit/memory/test_oracle_agent_memory_oci_auth.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b34a8f..8a82568d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,21 @@ policy. ### Fixed +- **`OracleAgentMemoryManager` could not use OCI Generative AI models with a + profile or session token.** litellm's `oci/` driver, which Oracle's + `oracleagentmemory` client uses for its embedder and extraction model, + does not read `~/.oci/config`, so the manager's own defaults + (`oci/cohere.embed-v4.0`, `oci/openai.gpt-4.1`) failed with "Missing + required parameters" unless litellm's `OCI_USER` / `OCI_KEY_FILE` + variables were set. For `oci/...` specs the manager now builds the signer + from the OCI profile (`OCI_SESSION_PROFILE` / `OCI_PROFILE`, API key or + session token, instance and resource principals via `OCI_AUTH_TYPE`) and + passes it with `OCI_REGION` and `OCI_COMPARTMENT_ID`; `litellm_kwargs=` + overrides any of it and `oci_litellm_auth()` exposes the same derivation. + The manager also uses `MemoryExtractionConfig` where `oracleagentmemory` + provides it instead of the deprecated inline flag. Notebook 44 runs + OCI-only by default. + - **`OCIEmbeddings()` and `OracleAgentMemoryManager` failed with their default embedding model.** OCI retired the Cohere v3.0 embedding models from on-demand on 2026-09-30 (`404 Entity with key diff --git a/docs/concepts/memory-manager.md b/docs/concepts/memory-manager.md index 4b08cdb7..b1b0a0fc 100644 --- a/docs/concepts/memory-manager.md +++ b/docs/concepts/memory-manager.md @@ -211,7 +211,9 @@ Two ways to keep long-term memory in Oracle Database 26ai. `OracleStore` is a `BaseStore`, so it slots under `LLMMemoryManager` with the portable extractor and per-user scoping; `OracleAgentMemoryManager` hands the whole job to Oracle's own `oracleagentmemory` client (extraction, context cards, -retrieval scoped by user, agent and thread, all inside the database). +retrieval scoped by user, agent and thread, all inside the database). Both +default to OCI Generative AI models for embedding and extraction and take +the credentials from your OCI profile. ```python import os diff --git a/examples/notebook_36_oci_tools.py b/examples/notebook_36_oci_tools.py index 7a0f1c22..ada3df0b 100644 --- a/examples/notebook_36_oci_tools.py +++ b/examples/notebook_36_oci_tools.py @@ -1,7 +1,7 @@ # Copyright (c) 2025, 2026 Oracle and/or its affiliates. # Licensed under the Universal Permissive License v1.0 as shown at # https://oss.oracle.com/licenses/upl/ -"""Notebook 80: Drive the entire OCI control plane from natural language — `use_oci` + `describe_oci`. +"""Notebook 36: Drive the entire OCI control plane from natural language — `use_oci` + `describe_oci`. Locus ships two built-in tools that, together, expose every OCI Python SDK operation to an agent without per-service plumbing: diff --git a/examples/notebook_37_oracle_store.py b/examples/notebook_37_oracle_store.py index 5449b4b0..def9b3f4 100755 --- a/examples/notebook_37_oracle_store.py +++ b/examples/notebook_37_oracle_store.py @@ -5,7 +5,7 @@ """Notebook 37: a Locus agent with cross-thread memory on Oracle 26ai. -``oracle_checkpointer`` (notebook 03) persists *per-thread* agent +``oracle_checkpointer`` (notebook 42) persists *per-thread* agent state — same ``thread_id`` resumes a conversation. :class:`OracleStore` is the other half of long-term memory: a namespaced key/value store for *cross-thread* facts. The Locus agent loop reaches into it through @@ -43,7 +43,7 @@ export ORACLE_WALLET=~/.oci/wallets/mydb export ORACLE_WALLET_PASSWORD='' # if encrypted - python examples/notebook_06_oracle_store.py + python examples/notebook_37_oracle_store.py If those env vars aren't set the notebook prints the wiring snippet and exits cleanly — no traceback, no half-initialised state. @@ -75,7 +75,7 @@ ) -TABLE_NAME = "locus_notebook_06_store" +TABLE_NAME = "locus_notebook_37_store" USER_ID = "alice" @@ -84,7 +84,7 @@ def _missing_env() -> list[str]: def _print_skip_banner(missing: list[str]) -> None: - print("\n--- Notebook 06: OracleStore-backed cross-thread memory agent ---") + print("\n--- Notebook 37: OracleStore-backed cross-thread memory agent ---") print( "Required environment variables not set; skipping the live demo so " "this file still runs cleanly in CI.\n" diff --git a/examples/notebook_38_oracle_26ai_rag.py b/examples/notebook_38_oracle_26ai_rag.py index 08b53b66..0f2f690a 100755 --- a/examples/notebook_38_oracle_26ai_rag.py +++ b/examples/notebook_38_oracle_26ai_rag.py @@ -42,7 +42,7 @@ export OCI_COMPARTMENT=ocid1.compartment.oc1..xxx export OCI_AUTH_TYPE=api_key # or security_token - python examples/notebook_02_oracle_26ai_rag.py + python examples/notebook_38_oracle_26ai_rag.py If ``ORACLE_DSN`` / ``ORACLE_PASSWORD`` / ``OCI_COMPARTMENT`` aren't set the notebook prints the wiring snippet and exits cleanly — no @@ -106,7 +106,7 @@ def _missing_env() -> list[str]: def _print_skip_banner(missing: list[str]) -> None: - print("\n--- Notebook 02: Oracle 26ai RAG agent ---") + print("\n--- Notebook 38: Oracle 26ai RAG agent ---") print( "Required environment variables not set; skipping the live demo so " "this file still runs cleanly in CI.\n" @@ -214,7 +214,7 @@ async def main() -> None: wallet_password=os.environ.get("ORACLE_WALLET_PASSWORD", ""), # Demo defaults. For production, pre-create the table and pass # auto_create_table=False so the runtime user only needs DML. - table_name="locus_notebook_06", + table_name="locus_notebook_38", dimension=1536, distance_metric="COSINE", ) diff --git a/examples/notebook_39_oracle_adb_loader.py b/examples/notebook_39_oracle_adb_loader.py index 5f3ffd90..7a126cb1 100755 --- a/examples/notebook_39_oracle_adb_loader.py +++ b/examples/notebook_39_oracle_adb_loader.py @@ -27,11 +27,11 @@ whole result set in one turn. Switch to ``async for doc in loader.lazy_load()`` if you're streaming into a UI. - The same connection envelope (DSN, user, wallet) feeds the loader, - the vector store (notebook 02), the in-DB chunker (notebook 04), - the in-DB embedder (notebook 37), and the checkpointer (07). + the vector store (notebook 38), the in-DB chunker (notebook 40), + the in-DB embedder (notebook 41), and the checkpointer (42). This notebook is self-contained: it creates a disposable demo table -``locus_notebook_05_articles``, populates a few rows, hands the loader +``locus_notebook_39_articles``, populates a few rows, hands the loader to an Agent as a tool, asks the agent two domain questions, and drops the table on the way out. @@ -43,7 +43,7 @@ export ORACLE_WALLET=~/.oci/wallets/mydb # directory holding tnsnames.ora export ORACLE_WALLET_PASSWORD='' # if the wallet is encrypted - python examples/notebook_05_oracle_adb_loader.py + python examples/notebook_39_oracle_adb_loader.py If ``ORACLE_DSN`` / ``ORACLE_PASSWORD`` / ``ORACLE_WALLET`` aren't set the notebook prints the wiring snippet and exits cleanly — no @@ -74,7 +74,7 @@ ) -_TABLE = "locus_notebook_05_articles" +_TABLE = "locus_notebook_39_articles" _DEMO_ROWS = [ ( @@ -120,7 +120,7 @@ def _missing_env() -> list[str]: def _print_skip_banner(missing: list[str]) -> None: - print("\n--- Notebook 05: OracleADBLoader agent ---") + print("\n--- Notebook 39: OracleADBLoader agent ---") print( "Required environment variables not set; skipping the live demo so " "this file still runs cleanly in CI.\n" diff --git a/examples/notebook_40_oracle_indb_chunker.py b/examples/notebook_40_oracle_indb_chunker.py index 273990d9..9673d089 100755 --- a/examples/notebook_40_oracle_indb_chunker.py +++ b/examples/notebook_40_oracle_indb_chunker.py @@ -38,7 +38,7 @@ export ORACLE_WALLET=~/.oci/wallets/mydb # directory holding tnsnames.ora export ORACLE_WALLET_PASSWORD='' # if the wallet is encrypted - python examples/notebook_04_oracle_indb_chunker.py + python examples/notebook_40_oracle_indb_chunker.py If ``ORACLE_DSN`` / ``ORACLE_PASSWORD`` / ``ORACLE_WALLET`` aren't set the notebook prints the wiring snippet and exits cleanly — no @@ -70,7 +70,7 @@ ) -_TABLE = "locus_notebook_04_docs" +_TABLE = "locus_notebook_40_docs" _LONG_PARAGRAPH = ( @@ -97,7 +97,7 @@ def _missing_env() -> list[str]: def _print_skip_banner(missing: list[str]) -> None: - print("\n--- Notebook 04: OracleInDBChunker agent ---") + print("\n--- Notebook 40: OracleInDBChunker agent ---") print( "Required environment variables not set; skipping the live demo so " "this file still runs cleanly in CI.\n" @@ -286,7 +286,7 @@ async def chunk_table_rows(limit: int = 100) -> dict: """Stream chunks from every row in the demo CLOB table. Wraps OracleInDBChunker.chunk_column over the - ``locus_notebook_04_docs`` table. The source CLOBs never leave + ``locus_notebook_40_docs`` table. The source CLOBs never leave the database — only the chunk records come back. Useful for an ingest agent that wants to know "how big is this corpus, in chunks" before committing to write to a vector store. @@ -336,7 +336,7 @@ async def chunk_table_rows(limit: int = 100) -> dict: "\nThe agent picked the right primitive for each request — " "single-string vs. column-streaming — and every chunk was " "produced by UTL_TO_CHUNKS inside Oracle 26ai. Pair with " - "OracleInDBEmbeddings (notebook 37) for a fully in-database " + "OracleInDBEmbeddings (notebook 41) for a fully in-database " "ingest pipeline." ) finally: diff --git a/examples/notebook_41_oracle_indb_embeddings.py b/examples/notebook_41_oracle_indb_embeddings.py index d8dc25c4..341358eb 100755 --- a/examples/notebook_41_oracle_indb_embeddings.py +++ b/examples/notebook_41_oracle_indb_embeddings.py @@ -72,7 +72,7 @@ export ORACLE_WALLET_PASSWORD='' # if the wallet is encrypted export OCI_INDB_MODEL=ALL_MINILM_L12_V2 # optional — defaults to this - python examples/notebook_37_oracle_indb_embeddings.py + python examples/notebook_41_oracle_indb_embeddings.py Difficulty: Intermediate. Self-contained — no prior notebook required. """ @@ -130,7 +130,7 @@ def _missing_env() -> list[str]: def _print_skip_banner(missing: list[str]) -> None: - print("\n--- Notebook 37: OracleInDBEmbeddings agent ---") + print("\n--- Notebook 41: OracleInDBEmbeddings agent ---") print( "Required environment variables not set; skipping the live demo so " "this file still runs cleanly in CI.\n" @@ -168,7 +168,7 @@ def _print_skip_banner(missing: list[str]) -> None: def _print_model_skip_banner(model: str, exc: Exception) -> None: - print("\n--- Notebook 37: OracleInDBEmbeddings agent ---") + print("\n--- Notebook 41: OracleInDBEmbeddings agent ---") print( f"The ONNX model {model!r} isn't loaded (or accessible) in the " "target database, so the live path can't run.\n" @@ -259,7 +259,7 @@ async def main() -> None: password=os.environ["ORACLE_PASSWORD"], wallet_location=os.path.expanduser(os.environ["ORACLE_WALLET"]), wallet_password=os.environ.get("ORACLE_WALLET_PASSWORD", ""), - table_name="locus_notebook_10", + table_name="locus_notebook_41", dimension=_DEFAULT_DIM, distance_metric="COSINE", ) diff --git a/examples/notebook_42_oracle_26ai_checkpointer.py b/examples/notebook_42_oracle_26ai_checkpointer.py index a4ed76c3..9ff82a61 100755 --- a/examples/notebook_42_oracle_26ai_checkpointer.py +++ b/examples/notebook_42_oracle_26ai_checkpointer.py @@ -44,7 +44,7 @@ export ORACLE_WALLET=~/.oci/wallets/mydb export ORACLE_WALLET_PASSWORD='' # if the wallet is encrypted - python examples/notebook_03_oracle_26ai_checkpointer.py + python examples/notebook_42_oracle_26ai_checkpointer.py If ``ORACLE_DSN`` / ``ORACLE_PASSWORD`` aren't set the notebook prints the wiring snippet and exits cleanly — no traceback, no @@ -73,8 +73,8 @@ ) -THREAD_ID = "notebook_03_thread" -TABLE_NAME = "locus_notebook_07" +THREAD_ID = "notebook_42_thread" +TABLE_NAME = "locus_notebook_42" def _missing_env() -> list[str]: @@ -82,7 +82,7 @@ def _missing_env() -> list[str]: def _print_skip_banner(missing: list[str]) -> None: - print("\n--- Notebook 03: Oracle 26ai durable agent thread ---") + print("\n--- Notebook 42: Oracle 26ai durable agent thread ---") print( "Required environment variables not set; skipping the live demo so " "this file still runs cleanly in CI.\n" diff --git a/examples/notebook_43_oracle_versioned_saver.py b/examples/notebook_43_oracle_versioned_saver.py index f100aa38..0d9a9b86 100755 --- a/examples/notebook_43_oracle_versioned_saver.py +++ b/examples/notebook_43_oracle_versioned_saver.py @@ -5,7 +5,7 @@ """Notebook 43: versioned agent history on Oracle Database 26ai. -Notebook 03's ``oracle_checkpointer`` keeps **one row per thread** — +Notebook 42's ``oracle_checkpointer`` keeps **one row per thread** — ``MERGE`` on save means the latest write wins and history is destructive. That's the right shape for most agent threads. But when you need a LangGraph-shape *history-preserving* saver — one row per @@ -51,7 +51,7 @@ export ORACLE_WALLET=~/.oci/wallets/mydb export ORACLE_WALLET_PASSWORD='' # if encrypted - python examples/notebook_09_oracle_versioned_saver.py + python examples/notebook_43_oracle_versioned_saver.py If those env vars aren't set the notebook prints the wiring snippet and exits cleanly — no traceback, no half-initialised state. @@ -80,7 +80,7 @@ ) -TABLE_PREFIX = "locus_notebook_12" +TABLE_PREFIX = "locus_notebook_43" THREAD_ID = "agent-incident-001" @@ -96,7 +96,7 @@ def _missing_env() -> list[str]: def _print_skip_banner(missing: list[str]) -> None: - print("\n--- Notebook 09: OracleCheckpointSaver agent ---") + print("\n--- Notebook 43: OracleCheckpointSaver agent ---") print( "Required environment variables not set; skipping the live demo so " "this file still runs cleanly in CI.\n" @@ -114,9 +114,9 @@ def _print_skip_banner(missing: list[str]) -> None: from locus.agent import Agent from locus.memory.backends import OracleCheckpointSaver - cp = oracle_checkpointer(dsn=..., table_name="locus_notebook_09_live") + cp = oracle_checkpointer(dsn=..., table_name="locus_notebook_43_live") saver = OracleCheckpointSaver(dsn=..., user=..., wallet_location=..., - table_name="locus_notebook_12") + table_name="locus_notebook_43") agent = Agent(model=model, system_prompt="...", checkpointer=cp, checkpoint_every_n_iterations=1) @@ -280,7 +280,7 @@ async def main() -> None: saver = _build_saver() # Live thread continuity comes from the single-row checkpointer - # (notebook 03's primitive); the versioned saver adds an audit + # (notebook 42's primitive); the versioned saver adds an audit # trail on top. Both ride the same Autonomous Database wallet. live_cp = oracle_checkpointer( dsn=os.environ["ORACLE_DSN"], diff --git a/examples/notebook_44_oracle_agent_memory.py b/examples/notebook_44_oracle_agent_memory.py index cf4f68c9..7d3d599c 100755 --- a/examples/notebook_44_oracle_agent_memory.py +++ b/examples/notebook_44_oracle_agent_memory.py @@ -3,13 +3,13 @@ # Licensed under the Universal Permissive License v1.0 as shown at # https://oss.oracle.com/licenses/upl/ -"""Notebook 04: a Locus agent with Oracle's official agent-memory client. +"""Notebook 44: a Locus agent with Oracle's official agent-memory client. The Oracle-native memory path. Wires Locus's :class:`~locus.memory.managers.OracleAgentMemoryManager` to ``oracleagentmemory.core.OracleAgentMemory`` (Oracle's official ``oracleagentmemory`` PyPI package). Compared with the BaseStore-flavoured -:class:`~locus.memory.manager.LLMMemoryManager` from notebook 06, this +:class:`~locus.memory.manager.LLMMemoryManager` from notebook 37, this manager gets you: - LLM-backed memory extraction tuned on LongMemEval (94.4 % recall on @@ -21,9 +21,9 @@ The trade-off: it's Oracle-only and the upstream is alpha (``oracleagentmemory`` 26.4.0, Dev Status 3). For non-Oracle backends (InMemoryStore / Redis / Postgres / OpenSearch) or for test -environments, stick with notebook 06. +environments, stick with notebook 37. -Same two-session shape as notebook 06 so you can compare side-by-side: +Same two-session shape as notebook 37 so you can compare side-by-side: - Session A (``thread_id="t-a"``, ``user_id="alice"``) — agent learns a fact about Alice; the manager's ``on_session_end`` hook ships the @@ -50,21 +50,21 @@ export ORACLE_WALLET=~/.oci/wallets/mydb export ORACLE_WALLET_PASSWORD='' # if encrypted - # oracleagentmemory drives Embedder + Llm through litellm. The - # demo defaults to ``openai/text-embedding-3-small`` + - # ``openai/gpt-4o-mini`` and needs an OpenAI key for those: - export OPENAI_API_KEY=sk-... + # oracleagentmemory drives its Embedder + Llm through litellm. The demo + # defaults to OCI Generative AI (``oci/cohere.embed-v4.0`` for + # embeddings, ``oci/openai.gpt-4.1`` for extraction) and Locus hands + # litellm a signer built from your OCI profile — API key or + # ``oci session authenticate`` token — plus the region and compartment: + export OCI_PROFILE=DEFAULT # or OCI_SESSION_PROFILE=... + export OCI_REGION=eu-frankfurt-1 + export OCI_COMPARTMENT_ID=ocid1.compartment.oc1..xxx - # Or pick any litellm model spec — e.g. OCI Generative AI. litellm's - # OCI driver does NOT read ~/.oci/config; it takes the auth fields - # from these environment variables: - export LOCUS_OAM_EMBEDDER=oci/cohere.embed-v4.0 - export LOCUS_OAM_LLM=oci/meta.llama-3.3-70b-instruct - export OCI_REGION=us-chicago-1 OCI_COMPARTMENT_ID=ocid1.tenancy.oc1..xxx - export OCI_TENANCY=ocid1.tenancy.oc1..xxx OCI_USER=ocid1.user.oc1..xxx - export OCI_FINGERPRINT=aa:bb:... OCI_KEY_FILE=~/.oci/api_key.pem + # Any other litellm model spec works too, with that provider's own + # credentials, e.g.: + # export LOCUS_OAM_EMBEDDER=openai/text-embedding-3-small + # export LOCUS_OAM_LLM=openai/gpt-4o-mini OPENAI_API_KEY=sk-... - python examples/notebook_64_oracle_agent_memory.py + python examples/notebook_44_oracle_agent_memory.py If ``ORACLE_DSN`` / ``ORACLE_PASSWORD`` aren't set the notebook prints the wiring snippet and exits cleanly — no traceback, no @@ -94,12 +94,12 @@ ) -THREAD_A = "notebook_64_thread_a" -THREAD_B = "notebook_64_thread_b" +THREAD_A = "notebook_44_thread_a" +THREAD_B = "notebook_44_thread_b" USER_ID = "alice" -EMBEDDER = os.environ.get("LOCUS_OAM_EMBEDDER", "openai/text-embedding-3-small") -LLM = os.environ.get("LOCUS_OAM_LLM", "openai/gpt-4o-mini") -TABLE_PREFIX = "locus_notebook_64_oam_" +EMBEDDER = os.environ.get("LOCUS_OAM_EMBEDDER", "oci/cohere.embed-v4.0") +LLM = os.environ.get("LOCUS_OAM_LLM", "oci/openai.gpt-4.1") +TABLE_PREFIX = "locus_notebook_44_oam_" def _missing_env() -> list[str]: @@ -107,7 +107,7 @@ def _missing_env() -> list[str]: def _print_skip_banner(missing: list[str]) -> None: - print("\n--- Notebook 64: Oracle agent memory ---") + print("\n--- Notebook 44: Oracle agent memory ---") print( "Required environment variables not set; skipping the live demo so " "this file still runs cleanly in CI.\n" @@ -138,8 +138,8 @@ def _print_skip_banner(missing: list[str]) -> None: ) manager = OracleAgentMemoryManager( connection=conn, - embedder="openai/text-embedding-3-small", - llm="openai/gpt-4o-mini", + embedder="oci/cohere.embed-v4.0", # litellm spec; auth from your OCI profile + llm="oci/openai.gpt-4.1", table_name_prefix="locus_oam_", ) agent = Agent(config=AgentConfig(model=model, memory_manager=manager)) @@ -268,15 +268,21 @@ async def show_what_was_extracted(conn) -> None: memories the LLM extractor produced for Alice between sessions. Pure diagnostic — uses the client directly, not via the Locus - adapter, so the comparison with notebook 06 stays apples-to-apples. + adapter, so the comparison with notebook 37 stays apples-to-apples. """ - from oracleagentmemory.core import OracleAgentMemory + from oracleagentmemory.core import MemoryExtractionConfig, OracleAgentMemory + from oracleagentmemory.core.embedders import Embedder + from oracleagentmemory.core.llms import Llm + from locus.memory.managers.oracle_agent_memory import oci_litellm_auth + + # Same wrappers the Locus manager builds: for ``oci/...`` specs the + # signer, region and compartment come from your OCI profile. client = OracleAgentMemory( connection=conn, - embedder=EMBEDDER, - llm=LLM, - extract_memories=False, # read-only probe + embedder=Embedder(model=EMBEDDER, **oci_litellm_auth(EMBEDDER)), + llm=Llm(model=LLM, **oci_litellm_auth(LLM)), + memory_extraction_config=MemoryExtractionConfig(extract_memories=False), # read-only probe table_name_prefix=TABLE_PREFIX, ) # Use the async variant — calling the sync `search` from inside an @@ -311,7 +317,7 @@ async def main() -> None: print( "\nThread B's Agent never saw Thread A's messages. The context card " "from oracleagentmemory carried Alice's preference across. Same wallet, " - "same Autonomous Database as notebook 06 — the upgrade is the manager." + "same Autonomous Database as notebook 37 — the upgrade is the manager." ) finally: # Drain the library's background extraction and close its client diff --git a/src/locus/memory/managers/oracle_agent_memory.py b/src/locus/memory/managers/oracle_agent_memory.py index 1926f15a..d49a1049 100644 --- a/src/locus/memory/managers/oracle_agent_memory.py +++ b/src/locus/memory/managers/oracle_agent_memory.py @@ -39,7 +39,9 @@ import asyncio import contextlib +import os from collections.abc import Callable +from pathlib import Path from typing import TYPE_CHECKING, Any from uuid import uuid4 @@ -81,6 +83,82 @@ def _default_agent_id(state: AgentState) -> str | None: return str(value) if value else None +_LITELLM_OCI_ENV = ("OCI_USER", "OCI_KEY_FILE", "OCI_KEY_CONTENT", "OCI_FINGERPRINT") + + +def oci_litellm_auth(spec: str) -> dict[str, Any]: + """litellm OCI auth kwargs for an ``oci/...`` model spec, or ``{}``. + + litellm's OCI driver takes a signer or raw key fields per call and does + not read ``~/.oci/config``. Locus users already have a profile (API key + or ``oci session authenticate`` token) or run under an instance / + resource principal, so build the signer from that, the way + ``OCIChatCompletionsModel`` does. Returns ``{}`` for non-OCI specs, when + the ``oci`` SDK is missing, when litellm's own ``OCI_USER`` / + ``OCI_KEY_FILE`` variables are set (the caller configured litellm + directly), or when no usable profile is found, so behaviour is unchanged + for anyone not relying on it. + """ + if not spec.startswith("oci/") or any(os.environ.get(k) for k in _LITELLM_OCI_ENV): + return {} + try: + import oci # noqa: PLC0415 + except ImportError: + return {} + region = os.environ.get("OCI_REGION") + compartment = os.environ.get("OCI_COMPARTMENT_ID") or os.environ.get("OCI_COMPARTMENT") + auth_type = (os.environ.get("OCI_AUTH_TYPE") or "").lower() + signer: Any = None + try: + if auth_type == "instance_principal": + signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner() + region = region or getattr(signer, "region", None) + elif auth_type == "resource_principal": + signer = oci.auth.signers.get_resource_principals_signer() + region = region or getattr(signer, "region", None) + else: + profile = ( + os.environ.get("OCI_SESSION_PROFILE") + or os.environ.get("OCI_PROFILE") + or os.environ.get("OCI_CLI_PROFILE") + or "DEFAULT" + ) + cfg = oci.config.from_file(profile_name=profile) + region = region or cfg.get("region") + key_file = str(Path(cfg["key_file"]).expanduser()) + token_file = cfg.get("security_token_file") + if token_file: + token = Path(token_file).expanduser().read_text(encoding="utf-8").strip() + private_key = oci.signer.load_private_key_from_file( + key_file, cfg.get("pass_phrase") + ) + signer = oci.auth.signers.SecurityTokenSigner(token, private_key) + else: + signer = oci.signer.Signer( + tenancy=cfg["tenancy"], + user=cfg["user"], + fingerprint=cfg["fingerprint"], + private_key_file_location=key_file, + pass_phrase=cfg.get("pass_phrase"), + ) + except Exception: # noqa: BLE001 - any config problem means "not derivable" + return {} + kwargs: dict[str, Any] = {"oci_signer": signer} + if region: + kwargs["oci_region"] = region + if compartment: + kwargs["oci_compartment_id"] = compartment + return kwargs + + +def _litellm_kwargs_for(spec: str, explicit: dict[str, Any] | None) -> dict[str, Any]: + """Auto-derived OCI auth for ``spec`` with the caller's kwargs layered on top.""" + merged = oci_litellm_auth(spec) + if explicit: + merged.update(explicit) + return merged + + class OracleAgentMemoryManager(BaseMemoryManager): """``BaseMemoryManager`` backed by Oracle's ``oracleagentmemory`` client. @@ -162,6 +240,15 @@ class OracleAgentMemoryManager(BaseMemoryManager): — number of relevant memories to render into the card. max_recent_messages: Forwarded to ``get_context_card_async`` — number of recent messages to render into the card. + litellm_kwargs: Extra keyword arguments for the ``Embedder`` and + ``Llm`` wrappers when ``embedder`` / ``llm`` are strings (for + example ``{"oci_signer": signer}``). For ``oci/...`` specs the + manager fills in ``oci_signer``, ``oci_region`` and + ``oci_compartment_id`` from the OCI profile and environment + (``OCI_SESSION_PROFILE`` / ``OCI_PROFILE``, ``OCI_REGION``, + ``OCI_COMPARTMENT_ID``, ``OCI_AUTH_TYPE``) unless litellm's own + ``OCI_USER`` / ``OCI_KEY_FILE`` variables are set; anything you + pass here overrides that. """ def __init__( @@ -181,6 +268,7 @@ def __init__( max_relevant_results: int = 5, max_recent_messages: int = 5, thread_defaults: dict[str, Any] | None = None, + litellm_kwargs: dict[str, Any] | None = None, ) -> None: if connection is None and store is None: raise ValueError( @@ -213,17 +301,30 @@ def __init__( # ``llm`` blows up later inside the extractor with # "'str' object has no attribute 'generate_async'". Wrap both # eagerly so the caller-friendly string shape just works. + # ``oci/...`` specs: litellm's OCI driver does not read ~/.oci/config, + # so derive its auth kwargs (signer, region, compartment) from the + # same OCI environment the rest of Locus uses. Explicit + # ``litellm_kwargs`` win; non-OCI specs are passed through untouched. if isinstance(embedder, str): - embedder = Embedder(model=embedder) + embedder = Embedder(model=embedder, **_litellm_kwargs_for(embedder, litellm_kwargs)) if isinstance(llm, str): - llm = Llm(model=llm) + llm = Llm(model=llm, **_litellm_kwargs_for(llm, litellm_kwargs)) ctor_kwargs: dict[str, Any] = { "embedder": embedder, "llm": llm, - "extract_memories": extract_memories, "table_name_prefix": table_name_prefix, } + # oracleagentmemory >= 26.8 deprecates the inline flag in favour of + # ``memory_extraction_config``; older releases only know the flag. + try: + from oracleagentmemory.core import MemoryExtractionConfig # noqa: PLC0415 + + ctor_kwargs["memory_extraction_config"] = MemoryExtractionConfig( + extract_memories=extract_memories + ) + except ImportError: + ctor_kwargs["extract_memories"] = extract_memories if connection is not None: ctor_kwargs["connection"] = connection if store is not None: diff --git a/tests/unit/memory/test_oracle_agent_memory_oci_auth.py b/tests/unit/memory/test_oracle_agent_memory_oci_auth.py new file mode 100644 index 00000000..4be6595d --- /dev/null +++ b/tests/unit/memory/test_oracle_agent_memory_oci_auth.py @@ -0,0 +1,82 @@ +# Copyright (c) 2025, 2026 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License v1.0 as shown at +# https://oss.oracle.com/licenses/upl/ +"""``OracleAgentMemoryManager`` derives litellm's OCI auth from the OCI profile. + +litellm's ``oci/`` driver ignores ``~/.oci/config``; without these kwargs an +``oci/...`` embedder or LLM fails with "Missing credentials" on any machine +that authenticates through a profile or a session token. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from locus.memory.managers.oracle_agent_memory import _litellm_kwargs_for, oci_litellm_auth + + +def test_non_oci_specs_are_left_alone(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OCI_PROFILE", "DEFAULT") + assert oci_litellm_auth("openai/text-embedding-3-small") == {} + + +def test_litellm_env_configuration_wins(monkeypatch: pytest.MonkeyPatch) -> None: + """When the caller configured litellm's own OCI variables, do not interfere.""" + monkeypatch.setenv("OCI_USER", "ocid1.user.oc1..example") + assert oci_litellm_auth("oci/cohere.embed-v4.0") == {} + + +def test_missing_profile_means_no_kwargs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + pytest.importorskip("oci") + for k in ("OCI_USER", "OCI_KEY_FILE", "OCI_KEY_CONTENT", "OCI_FINGERPRINT", "OCI_AUTH_TYPE"): + monkeypatch.delenv(k, raising=False) + monkeypatch.setenv("OCI_PROFILE", "no-such-profile-for-this-test") + monkeypatch.setenv("OCI_CLI_CONFIG_FILE", str(tmp_path / "missing-config")) + assert oci_litellm_auth("oci/openai.gpt-4.1") == {} + + +def test_session_token_profile_yields_signer_region_and_compartment( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + oci = pytest.importorskip("oci") + rsa = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.rsa") + from cryptography.hazmat.primitives import serialization # noqa: PLC0415 + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + key_file = tmp_path / "key.pem" + key_file.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + ) + token_file = tmp_path / "token" + token_file.write_text("session-token-for-test") + for k in ("OCI_USER", "OCI_KEY_FILE", "OCI_KEY_CONTENT", "OCI_FINGERPRINT", "OCI_AUTH_TYPE"): + monkeypatch.delenv(k, raising=False) + monkeypatch.setenv("OCI_SESSION_PROFILE", "SESSION") + monkeypatch.setenv("OCI_REGION", "eu-frankfurt-1") + monkeypatch.setenv("OCI_COMPARTMENT_ID", "ocid1.compartment.oc1..example") + monkeypatch.setattr( + oci.config, + "from_file", + lambda profile_name: { + "region": "us-chicago-1", + "key_file": str(key_file), + "security_token_file": str(token_file), + "tenancy": "ocid1.tenancy.oc1..example", + }, + ) + + kwargs = oci_litellm_auth("oci/cohere.embed-v4.0") + + assert isinstance(kwargs["oci_signer"], oci.auth.signers.SecurityTokenSigner) + assert kwargs["oci_region"] == "eu-frankfurt-1" # env beats the profile region + assert kwargs["oci_compartment_id"] == "ocid1.compartment.oc1..example" + + layered = _litellm_kwargs_for("oci/cohere.embed-v4.0", {"oci_region": "us-ashburn-1"}) + assert layered["oci_region"] == "us-ashburn-1" + assert "oci_signer" in layered From 20c9e20d4d8b06b56222c76f61fc58f521ede562 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Tue, 27 Oct 2026 17:43:35 -0400 Subject: [PATCH 2/2] docs(26ai): how to load the prebuilt ONNX embedding model on Autonomous Database One DBMS_VECTOR.LOAD_ONNX_MODEL_CLOUD call against Oracle's prebuilt all-MiniLM-L12-v2 (pre-authenticated URL, no credential) puts a 384-dimension embedding model in the database; the Oracle Database 26ai page and notebook 41's prerequisites now say so, next to the on-premises directory-object path. Verified on an Autonomous Database 26ai in eu-frankfurt-1: the load took six seconds and notebook 41 then runs the fully in-database chunk, embed and search path. Signed-off-by: Federico Kamelhar --- docs/concepts/oracle-26ai.md | 18 +++++++++++++++++- examples/notebook_41_oracle_indb_embeddings.py | 16 +++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/concepts/oracle-26ai.md b/docs/concepts/oracle-26ai.md index e0705b2f..80e5d058 100644 --- a/docs/concepts/oracle-26ai.md +++ b/docs/concepts/oracle-26ai.md @@ -68,7 +68,23 @@ agent = Agent(model="oci:openai.gpt-5.5", tools=[retriever.as_tool()]) When the text must not leave the database, chunk and embed inside it: `OracleInDBChunker` splits with `DBMS_VECTOR_CHAIN`, `OracleInDBEmbeddings` runs an ONNX model loaded into the database, and `OracleVectorStore` keeps -the result, so no document content goes to a hosted embedding API. +the result, so no document content goes to a hosted embedding API. The +model is a one-time load. On Autonomous Database, Oracle's prebuilt +all-MiniLM-L12-v2 (384 dimensions) loads straight from Object Storage: + +```sql +BEGIN + DBMS_VECTOR.LOAD_ONNX_MODEL_CLOUD( + model_name => 'ALL_MINILM_L12_V2', + credential => NULL, -- pre-authenticated URL + uri => ''); +END; +/ +SELECT vector_dimension_count(vector_embedding(ALL_MINILM_L12_V2 USING 'hello' AS data)) FROM dual; -- 384 +``` + +Then `OracleInDBEmbeddings(model_name="ALL_MINILM_L12_V2", dimension=384)` +and `OracleVectorStore(dimension=384)`; notebook 41 runs the whole path. ## Threads and memory diff --git a/examples/notebook_41_oracle_indb_embeddings.py b/examples/notebook_41_oracle_indb_embeddings.py index 341358eb..f911fe27 100755 --- a/examples/notebook_41_oracle_indb_embeddings.py +++ b/examples/notebook_41_oracle_indb_embeddings.py @@ -43,7 +43,21 @@ ------------- The ONNX model must be loaded into the database before this notebook -can run a query. Typical one-time path:: +can run a query. On Autonomous Database the one-time path is a single +call against Oracle's prebuilt model in Object Storage (the URL is in the +Oracle Machine Learning blog post "Pre-built Embedding Generation model +for Oracle Database 26ai"; it is a pre-authenticated URL, so no +credential):: + + BEGIN + DBMS_VECTOR.LOAD_ONNX_MODEL_CLOUD( + model_name => 'ALL_MINILM_L12_V2', + credential => NULL, + uri => 'https://.../all_MiniLM_L12_v2.onnx'); + END; + / + +On-premises, copy the file to a directory object and load it from there:: BEGIN DBMS_VECTOR.LOAD_ONNX_MODEL(