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/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_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..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( @@ -72,7 +86,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 +144,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 +182,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 +273,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