Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/locus/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions src/locus/agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 31 additions & 1 deletion src/locus/models/providers/oci/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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.<region>.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,
Expand Down
69 changes: 64 additions & 5 deletions src/locus/multiagent/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/locus/tools/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading