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
42 changes: 22 additions & 20 deletions src/dynavec/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
)
from .graph import (
AgentGraph,
AgentState,
BaseNode,
Edge,
FlowGraph,
Expand All @@ -40,36 +39,24 @@
append_reducer,
default_reducer,
)
from .handoff import Handoff, HandoffRequest
from .planner import Planner
from .react import ReActAgent
from .registry import ToolRegistry, default_registry
from .state import AgentState
from .supervisor import AgentLike, Supervisor

__all__ = [
# Existing agent primitives
"AgentResult",
"AgentLike",
"AgentStep",
"AgentTool",
"Plan",
"PlanStep",
"Planner",
"ReActAgent",
"tool",
# Tool registry & connectors
"ToolRegistry",
"create_http_tool",
"create_python_code_tool",
"default_registry",
"mcp_tools_from_session",
"mcp_tools_from_session_async",
# Checkpointer
"Checkpoint",
"BaseCheckpointer",
"MemoryCheckpointer",
"DynamoDBCheckpointer",
# Graph orchestration engine (#277 / #278)
"AgentGraph",
"AgentState",
"BaseNode",
"BaseCheckpointer",
"Checkpoint",
"DynamoDBCheckpointer",
"Edge",
"FlowGraph",
"FlowState",
Expand All @@ -79,8 +66,23 @@
"GraphResult",
"GraphState",
"GraphValidationError",
"Handoff",
"HandoffRequest",
"MaxIterationsExceededError",
"MemoryCheckpointer",
"NodeTrace",
"Plan",
"PlanStep",
"Planner",
"ReActAgent",
"Supervisor",
"ToolRegistry",
"append_reducer",
"create_http_tool",
"create_python_code_tool",
"default_reducer",
"default_registry",
"mcp_tools_from_session",
"mcp_tools_from_session_async",
"tool",
]
55 changes: 55 additions & 0 deletions src/dynavec/agents/handoff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Handoff primitives for multi-agent workflows."""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from .base import AgentResult
from .state import AgentState

if TYPE_CHECKING:
from .supervisor import AgentLike


@dataclass(frozen=True)
class HandoffRequest:
"""Request graph execution to continue at a named specialist."""

target: str
goal: str


class Handoff:
"""Pass workflow state through or request transfer to a named specialist."""

def __call__(
self,
target: str | AgentState,
goal: str | None = None,
) -> HandoffRequest | AgentState:
"""Preserve legacy state passthrough and support named handoff requests."""
if isinstance(target, AgentState):
if goal is not None:
raise TypeError("A goal cannot be supplied when passing through state.")
return target

if goal is None:
raise TypeError("A goal is required for a named handoff.")

if not target.strip():
raise ValueError("Handoff target must not be empty.")
if not goal.strip():
raise ValueError("Handoff goal must not be empty.")

return HandoffRequest(target=target, goal=goal)

def transfer(
self,
target: AgentLike,
goal: str,
*,
state: AgentState,
) -> AgentResult:
"""Invoke a target specialist with the existing workflow state."""
return target.run(goal, state=state)
84 changes: 54 additions & 30 deletions src/dynavec/agents/react.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

from __future__ import annotations

import json
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any

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,17 +95,25 @@ 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())

def _init_messages(self, goal: str) -> list[Message]:
def _init_messages(
self,
goal: str,
state: AgentState | None = None,
) -> list[Message]:
messages: list[Message] = []
if self.system_prompt:
messages.append(Message(role="system", content=self.system_prompt))
messages.append(Message(role="user", content=goal))

user_content = goal
if state is not None and state.data:
shared_context = json.dumps(state.data, default=str, sort_keys=True)
user_content = f"{goal}\n\n" f"Shared workflow context (JSON):\n{shared_context}"

messages.append(Message(role="user", content=user_content))
return messages

def _dispatch_tool_call(self, tc: ToolCall) -> str:
Expand All @@ -118,9 +128,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 +158,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 +179,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,17 +190,27 @@ 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 checkpoint_state is not None:
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:
messages = self._init_messages(goal)
messages = self._init_messages(goal, state)
if self.checkpointer is not None and thread_id is not None:
self.checkpointer.put(
thread_id,
Expand Down Expand Up @@ -296,6 +312,7 @@ async def arun(
*,
thread_id: str | None = None,
resume_from: str | None = None,
state: AgentState | None = None,
**kwargs: Any,
) -> AgentResult:
"""Execute the ReAct loop asynchronously."""
Expand All @@ -307,16 +324,25 @@ async def arun(
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 checkpoint_state is not None:
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:
messages = self._init_messages(goal)
messages = self._init_messages(goal, state)
if self.checkpointer is not None and thread_id is not None:
self.checkpointer.put(
thread_id,
Expand Down Expand Up @@ -371,9 +397,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)
Loading
Loading