Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/dynavec/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@
mcp_tools_from_session,
mcp_tools_from_session_async,
)
from .handoff import Handoff
from .planner import Planner
from .react import ReActAgent
from .registry import ToolRegistry, default_registry
from .state import AgentState
from .supervisor import AgentLike, Supervisor

__all__ = [
"AgentState",
"Handoff",
"Supervisor",
"AgentResult",
"AgentLike",
"AgentStep",
"AgentTool",
"Plan",
Expand Down
13 changes: 13 additions & 0 deletions src/dynavec/agents/handoff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Handoff primitives for multi-agent workflows."""

from __future__ import annotations

from .state import AgentState


class Handoff:
"""Pass shared agent state from one workflow step to another."""

def __call__(self, state: AgentState) -> AgentState:
"""Return the same state so downstream agents share workflow context."""
return state
41 changes: 21 additions & 20 deletions src/dynavec/agents/react.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ..chat.base import ChatModel, Message, Tool, ToolCall
from .base import AgentResult, AgentStep, AgentTool
from .registry import ToolRegistry
from .state import AgentState

if TYPE_CHECKING:
from ..checkpoint import BaseCheckpointer
Expand Down Expand Up @@ -93,9 +94,7 @@ def __init__(
self._chat_tools.append(t.to_chat_tool())
elif tools:
for raw_tool in tools:
agent_tool = (
raw_tool if isinstance(raw_tool, AgentTool) else AgentTool(raw_tool)
)
agent_tool = raw_tool if isinstance(raw_tool, AgentTool) else AgentTool(raw_tool)
self._tools_map[agent_tool.name] = agent_tool
self._chat_tools.append(agent_tool.to_chat_tool())

Expand All @@ -118,9 +117,7 @@ async def _adispatch_tool_call(self, tc: ToolCall) -> str:
return await tool_instance.aexecute(tc.arguments)
return f"Error: Tool {tc.name!r} is not registered in available tools."

def _process_tool_calls(
self, tool_calls: list[ToolCall], messages: list[Message]
) -> list[str]:
def _process_tool_calls(self, tool_calls: list[ToolCall], messages: list[Message]) -> list[str]:
step_observations: list[str] = []
for tc in tool_calls:
obs = self._dispatch_tool_call(tc)
Expand Down Expand Up @@ -150,12 +147,9 @@ async def _aprocess_tool_calls(
)
return step_observations

def _finalize_result(
self, steps: list[AgentStep], total_tool_calls: int
) -> AgentResult:
def _finalize_result(self, steps: list[AgentStep], total_tool_calls: int) -> AgentResult:
last_output = (
steps[-1].thought
or (steps[-1].observations[-1] if steps[-1].observations else "")
steps[-1].thought or (steps[-1].observations[-1] if steps[-1].observations else "")
if steps
else ""
)
Expand All @@ -174,6 +168,7 @@ def run(
*,
thread_id: str | None = None,
resume_from: str | None = None,
state: AgentState | None = None,
**kwargs: Any,
) -> AgentResult:
"""Execute the ReAct loop synchronously until goal completion or max_steps."""
Expand All @@ -184,13 +179,21 @@ def run(

if self.checkpointer is not None and thread_id is not None:
existing_cp = self.checkpointer.get(thread_id, resume_from)

if existing_cp is not None:
state = existing_cp.state
if "messages" in state:
messages = [_dict_to_message(m) for m in state["messages"]]
if "steps" in state:
steps = [_dict_to_step(s) for s in state["steps"]]
total_tool_calls = int(state.get("total_tool_calls", 0))
checkpoint_state = existing_cp.state

if "messages" in checkpoint_state:
messages = [
_dict_to_message(m) for m in checkpoint_state["messages"]
]

if "steps" in checkpoint_state:
steps = [
_dict_to_step(s) for s in checkpoint_state["steps"]
]

total_tool_calls = int(checkpoint_state.get("total_tool_calls", 0))
start_step = existing_cp.step + 1

if not messages:
Expand Down Expand Up @@ -371,9 +374,7 @@ async def arun(
)

total_tool_calls += len(msg.tool_calls)
step_observations = await self._aprocess_tool_calls(
list(msg.tool_calls), messages
)
step_observations = await self._aprocess_tool_calls(list(msg.tool_calls), messages)

step = AgentStep(
step_number=step_idx,
Expand Down
13 changes: 13 additions & 0 deletions src/dynavec/agents/state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Shared state primitives for multi-agent workflows."""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any


@dataclass
class AgentState:
"""Shared state passed between agents in a workflow."""

data: dict[str, Any] = field(default_factory=dict)
32 changes: 32 additions & 0 deletions src/dynavec/agents/supervisor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Supervisor primitives for multi-agent workflows."""

from __future__ import annotations

from typing import Protocol

from .base import AgentResult
from .state import AgentState


class AgentLike(Protocol):
"""Interface required by a supervisor-managed agent."""

def run(
self,
goal: str,
*,
state: AgentState,
) -> AgentResult:
"""Execute an agent using shared workflow state."""
...


class Supervisor:
"""Delegate a goal to multiple agents using shared workflow state."""

def __init__(self, agents: list[AgentLike]) -> None:
self.agents = agents

def run(self, goal: str, *, state: AgentState) -> list[AgentResult]:
"""Run each specialist with the same shared state."""
return [agent.run(goal, state=state) for agent in self.agents]
20 changes: 20 additions & 0 deletions tests/test_agent_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from dynavec.agents import AgentState


def test_agent_state_stores_shared_data():
state = AgentState()

state.data["customer_id"] = "C123"
state.data["decline_code"] = "51"

assert state.data["customer_id"] == "C123"
assert state.data["decline_code"] == "51"


def test_agent_state_has_independent_data():
first = AgentState()
second = AgentState()

first.data["customer_id"] = "C123"

assert second.data == {}
24 changes: 21 additions & 3 deletions tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest

from dynavec.agents import (
AgentState,
AgentTool,
Plan,
Planner,
Expand Down Expand Up @@ -43,9 +44,7 @@ def invoke(
if self.call_count >= len(self.responses):
# Default response if script exhausted
return ChatResult(
message=Message(
role="assistant", content="Scripted responses exhausted."
),
message=Message(role="assistant", content="Scripted responses exhausted."),
finish_reason="stop",
)

Expand Down Expand Up @@ -343,3 +342,22 @@ async def test_planner_async_aplan() -> None:
assert len(plan.steps) == 2
assert plan.steps[0].description == "Step A"
assert plan.steps[1].description == "Step B"


def test_react_agent_accepts_shared_state():
state = AgentState()

agent = ReActAgent(
model=ScriptedChatModel(
[
"Transaction analysis completed.",
]
)
)

result = agent.run(
"Analyze transaction TX123.",
state=state,
)

assert result.output == "Transaction analysis completed."
18 changes: 18 additions & 0 deletions tests/test_handoff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from dynavec.agents import AgentState, Handoff


def test_handoff_preserves_shared_state():
state = AgentState()

state.data["transaction_id"] = "TX123"
state.data["decline_code"] = "51"

handoff = Handoff()

next_state = handoff(state)

assert next_state is state
assert next_state.data == {
"transaction_id": "TX123",
"decline_code": "51",
}
Loading
Loading