From 51263613e209cc46f2d7d121b3d621ba503fd727 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Sat, 23 May 2026 09:56:37 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(*):=20three=20papercuts=20=E2=80=94=20i?= =?UTF-8?q?nterrupt=5Fbefore=20resume,=20OCIModel=20ergonomics,=20AgentCon?= =?UTF-8?q?fig.name=20+=20Tool.func?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles three small fixes that were filed together as separate issues. #258 — StateGraph.interrupt_before now durable - The pause boundary now writes through the checkpointer, so a follow-up ``execute(Command(resume=...))`` can recover the paused state across processes. Previously the inline ``interrupt()`` path saved but the ``interrupt_before`` path did not. - Resume no longer re-pauses at the same gate. On the first iteration after a resume, the gate check is skipped for the node we were paused on; subsequent iterations gate normally. - Both save sites (``interrupt_before`` and inline ``interrupt()``) now pack the graph-level state into ``AgentState.metadata`` instead of passing ``state=None``, which crashed the in-protocol checkpointers that call ``state.to_checkpoint()``. #259 — OCIModel region= and profile= aliases - ``region=`` builds the inference endpoint (``https://inference.generativeai..oci.oraclecloud.com``) when ``service_endpoint`` is not provided. Targets a cross-region model without typing out the full URL. - ``profile=`` is now an explicit alias for ``profile_name=``. Previously it was swallowed by ``**kwargs`` and the model silently fell through to the ``DEFAULT`` profile. Setting both at the same time to different values raises ``ValueError``. - ``service_endpoint=`` still wins when both endpoint and region are given. #260 — AgentConfig.name + Tool.func alias - ``AgentConfig.name: str | None`` is a real field that flows to ``Agent.name`` as a property. The previous ``extra='forbid'`` rejected ``AgentConfig(name=...)`` even though ``name`` is what users expect to set when labeling an agent in logs / multi-agent composers. - ``Tool.func`` is a ``@property`` alias for ``.fn``. Some downstream samples and the LangChain/LangGraph idiom reach for ``.func``; surfacing both names avoids the ``getattr(t, "fn", None) or getattr(t, "func", t)`` dance. Tests - Unit: AgentConfig.name + default, Tool.func is Tool.fn (sync + async), OCIModel region= builds the right URL, endpoint wins over region, profile= aliases profile_name, conflict raises, interrupt_before saves through the checkpointer, resume completes the graph past the gate. All 4885 unit tests pass. - Integration: new ``tests/integration/test_papercut_fixes_live.py`` exercises each fix end-to-end against a real OCI GenAI inference endpoint. Skips when OCI env vars aren't set. Verified locally against ``openai.gpt-5.5`` in ``us-chicago-1``. Signed-off-by: Federico Kamelhar --- src/locus/agent/agent.py | 5 + src/locus/agent/config.py | 14 + src/locus/models/providers/oci/__init__.py | 32 +- src/locus/multiagent/graph.py | 69 +++- src/locus/tools/decorator.py | 8 + tests/integration/test_papercut_fixes_live.py | 324 ++++++++++++++++++ tests/unit/test_agent_config.py | 11 + tests/unit/test_graph.py | 98 ++++++ tests/unit/test_idempotent_tools.py | 25 ++ tests/unit/test_oci_model.py | 47 +++ 10 files changed, 627 insertions(+), 6 deletions(-) create mode 100644 tests/integration/test_papercut_fixes_live.py diff --git a/src/locus/agent/agent.py b/src/locus/agent/agent.py index 540c4237..401fcce6 100644 --- a/src/locus/agent/agent.py +++ b/src/locus/agent/agent.py @@ -214,6 +214,11 @@ def _initialize(self) -> None: initialize_agent(self) + @property + def name(self) -> str | None: + """Display name from the config (multi-agent label, logs, traces).""" + return self.config.name + def run_sync( self, prompt: str, diff --git a/src/locus/agent/config.py b/src/locus/agent/config.py index 0209abdc..eff5df2e 100644 --- a/src/locus/agent/config.py +++ b/src/locus/agent/config.py @@ -511,6 +511,20 @@ def _coerce_grounding(cls, v: Any) -> Any: description="Unique agent identifier", ) + # Display name (separate from agent_id). Used by multi-agent + # composers (Orchestrator, Swarm, StateGraph nodes) to label the + # agent in logs and routing decisions. Users putting ``name=`` on + # an ``Agent`` constructor expect it to flow through to here, so + # surface it explicitly rather than swallowing it as an extra kwarg. + name: str | None = Field( + default=None, + description=( + "Human-readable display name for the agent. Distinct from " + "``agent_id`` (which is meant to be unique) — ``name`` is " + "what shows up in logs, traces, and multi-agent labels." + ), + ) + # Model parameters temperature: float = Field( default=0.7, diff --git a/src/locus/models/providers/oci/__init__.py b/src/locus/models/providers/oci/__init__.py index c4356360..6c9ef9fd 100644 --- a/src/locus/models/providers/oci/__init__.py +++ b/src/locus/models/providers/oci/__init__.py @@ -103,8 +103,10 @@ def __init__( auth_type: str | OCIAuthType | None = None, config_file: str = "~/.oci/config", service_endpoint: str | None = None, + region: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, + profile: str | None = None, **kwargs: Any, ) -> None: """Initialize OCI GenAI model. @@ -118,11 +120,32 @@ def __init__( instance_principal). When ``None`` (default), reads ``OCI_AUTH_TYPE`` from env, falling back to ``api_key``. config_file: Path to OCI config file - service_endpoint: Full OCI GenAI service endpoint URL + service_endpoint: Full OCI GenAI service endpoint URL. Takes + precedence over ``region`` when both are set. + region: OCI region for the GenAI inference endpoint (e.g. + ``"us-chicago-1"``). When set and ``service_endpoint`` is + not provided, the endpoint is built as + ``https://inference.generativeai.{region}.oci.oraclecloud.com``. + Use this to target a model hosted outside your profile's + home region without writing out the full URL. max_tokens: Maximum tokens for response temperature: Model temperature (0.0-1.0) + profile: Alias for ``profile_name``. Setting both at the same + time (to different values) raises ``ValueError``. Exists + because ``profile=`` is the natural name users reach for + — the previous behaviour silently swallowed it through + ``**kwargs`` and fell through to the ``DEFAULT`` profile. **kwargs: Additional model parameters """ + # ``profile=`` is the natural name; treat it as an alias. + if profile is not None: + if profile_name not in ("DEFAULT", profile): + raise ValueError( + "OCIModel: pass either `profile` or `profile_name`, not both " + f"(got profile={profile!r}, profile_name={profile_name!r})." + ) + profile_name = profile + if auth_type is None: import os @@ -139,6 +162,13 @@ def __init__( compartment_id = os.getenv("OCI_COMPARTMENT") or os.getenv("OCI_COMPARTMENT_ID") + # Resolve the endpoint. Explicit ``service_endpoint`` wins; otherwise + # build it from ``region`` so users don't have to type out the full + # ``https://inference.generativeai..oci.oraclecloud.com`` URL + # just to target a model hosted outside their profile's home region. + if service_endpoint is None and region is not None: + service_endpoint = f"https://inference.generativeai.{region}.oci.oraclecloud.com" + config = OCIConfig( model_id=model_id, compartment_id=compartment_id, diff --git a/src/locus/multiagent/graph.py b/src/locus/multiagent/graph.py index 107173e3..b8317904 100644 --- a/src/locus/multiagent/graph.py +++ b/src/locus/multiagent/graph.py @@ -1038,8 +1038,20 @@ async def execute( iterations += 1 next_nodes: list[str] = [] + # When resuming from an ``interrupt_before`` pause, the very + # first iteration of the new ``execute()`` call re-enters the + # gate node we paused on. Without this guard, the gate would + # fire immediately and we'd pause again — making "approve once + # and continue" semantics impossible. Skip the gate check for + # the resume_node on the first iteration only; ``resume_node`` + # is cleared at the bottom of the iteration so subsequent + # iterations gate normally. + just_resumed_node = resume_node if iterations == 1 else None + # Check for interrupt_before for node_id in current_nodes: + if node_id == just_resumed_node: + continue if node_id in cfg.interrupt_before: # Create a placeholder interrupt value for interrupt_before from locus.core.interrupt import InterruptValue @@ -1055,6 +1067,47 @@ async def execute( pending_nodes=current_nodes, state_snapshot=state, ) + + # Save to checkpointer if available so a follow-up + # ``execute(Command(resume=...))`` can recover the + # paused state. Without this, durable cross-process + # resume doesn't work for the ``interrupt_before`` + # gate — the reason most users wire a checkpointer + # to a ``StateGraph`` in the first place. + # + # We pack the graph-level fields into + # ``AgentState.metadata`` because the resume side + # (``execute()`` above, line ~999) reads them off + # ``saved_state.metadata`` — MemoryCheckpointer and + # the other in-protocol backends round-trip the + # ``AgentState`` itself, not the ``metadata=`` kwarg. + if cfg.checkpointer and cfg.thread_id: + from locus.core.state import AgentState # noqa: PLC0415 + + stub_state = AgentState( + metadata={ + "graph_state": state, + "interrupted_node": node_id, + "interrupt": interrupt_state.model_dump(), + } + ) + await cfg.checkpointer.save( + state=stub_state, + thread_id=cfg.thread_id, + ) + from locus.observability.emit import ( # noqa: PLC0415 + EV_CHECKPOINT_SAVED, + emit, + ) + + await emit( + EV_CHECKPOINT_SAVED, + thread_id=cfg.thread_id, + backend=type(cfg.checkpointer).__name__, + trigger="graph_interrupt_before", + interrupted_node=node_id, + ) + # Include resume node in final state final_state_with_resume = {**state, "__resume_node__": node_id} return GraphResult( @@ -1186,16 +1239,22 @@ async def execute( state_snapshot=state, ) - # Save to checkpointer if available + # Save to checkpointer if available. Same packing as + # the ``interrupt_before`` branch above — the resume + # path reads off ``saved_state.metadata``. if cfg.checkpointer and cfg.thread_id: - await cfg.checkpointer.save( - state=None, - thread_id=cfg.thread_id, + from locus.core.state import AgentState # noqa: PLC0415 + + stub_state = AgentState( metadata={ "graph_state": state, "interrupted_node": node_id, "interrupt": interrupt_state.model_dump(), - }, + } + ) + await cfg.checkpointer.save( + state=stub_state, + thread_id=cfg.thread_id, ) from locus.observability.emit import ( # noqa: PLC0415 EV_CHECKPOINT_SAVED, diff --git a/src/locus/tools/decorator.py b/src/locus/tools/decorator.py index e6e6a2db..a6564c55 100644 --- a/src/locus/tools/decorator.py +++ b/src/locus/tools/decorator.py @@ -43,6 +43,14 @@ class Tool(BaseModel): model_config = {"arbitrary_types_allowed": True} + @property + def func(self) -> Callable[..., Any]: + """Alias for :attr:`fn`. Some samples and downstream code reach + for ``.func`` (the LangChain/LangGraph idiom); keep both names + pointed at the same underlying callable so users don't have to + write ``getattr(t, 'fn', None) or getattr(t, 'func', t)``.""" + return self.fn + async def execute(self, ctx: ToolContext | None = None, **kwargs: Any) -> Any: """ Execute the tool with given arguments. diff --git a/tests/integration/test_papercut_fixes_live.py b/tests/integration/test_papercut_fixes_live.py new file mode 100644 index 00000000..47f15f47 --- /dev/null +++ b/tests/integration/test_papercut_fixes_live.py @@ -0,0 +1,324 @@ +# 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/ + +"""End-to-end coverage for the three papercut fixes that ship together. + +Each test exercises a fix against a real OCI GenAI inference endpoint +so the contract is verified all the way down to the wire, not just at +the Python API surface. + +Skips automatically when OCI credentials / endpoint env vars aren't +set. The same envelope as ``tests/integration/test_oci_graph_integration.py``: + +- ``OCI_MODEL_ID`` — e.g. ``"openai.gpt-oss-20b"`` +- ``OCI_PROFILE`` — profile name in ``~/.oci/config`` +- ``OCI_AUTH_TYPE`` — ``api_key`` or ``security_token`` +- ``OCI_COMPARTMENT`` — compartment OCID for the GenAI tenancy +- ``OCI_ENDPOINT`` *or* ``OCI_GENAI_REGION`` — the inference service. + When only ``OCI_GENAI_REGION`` is set, the new ``region=`` constructor + param is exercised; when ``OCI_ENDPOINT`` is set, it wins. + +To run locally:: + + OCI_MODEL_ID=openai.gpt-oss-20b \ + OCI_PROFILE= \ + OCI_AUTH_TYPE=api_key \ + OCI_COMPARTMENT=ocid1.compartment.oc1..xxx \ + OCI_GENAI_REGION=us-chicago-1 \ + uv run pytest tests/integration/test_papercut_fixes_live.py -v +""" + +from __future__ import annotations + +import os + +import pytest + + +pytest.importorskip("oci") + + +from locus.agent import Agent, AgentConfig +from locus.core import Command, Message +from locus.memory.backends.memory import MemoryCheckpointer +from locus.models.providers.oci import OCIAuthType, OCIModel +from locus.multiagent import END, START, GraphConfig, StateGraph +from locus.tools.decorator import tool + + +pytestmark = [ + pytest.mark.integration, + pytest.mark.requires_oci, +] + + +# ============================================================================= +# Env helpers +# ============================================================================= + + +def _env() -> dict[str, str | None]: + return { + "model_id": os.environ.get("OCI_MODEL_ID"), + "profile": os.environ.get("OCI_PROFILE"), + "auth_type": os.environ.get("OCI_AUTH_TYPE", "api_key"), + "endpoint": os.environ.get("OCI_ENDPOINT"), + "region": os.environ.get("OCI_GENAI_REGION"), + "compartment": os.environ.get("OCI_COMPARTMENT"), + } + + +def _skip_unless_ready() -> dict[str, str]: + env = _env() + missing = [k for k in ("model_id", "profile", "compartment") if not env[k]] + if missing: + pytest.skip(f"OCI integration env not set: missing {missing}") + if not (env["endpoint"] or env["region"]): + pytest.skip("Set OCI_ENDPOINT or OCI_GENAI_REGION to target an inference endpoint") + return {k: v for k, v in env.items() if v is not None} # type: ignore[misc] + + +def _auth_from(s: str) -> OCIAuthType: + return OCIAuthType(s) + + +# ============================================================================= +# #259 — OCIModel region= and profile= aliases (real call against the endpoint) +# ============================================================================= + + +class TestOCIModelRegionLive: + """``region=`` builds the inference endpoint and the resulting model + actually reaches OCI GenAI. Without the fix, ``region=`` was + silently swallowed by ``**kwargs`` and the model went to the + profile's home region (404 if the model lived elsewhere).""" + + @pytest.mark.asyncio + async def test_region_alone_targets_that_region(self) -> None: + env = _skip_unless_ready() + if not env.get("region"): + pytest.skip("OCI_GENAI_REGION not set; cannot exercise region= path") + + model = OCIModel( + model_id=env["model_id"], + profile=env["profile"], + auth_type=_auth_from(env["auth_type"]), + compartment_id=env["compartment"], + region=env["region"], + max_tokens=256, + temperature=0.0, + ) + expected_endpoint = f"https://inference.generativeai.{env['region']}.oci.oraclecloud.com" + assert model.config.service_endpoint == expected_endpoint + + # The endpoint round-trip is the contract we care about here. + # Don't assert on visible content — some models (gpt-oss-*, + # other reasoning models) can produce only hidden reasoning + # tokens at small budgets. The presence of a ``usage`` count + # proves the wire call actually reached the regional endpoint. + resp = await model.complete([Message.user("Reply with the word 'ok'.")]) + assert resp.message is not None + prompt_tokens = (resp.usage or {}).get("prompt_tokens", 0) + assert prompt_tokens > 0, "no token usage returned — the model never billed the request" + + @pytest.mark.asyncio + async def test_explicit_endpoint_wins_over_region(self) -> None: + """``service_endpoint=`` is authoritative when both are set.""" + env = _skip_unless_ready() + if not env.get("endpoint"): + pytest.skip("OCI_ENDPOINT not set; cannot exercise explicit-endpoint path") + + model = OCIModel( + model_id=env["model_id"], + profile=env["profile"], + auth_type=_auth_from(env["auth_type"]), + compartment_id=env["compartment"], + service_endpoint=env["endpoint"], + region="us-ashburn-1", # mismatched on purpose + max_tokens=256, + temperature=0.0, + ) + assert model.config.service_endpoint == env["endpoint"] + + resp = await model.complete([Message.user("Reply with the word 'ok'.")]) + assert resp.message is not None + assert (resp.usage or {}).get("prompt_tokens", 0) > 0 + + +class TestOCIModelProfileAliasLive: + """``profile=`` is the natural name; before the fix it fell through + to ``**kwargs`` and the model silently used the ``DEFAULT`` profile. + Now it aliases ``profile_name=`` and a real call goes through with + the chosen profile's credentials.""" + + @pytest.mark.asyncio + async def test_profile_alias_round_trip(self) -> None: + env = _skip_unless_ready() + + kwargs: dict[str, object] = { + "model_id": env["model_id"], + "auth_type": _auth_from(env["auth_type"]), + "compartment_id": env["compartment"], + "max_tokens": 256, + "temperature": 0.0, + } + if env.get("endpoint"): + kwargs["service_endpoint"] = env["endpoint"] + elif env.get("region"): + kwargs["region"] = env["region"] + + # Build with ``profile=`` (the alias). + m1 = OCIModel(profile=env["profile"], **kwargs) # type: ignore[arg-type] + assert m1.config.profile_name == env["profile"] + r1 = await m1.complete([Message.user("Reply with the word 'ok'.")]) + assert r1.message is not None + # Token-usage presence proves the call reached the endpoint + # under the aliased profile's credentials (visible content is + # optional for reasoning models at small token budgets). + assert (r1.usage or {}).get("prompt_tokens", 0) > 0 + + # And the same profile via ``profile_name=`` should behave + # identically — proves the alias didn't change semantics. + m2 = OCIModel(profile_name=env["profile"], **kwargs) # type: ignore[arg-type] + r2 = await m2.complete([Message.user("Reply with the word 'ok'.")]) + assert r2.message is not None + assert (r2.usage or {}).get("prompt_tokens", 0) > 0 + + +# ============================================================================= +# #258 — StateGraph interrupt_before with checkpointer + resume past gate +# ============================================================================= + + +class TestInterruptBeforeResumeLive: + """Pause a graph at an ``interrupt_before`` gate, let the + checkpointer persist the paused state, then resume — the post-gate + node makes a real LLM call and the graph completes. + + Before the fix this path was broken in two ways: (1) the pause + boundary didn't write to the checkpointer (so cross-process resume + lost the state), and (2) resume re-paused at the same gate (so even + in-process resume never advanced).""" + + @pytest.mark.asyncio + async def test_pause_then_resume_runs_llm_node(self) -> None: + env = _skip_unless_ready() + + # Build the model from env. Use ``region=`` if no explicit + # endpoint — exercises the #259 fix alongside #258. + model_kwargs: dict[str, object] = { + "model_id": env["model_id"], + "profile": env["profile"], + "auth_type": _auth_from(env["auth_type"]), + "compartment_id": env["compartment"], + "max_tokens": 32, + "temperature": 0.0, + } + if env.get("endpoint"): + model_kwargs["service_endpoint"] = env["endpoint"] + else: + model_kwargs["region"] = env["region"] + oci_model = OCIModel(**model_kwargs) # type: ignore[arg-type] + + graph = StateGraph() + + async def gate(_inputs: dict) -> dict: + return {"approved": True} + + async def llm_node(_inputs: dict) -> dict: + resp = await oci_model.complete([Message.user("Reply with the word 'done'.")]) + text = (resp.message.content or "").strip().lower() if resp.message else "" + return {"llm_text": text, "done": True} + + graph.add_node("gate", gate) + graph.add_node("llm_node", llm_node) + graph.add_edge(START, "gate") + graph.add_edge("gate", "llm_node") + graph.add_edge("llm_node", END) + + cp = MemoryCheckpointer() + cfg = GraphConfig( + interrupt_before=["gate"], + checkpointer=cp, + thread_id="papercut-258-live", + ) + + # First execute — should pause at the gate without ever invoking the LLM. + paused = await graph.execute({}, config=cfg) + assert paused.interrupt is not None + assert paused.interrupt.node_id == "gate" + + # The fix saves through the checkpointer at the pause boundary. + saved = await cp.load("papercut-258-live") + assert saved is not None, "interrupt_before must persist when a checkpointer is wired" + assert saved.metadata.get("interrupted_node") == "gate" + + # Resume — the LLM node fires and the graph completes. + resumed = await graph.execute(Command(resume=True), config=cfg) + assert resumed.interrupt is None, "resume should not re-pause at the gate" + assert resumed.final_state.get("done") is True + assert (resumed.final_state.get("llm_text") or "").strip() != "" + + +# ============================================================================= +# #260 — AgentConfig.name + Tool.func alias (real Agent run, real LLM call) +# ============================================================================= + + +class TestAgentNameAndToolFuncLive: + """End-to-end: build an ``Agent`` with ``name=`` set, attach a tool + via ``@tool``, run a prompt that should drive the tool. Verifies + the new ``name`` field flows through the config, the agent runs + cleanly against a real LLM, and ``.func`` returns the same callable + as ``.fn`` after decoration.""" + + @pytest.mark.asyncio + async def test_named_agent_runs_with_real_llm_and_tool(self) -> None: + env = _skip_unless_ready() + + model_kwargs: dict[str, object] = { + "model_id": env["model_id"], + "profile": env["profile"], + "auth_type": _auth_from(env["auth_type"]), + "compartment_id": env["compartment"], + "max_tokens": 96, + "temperature": 0.0, + } + if env.get("endpoint"): + model_kwargs["service_endpoint"] = env["endpoint"] + else: + model_kwargs["region"] = env["region"] + oci_model = OCIModel(**model_kwargs) # type: ignore[arg-type] + + @tool + def add(a: int, b: int) -> int: + """Return a + b.""" + return a + b + + # ``.func`` is the alias for ``.fn`` — both must point at the + # same wrapped callable after decoration. + assert add.func is add.fn + assert add.func(2, 3) == 5 + + config = AgentConfig( + model=oci_model, + name="papercut-260-live", + tools=[add], + system_prompt=( + "You are a calculator. Use the `add` tool to compute sums; " + "do not compute the answer yourself." + ), + max_iterations=4, + ) + assert config.name == "papercut-260-live" + + agent = Agent(config=config) + assert agent.name == "papercut-260-live" + + result = agent.run_sync("What is 17 + 25?") + # Either the model returns the tool result inline or it answers + # in plain text — both are acceptable for this smoke test. The + # important assertion is that the full Agent + Tool path runs + # against a real LLM without exceptions. + assert result is not None diff --git a/tests/unit/test_agent_config.py b/tests/unit/test_agent_config.py index 4d12bb3e..2cdca2e2 100644 --- a/tests/unit/test_agent_config.py +++ b/tests/unit/test_agent_config.py @@ -245,6 +245,17 @@ def test_default_system_prompt(self): config = AgentConfig(model="openai:gpt-4o") assert "helpful" in config.system_prompt.lower() + def test_name_accepted_and_stored(self): + """``AgentConfig`` accepts a display name without erroring on + ``extra='forbid'`` — users putting ``name=`` on the agent + reasonably expect to pass it on the config too.""" + config = AgentConfig(model="openai:gpt-4o", name="planner") + assert config.name == "planner" + + def test_name_defaults_to_none(self): + config = AgentConfig(model="openai:gpt-4o") + assert config.name is None + class TestReasoningShorthand: """Boolean shorthand for reflexion + grounding configs. diff --git a/tests/unit/test_graph.py b/tests/unit/test_graph.py index 1df5997f..0e22f8f4 100644 --- a/tests/unit/test_graph.py +++ b/tests/unit/test_graph.py @@ -1060,6 +1060,104 @@ async def step2(inputs): assert "__resume_node__" in result.final_state assert result.final_state["__resume_node__"] == "step2" + @pytest.mark.asyncio + async def test_interrupt_before_saves_through_checkpointer(self): + """When a checkpointer is configured, the interrupt_before pause + boundary writes through to it — durable resume needs the paused + state on disk, not just on the returned ``GraphResult``.""" + from locus.memory.backends.memory import MemoryCheckpointer + + graph = StateGraph() + + async def gate(inputs): + return {"gated": True} + + async def after(inputs): + return {"done": True} + + graph.add_node("gate", gate) + graph.add_node("after", after) + graph.add_edge(START, "gate") + graph.add_edge("gate", "after") + graph.add_edge("after", END) + + cp = MemoryCheckpointer() + cfg = GraphConfig( + interrupt_before=["gate"], + checkpointer=cp, + thread_id="t-save", + ) + result = await graph.execute({"x": 1}, config=cfg) + assert result.interrupt is not None + assert result.interrupt.node_id == "gate" + + saved = await cp.load("t-save") + assert saved is not None, "interrupt_before must persist when a checkpointer is wired" + assert saved.metadata.get("interrupted_node") == "gate" + assert saved.metadata.get("graph_state", {}).get("x") == 1 + + @pytest.mark.asyncio + async def test_resume_after_interrupt_before_advances_past_gate(self): + """``Command(resume=...)`` must continue past the gate node — the + previous behaviour re-paused on every resume, so durable + human-in-the-loop flows needed application-side bookkeeping to + rewrite ``__resume_node__``.""" + from locus.memory.backends.memory import MemoryCheckpointer + + graph = StateGraph() + + async def gate(inputs): + return {"gated": True} + + async def after(inputs): + return {"done": True} + + graph.add_node("gate", gate) + graph.add_node("after", after) + graph.add_edge(START, "gate") + graph.add_edge("gate", "after") + graph.add_edge("after", END) + + cp = MemoryCheckpointer() + cfg = GraphConfig( + interrupt_before=["gate"], + checkpointer=cp, + thread_id="t-resume", + ) + first = await graph.execute({}, config=cfg) + assert first.interrupt is not None # paused + + resumed = await graph.execute(Command(resume=True), config=cfg) + assert resumed.interrupt is None, "resume should not re-pause at the gate" + assert resumed.final_state.get("done") is True + + @pytest.mark.asyncio + async def test_resume_without_checkpointer_uses_resume_node_in_state(self): + """Same advance-past-gate behaviour for the checkpointer-less path + (``Command(resume=..., update={...with __resume_node__})``).""" + graph = StateGraph() + + async def gate(inputs): + return {"gated": True} + + async def after(inputs): + return {"done": True} + + graph.add_node("gate", gate) + graph.add_node("after", after) + graph.add_edge(START, "gate") + graph.add_edge("gate", "after") + graph.add_edge("after", END) + + cfg = GraphConfig(interrupt_before=["gate"]) + first = await graph.execute({}, config=cfg) + assert first.interrupt is not None + carry = dict(first.final_state) # includes __resume_node__ == "gate" + + resumed = await graph.execute(Command(resume=True, update=carry), config=cfg) + assert resumed.interrupt is None + assert resumed.final_state.get("done") is True + class TestStateGraphParallelExecution: """Tests for parallel node execution.""" diff --git a/tests/unit/test_idempotent_tools.py b/tests/unit/test_idempotent_tools.py index c4f67936..4f5f5b8e 100644 --- a/tests/unit/test_idempotent_tools.py +++ b/tests/unit/test_idempotent_tools.py @@ -183,3 +183,28 @@ def test_get_today_date_returns_expected_keys(self): result = get_today_date.fn() assert {"today", "weekday", "year", "tomorrow", "next_7_days_by_weekday"} <= result.keys() + + +class TestToolFuncAlias: + """``.fn`` and ``.func`` both point at the wrapped callable. + + Some downstream samples and frameworks (LangChain/LangGraph idiom) reach + for ``.func``; the Pydantic field is named ``fn``. Surfacing both names + avoids the ``getattr(t, "fn", None) or getattr(t, "func", t)`` dance. + """ + + def test_func_returns_same_callable_as_fn(self): + @tool(description="t") + async def my_tool(x: int) -> int: + return x + 1 + + assert my_tool.func is my_tool.fn + + def test_func_works_for_sync_tool(self): + @tool + def my_sync_tool(x: int) -> int: + """Doubles x.""" + return x * 2 + + assert my_sync_tool.func(3) == 6 + assert my_sync_tool.func is my_sync_tool.fn diff --git a/tests/unit/test_oci_model.py b/tests/unit/test_oci_model.py index 84236fa5..03dbd122 100644 --- a/tests/unit/test_oci_model.py +++ b/tests/unit/test_oci_model.py @@ -48,6 +48,53 @@ def test_string_auth_type_coerced(self) -> None: assert m.config.auth_type == OCIAuthType.API_KEY +class TestOCIModelRegionAndProfileAlias: + """``region=`` builds the inference endpoint; ``profile=`` is an + alias for ``profile_name``. Both exist so users don't have to type + out the full ``https://inference.generativeai..oci.oraclecloud.com`` + URL just to target a cross-region model, and so ``profile=`` (the + natural name) stops being silently swallowed into ``**kwargs``.""" + + def test_region_builds_inference_endpoint(self) -> None: + m = OCIModel(model_id="openai.gpt-oss-20b", region="us-chicago-1") + assert m.config.service_endpoint == ( + "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com" + ) + + def test_explicit_endpoint_wins_over_region(self) -> None: + explicit = "https://example.invalid/v1" + m = OCIModel( + model_id="openai.gpt-oss-20b", + region="us-chicago-1", + service_endpoint=explicit, + ) + assert m.config.service_endpoint == explicit + + def test_no_region_no_endpoint_means_endpoint_stays_none(self) -> None: + m = OCIModel(model_id="cohere.command-r-plus") + assert m.config.service_endpoint is None + + def test_profile_alias_sets_profile_name(self) -> None: + m = OCIModel(model_id="cohere.command-r-plus", profile="MY_SESSION_PROFILE") + assert m.config.profile_name == "MY_SESSION_PROFILE" + + def test_profile_and_profile_name_same_value_is_fine(self) -> None: + m = OCIModel( + model_id="cohere.command-r-plus", + profile="MY", + profile_name="MY", + ) + assert m.config.profile_name == "MY" + + def test_profile_and_profile_name_conflict_raises(self) -> None: + with pytest.raises(ValueError, match=r"profile.*profile_name"): + OCIModel( + model_id="cohere.command-r-plus", + profile="ONE", + profile_name="TWO", + ) + + # --------------------------------------------------------------------------- # _get_provider dispatch # --------------------------------------------------------------------------- From 1febd2ec687c2f98aa3a695008dae2cb60df16a6 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Sat, 23 May 2026 10:12:56 -0400 Subject: [PATCH 2/3] test(graph): cover interrupt save sites across non-memory backends + inline interrupt path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled three-papercut fix touched two save sites in ``StateGraph.execute()`` (the ``interrupt_before`` gate and the inline ``interrupt()`` branch). Initial coverage only exercised ``interrupt_before`` against ``MemoryCheckpointer``. Add tests so the save shape is verified end-to-end on: - ``FileCheckpointer`` (real JSON serialisation on disk) — proves ``AgentState.metadata`` round-trips through a non-memory backend that uses the canonical ``to_checkpoint()`` / ``from_checkpoint()`` pattern shared by every BaseCheckpointer implementation. - The inline ``interrupt()`` save site, against both Memory and File backends — this branch was broken on main (passed ``state=None``, crashed any backend calling ``state.to_checkpoint()``). The fix packs the graph state into ``AgentState.metadata`` and the new tests prove it persists correctly through both backends. Also re-ran ``tests/integration/test_checkpoint_backends.py`` and ``tests/integration/test_checkpointer_adapters.py``: 14 passed, 28 skipped (skipped require live Redis / PostgreSQL / OpenSearch / OCI Bucket services). No regressions on the lower-level save/load round trips. Signed-off-by: Federico Kamelhar --- tests/unit/test_graph.py | 113 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/tests/unit/test_graph.py b/tests/unit/test_graph.py index 0e22f8f4..f372efb5 100644 --- a/tests/unit/test_graph.py +++ b/tests/unit/test_graph.py @@ -1158,6 +1158,119 @@ async def after(inputs): assert resumed.interrupt is None assert resumed.final_state.get("done") is True + @pytest.mark.asyncio + async def test_interrupt_before_round_trips_through_file_checkpointer(self, tmp_path): + """The pause-boundary save shape must round-trip through any + BaseCheckpointer-conformant backend, not just MemoryCheckpointer. + FileCheckpointer exercises JSON serialization on disk — proves + ``AgentState.metadata`` survives ``to_checkpoint()`` → + ``from_checkpoint()`` for the non-memory path too.""" + from locus.memory.backends.file import FileCheckpointer + + graph = StateGraph() + + async def gate(inputs): + return {"gated": True} + + async def after(inputs): + return {"done": True} + + graph.add_node("gate", gate) + graph.add_node("after", after) + graph.add_edge(START, "gate") + graph.add_edge("gate", "after") + graph.add_edge("after", END) + + cp = FileCheckpointer(base_dir=tmp_path / "cps") + cfg = GraphConfig( + interrupt_before=["gate"], + checkpointer=cp, + thread_id="t-file", + ) + paused = await graph.execute({"x": 1}, config=cfg) + assert paused.interrupt is not None + + # Reload through the file backend (verifies on-disk shape). + saved = await cp.load("t-file") + assert saved is not None + assert saved.metadata.get("interrupted_node") == "gate" + assert saved.metadata.get("graph_state", {}).get("x") == 1 + + resumed = await graph.execute(Command(resume=True), config=cfg) + assert resumed.interrupt is None + assert resumed.final_state.get("done") is True + + +class TestInlineInterruptCheckpointerSave: + """The inline ``interrupt()`` save site at the other end of the + execution loop uses the same shape as the new ``interrupt_before`` + save. Was crashing before the fix (``state=None`` against any + backend that calls ``state.to_checkpoint()``). These tests prove the + shape works on both memory and file backends.""" + + @pytest.mark.asyncio + async def test_inline_interrupt_persists_to_memory_checkpointer(self): + from locus.memory.backends.memory import MemoryCheckpointer + + graph = StateGraph() + + async def prepare(inputs): + return {"prepared": True} + + async def approve(inputs): + approval = interrupt({"message": "Approve?"}) + return {"approved": approval == "yes"} + + async def execute_action(inputs): + return {"executed": True} + + graph.add_node("prepare", prepare) + graph.add_node("approve", approve) + graph.add_node("execute_action", execute_action) + graph.add_edge(START, "prepare") + graph.add_edge("prepare", "approve") + graph.add_edge("approve", "execute_action") + graph.add_edge("execute_action", END) + + cp = MemoryCheckpointer() + cfg = GraphConfig(checkpointer=cp, thread_id="t-inline-mem") + result = await graph.execute({}, config=cfg) + assert result.is_interrupted + assert result.interrupt.node_id == "approve" + + saved = await cp.load("t-inline-mem") + assert saved is not None + assert saved.metadata.get("interrupted_node") == "approve" + assert "interrupt" in saved.metadata + + @pytest.mark.asyncio + async def test_inline_interrupt_persists_to_file_checkpointer(self, tmp_path): + from locus.memory.backends.file import FileCheckpointer + + graph = StateGraph() + + async def prepare(inputs): + return {"prepared": True} + + async def approve(inputs): + approval = interrupt({"message": "Approve?"}) + return {"approved": approval == "yes"} + + graph.add_node("prepare", prepare) + graph.add_node("approve", approve) + graph.add_edge(START, "prepare") + graph.add_edge("prepare", "approve") + graph.add_edge("approve", END) + + cp = FileCheckpointer(base_dir=tmp_path / "cps-inline") + cfg = GraphConfig(checkpointer=cp, thread_id="t-inline-file") + result = await graph.execute({}, config=cfg) + assert result.is_interrupted + + saved = await cp.load("t-inline-file") + assert saved is not None + assert saved.metadata.get("interrupted_node") == "approve" + class TestStateGraphParallelExecution: """Tests for parallel node execution.""" From 1c63cd009d714fa892c131b7c5e90e8c7f5bbcbe Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Sat, 23 May 2026 10:29:30 -0400 Subject: [PATCH 3/3] test(graph): cover interrupt_before save shape across real Redis + OpenSearch backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix at the ``interrupt_before`` and inline ``interrupt()`` save sites packs graph-level state into ``AgentState.metadata`` so it survives ``state.to_checkpoint()`` / ``state.from_checkpoint()``. Unit + file-backed coverage already verified the shape; these integration tests prove it round-trips through the two network-backed protocol backends that exercise real serialization paths: - ``redis_checkpointer`` — JSON serialization into Redis keys, then full reload from a separate Redis client call. - ``opensearch_checkpointer`` — JSON document indexing into OpenSearch, then ``_search`` round-trip via the OS REST API. Both tests pause→save→reload→assert metadata round-trips→resume→ assert the post-gate node ran. Skip cleanly when ``REDIS_URL`` / ``OPENSEARCH_HOSTS`` aren't set. Verified locally against a managed OCI OpenSearch dev cluster and the local Redis container shipped with the observai stack. Signed-off-by: Federico Kamelhar --- tests/integration/test_papercut_fixes_live.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/tests/integration/test_papercut_fixes_live.py b/tests/integration/test_papercut_fixes_live.py index 47f15f47..664734f3 100644 --- a/tests/integration/test_papercut_fixes_live.py +++ b/tests/integration/test_papercut_fixes_live.py @@ -261,6 +261,154 @@ async def llm_node(_inputs: dict) -> dict: assert (resumed.final_state.get("llm_text") or "").strip() != "" +# ============================================================================= +# #258 — interrupt_before save shape across non-memory checkpointer backends +# ============================================================================= +# +# The fix changes the save shape from ``save(state=None, metadata={...})`` to +# ``save(state=AgentState(metadata={...}))`` at both the ``interrupt_before`` +# and inline ``interrupt()`` sites in ``StateGraph.execute()``. That shape +# round-trips correctly through ``MemoryCheckpointer`` and +# ``FileCheckpointer`` (covered by unit tests). These integration tests +# prove it also round-trips through the network-backed protocol backends: +# Redis (in-memory KV) and OpenSearch (JSON document store), with real +# serialization, network I/O, and indexing semantics in play. +# +# Each test skips cleanly when its backend env vars aren't set. + + +def _has_redis() -> bool: + return bool(os.environ.get("REDIS_URL")) + + +def _has_opensearch() -> bool: + return bool(os.environ.get("OPENSEARCH_HOSTS") or os.environ.get("OPENSEARCH_HOST")) + + +@pytest.mark.skipif(not _has_redis(), reason="REDIS_URL not set") +class TestInterruptBeforeRedisLive: + """Pause→save→reload→resume through a real Redis backend. + + Verifies ``AgentState.metadata`` (which is where the fix packs the + graph-level state) survives Redis's JSON serialization round-trip. + """ + + @pytest.mark.asyncio + async def test_redis_backend_persists_and_resumes(self) -> None: + from locus.memory.backends import redis_checkpointer + + cp = redis_checkpointer( + url=os.environ["REDIS_URL"], + prefix="locus:papercut258:", + ) + + graph = StateGraph() + + async def gate(_inputs: dict) -> dict: + return {"approved": True} + + async def after(_inputs: dict) -> dict: + return {"done": True} + + graph.add_node("gate", gate) + graph.add_node("after", after) + graph.add_edge(START, "gate") + graph.add_edge("gate", "after") + graph.add_edge("after", END) + + thread_id = "papercut258-redis" + cfg = GraphConfig( + interrupt_before=["gate"], + checkpointer=cp, + thread_id=thread_id, + ) + + try: + paused = await graph.execute({"seed": 7}, config=cfg) + assert paused.interrupt is not None + assert paused.interrupt.node_id == "gate" + + saved = await cp.load(thread_id) + assert saved is not None, "Redis must hold the paused state" + assert saved.metadata.get("interrupted_node") == "gate" + assert saved.metadata.get("graph_state", {}).get("seed") == 7 + + resumed = await graph.execute(Command(resume=True), config=cfg) + assert resumed.interrupt is None + assert resumed.final_state.get("done") is True + finally: + # Idempotent cleanup — tests own their thread namespace. + try: + await cp.delete(thread_id) + except Exception: + pass + await cp.close() + + +@pytest.mark.skipif(not _has_opensearch(), reason="OPENSEARCH_HOSTS not set") +class TestInterruptBeforeOpenSearchLive: + """Pause→save→reload→resume through a real OpenSearch backend. + + OpenSearch stores the checkpoint as a JSON document with index-level + semantics. This test verifies the fix's save shape survives that + indexing pass and that resume continues past the gate.""" + + @pytest.mark.asyncio + async def test_opensearch_backend_persists_and_resumes(self) -> None: + from locus.memory.backends import opensearch_checkpointer + + hosts = os.environ.get("OPENSEARCH_HOSTS") or os.environ["OPENSEARCH_HOST"] + cp = opensearch_checkpointer( + hosts=[hosts], + index_name="locus-papercut258", + username=os.environ.get("OPENSEARCH_USER"), + password=os.environ.get("OPENSEARCH_PASSWORD"), + use_ssl=os.environ.get("OPENSEARCH_USE_SSL", "false").lower() == "true", + verify_certs=os.environ.get("OPENSEARCH_VERIFY_CERTS", "true").lower() == "true", + ) + + graph = StateGraph() + + async def gate(_inputs: dict) -> dict: + return {"approved": True} + + async def after(_inputs: dict) -> dict: + return {"done": True} + + graph.add_node("gate", gate) + graph.add_node("after", after) + graph.add_edge(START, "gate") + graph.add_edge("gate", "after") + graph.add_edge("after", END) + + thread_id = "papercut258-opensearch" + cfg = GraphConfig( + interrupt_before=["gate"], + checkpointer=cp, + thread_id=thread_id, + ) + + try: + paused = await graph.execute({"seed": 11}, config=cfg) + assert paused.interrupt is not None + assert paused.interrupt.node_id == "gate" + + saved = await cp.load(thread_id) + assert saved is not None, "OpenSearch must hold the paused state" + assert saved.metadata.get("interrupted_node") == "gate" + assert saved.metadata.get("graph_state", {}).get("seed") == 11 + + resumed = await graph.execute(Command(resume=True), config=cfg) + assert resumed.interrupt is None + assert resumed.final_state.get("done") is True + finally: + try: + await cp.delete(thread_id) + except Exception: + pass + await cp.close() + + # ============================================================================= # #260 — AgentConfig.name + Tool.func alias (real Agent run, real LLM call) # =============================================================================