Skip to content

Commit fc6a497

Browse files
committed
Release v4.5.12
1 parent 47f35a9 commit fc6a497

14 files changed

Lines changed: 544 additions & 19 deletions

File tree

docker/Dockerfile.chat

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
1616
# Install Python packages (using latest versions)
1717
RUN pip install --no-cache-dir \
1818
praisonai_tools \
19-
"praisonai>=4.5.11" \
19+
"praisonai>=4.5.12" \
2020
"praisonai[chat]" \
2121
"embedchain[github,youtube]"
2222

docker/Dockerfile.dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
2020
# Install Python packages (using latest versions)
2121
RUN pip install --no-cache-dir \
2222
praisonai_tools \
23-
"praisonai>=4.5.11" \
23+
"praisonai>=4.5.12" \
2424
"praisonai[ui]" \
2525
"praisonai[chat]" \
2626
"praisonai[realtime]" \

docker/Dockerfile.ui

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
1616
# Install Python packages (using latest versions)
1717
RUN pip install --no-cache-dir \
1818
praisonai_tools \
19-
"praisonai>=4.5.11" \
19+
"praisonai>=4.5.12" \
2020
"praisonai[ui]" \
2121
"praisonai[crewai]"
2222

src/praisonai-agents/praisonaiagents/agent/agent.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5285,6 +5285,54 @@ def session_id(self) -> Optional[str]:
52855285
"""Get the current session ID."""
52865286
return self._session_id
52875287

5288+
def as_tool(
5289+
self,
5290+
description: Optional[str] = None,
5291+
tool_name: Optional[str] = None,
5292+
) -> 'Handoff':
5293+
"""Convert this agent to a callable tool for use by other agents.
5294+
5295+
Unlike handoffs which pass conversation context, as_tool() creates a tool
5296+
where the child agent receives only the generated input (no history).
5297+
The parent agent retains control and receives the result.
5298+
5299+
This is useful for hierarchical agent composition where you want to
5300+
invoke a specialist agent as a subordinate tool.
5301+
5302+
Args:
5303+
description: Tool description for the LLM (what this agent does)
5304+
tool_name: Custom tool name (default: invoke_<agent_name>)
5305+
5306+
Returns:
5307+
Handoff configured as a tool with no context passed
5308+
5309+
Example:
5310+
researcher = Agent(name="Researcher", instructions="Research topics")
5311+
coder = Agent(name="Coder", instructions="Write Python code")
5312+
5313+
writer = Agent(
5314+
name="Writer",
5315+
tools=[
5316+
researcher.as_tool("Research a topic and return findings"),
5317+
coder.as_tool("Write Python code for a given task"),
5318+
]
5319+
)
5320+
5321+
result = writer.chat("Write an article about async Python")
5322+
"""
5323+
from .handoff import Handoff, HandoffConfig, ContextPolicy
5324+
5325+
# Generate default tool name
5326+
agent_name_snake = self.name.lower().replace(' ', '_').replace('-', '_')
5327+
default_tool_name = f"invoke_{agent_name_snake}"
5328+
5329+
return Handoff(
5330+
agent=self,
5331+
tool_name_override=tool_name or default_tool_name,
5332+
tool_description_override=description or f"Invoke {self.name} to complete a subtask and return the result",
5333+
config=HandoffConfig(context_policy=ContextPolicy.NONE),
5334+
)
5335+
52885336
def chat(self, prompt, temperature=1.0, tools=None, output_json=None, output_pydantic=None, reasoning_steps=False, stream=None, task_name=None, task_description=None, task_id=None, config=None, force_retrieval=False, skip_retrieval=False, attachments=None, tool_choice=None):
52895337
"""
52905338
Chat with the agent.

src/praisonai-agents/praisonaiagents/agent/handoff.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
- Replaces/absorbs Agent.delegate() and SubagentDelegator functionality
1111
"""
1212

13-
from typing import Optional, Any, Callable, Dict, List, TYPE_CHECKING
13+
from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING
1414
from dataclasses import dataclass, field
1515
from enum import Enum
1616
import inspect
@@ -209,7 +209,7 @@ def __init__(
209209
tool_description_override: Optional[str] = None,
210210
on_handoff: Optional[Callable] = None,
211211
input_type: Optional[type] = None,
212-
input_filter: Optional[Callable[[HandoffInputData], HandoffInputData]] = None,
212+
input_filter: Optional[Union[Callable[[HandoffInputData], HandoffInputData], List[Callable[[HandoffInputData], HandoffInputData]]]] = None,
213213
config: Optional[HandoffConfig] = None,
214214
):
215215
"""
@@ -221,7 +221,8 @@ def __init__(
221221
tool_description_override: Custom tool description
222222
on_handoff: Callback function executed when handoff is invoked
223223
input_type: Type of input expected by the handoff (for structured data)
224-
input_filter: Function to filter/transform input before passing to target agent
224+
input_filter: Function or list of functions to filter/transform input.
225+
When a list is provided, filters are applied in order (chaining).
225226
config: HandoffConfig for advanced settings (context policy, timeouts, etc.)
226227
"""
227228
self.agent = agent
@@ -329,9 +330,14 @@ def _prepare_context(self, source_agent: 'Agent', kwargs: Dict[str, Any]) -> Han
329330
handoff_chain=_get_handoff_chain()[:],
330331
)
331332

332-
# Apply custom input filter if provided
333+
# Apply custom input filter(s) if provided
334+
# Supports both single filter and list of filters for chaining
333335
if self.input_filter:
334-
handoff_data = self.input_filter(handoff_data)
336+
if isinstance(self.input_filter, list):
337+
for filter_fn in self.input_filter:
338+
handoff_data = filter_fn(handoff_data)
339+
else:
340+
handoff_data = self.input_filter(handoff_data)
335341

336342
return handoff_data
337343

@@ -790,6 +796,36 @@ def remove_system_messages(data: HandoffInputData) -> HandoffInputData:
790796

791797
data.messages = filtered_messages
792798
return data
799+
800+
@staticmethod
801+
def compress_history(data: HandoffInputData) -> HandoffInputData:
802+
"""Compress all messages into a single summary message.
803+
804+
This filter concatenates all message contents into one user message,
805+
reducing token usage while preserving context. Useful for handoffs
806+
where the target agent needs context but not full conversation history.
807+
808+
Example:
809+
Handoff(agent=target, input_filter=handoff_filters.compress_history)
810+
"""
811+
if not data.messages:
812+
return data
813+
814+
summary_parts = []
815+
for msg in data.messages:
816+
if isinstance(msg, dict):
817+
role = msg.get('role', 'unknown')
818+
content = msg.get('content', '')
819+
if content:
820+
summary_parts.append(f"[{role}]: {content}")
821+
822+
if summary_parts:
823+
summary = "\n".join(summary_parts)
824+
data.messages = [{"role": "user", "content": f"Previous conversation summary:\n{summary}"}]
825+
else:
826+
data.messages = []
827+
828+
return data
793829

794830

795831
# Recommended prompt prefix for agents that use handoffs

src/praisonai-agents/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "praisonaiagents"
7-
version = "1.5.11"
7+
version = "1.5.12"
88
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
99
readme = "README.md"
1010
requires-python = ">=3.10"
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""
2+
Unit tests for Agent.as_tool() method.
3+
4+
Tests:
5+
1. as_tool() returns a Handoff with ContextPolicy.NONE
6+
2. as_tool() generates correct default tool name
7+
3. as_tool() uses custom description and name when provided
8+
4. as_tool() can be used in tools list
9+
"""
10+
11+
from unittest.mock import MagicMock
12+
13+
14+
class TestAgentAsTool:
15+
"""Tests for Agent.as_tool() method."""
16+
17+
def test_as_tool_returns_handoff(self):
18+
"""Test that as_tool() returns a Handoff instance."""
19+
from praisonaiagents.agent.handoff import Handoff, HandoffConfig, ContextPolicy
20+
21+
# Create a mock agent
22+
mock_agent = MagicMock()
23+
mock_agent.name = "TestAgent"
24+
mock_agent.role = "Test Role"
25+
mock_agent.goal = "Test Goal"
26+
27+
# Simulate as_tool behavior
28+
agent_name_snake = mock_agent.name.lower().replace(' ', '_').replace('-', '_')
29+
default_tool_name = f"invoke_{agent_name_snake}"
30+
31+
result = Handoff(
32+
agent=mock_agent,
33+
tool_name_override=default_tool_name,
34+
tool_description_override=f"Invoke {mock_agent.name} to complete a subtask and return the result",
35+
config=HandoffConfig(context_policy=ContextPolicy.NONE),
36+
)
37+
38+
assert isinstance(result, Handoff)
39+
assert result.config.context_policy == ContextPolicy.NONE
40+
assert result.tool_name == "invoke_testagent"
41+
42+
def test_as_tool_context_policy_none(self):
43+
"""Test that as_tool() uses ContextPolicy.NONE (no history passed)."""
44+
from praisonaiagents.agent.handoff import Handoff, HandoffConfig, ContextPolicy
45+
46+
mock_agent = MagicMock()
47+
mock_agent.name = "Researcher"
48+
49+
result = Handoff(
50+
agent=mock_agent,
51+
tool_name_override="invoke_researcher",
52+
tool_description_override="Research topics",
53+
config=HandoffConfig(context_policy=ContextPolicy.NONE),
54+
)
55+
56+
assert result.config.context_policy == ContextPolicy.NONE
57+
58+
def test_as_tool_default_tool_name(self):
59+
"""Test that as_tool() generates correct default tool name."""
60+
from praisonaiagents.agent.handoff import Handoff, HandoffConfig, ContextPolicy
61+
62+
# Test various agent names
63+
test_cases = [
64+
("Researcher", "invoke_researcher"),
65+
("Code Writer", "invoke_code_writer"),
66+
("data-analyst", "invoke_data_analyst"),
67+
("MyAgent123", "invoke_myagent123"),
68+
]
69+
70+
for agent_name, expected_tool_name in test_cases:
71+
mock_agent = MagicMock()
72+
mock_agent.name = agent_name
73+
74+
agent_name_snake = agent_name.lower().replace(' ', '_').replace('-', '_')
75+
tool_name = f"invoke_{agent_name_snake}"
76+
77+
result = Handoff(
78+
agent=mock_agent,
79+
tool_name_override=tool_name,
80+
tool_description_override="Test",
81+
config=HandoffConfig(context_policy=ContextPolicy.NONE),
82+
)
83+
84+
assert result.tool_name == expected_tool_name, f"Failed for {agent_name}"
85+
86+
def test_as_tool_custom_description(self):
87+
"""Test that as_tool() uses custom description when provided."""
88+
from praisonaiagents.agent.handoff import Handoff, HandoffConfig, ContextPolicy
89+
90+
mock_agent = MagicMock()
91+
mock_agent.name = "Coder"
92+
93+
custom_description = "Write Python code for any task"
94+
95+
result = Handoff(
96+
agent=mock_agent,
97+
tool_name_override="invoke_coder",
98+
tool_description_override=custom_description,
99+
config=HandoffConfig(context_policy=ContextPolicy.NONE),
100+
)
101+
102+
assert result.tool_description == custom_description
103+
104+
def test_as_tool_custom_name(self):
105+
"""Test that as_tool() uses custom tool name when provided."""
106+
from praisonaiagents.agent.handoff import Handoff, HandoffConfig, ContextPolicy
107+
108+
mock_agent = MagicMock()
109+
mock_agent.name = "Researcher"
110+
111+
custom_name = "research_topic"
112+
113+
result = Handoff(
114+
agent=mock_agent,
115+
tool_name_override=custom_name,
116+
tool_description_override="Research topics",
117+
config=HandoffConfig(context_policy=ContextPolicy.NONE),
118+
)
119+
120+
assert result.tool_name == custom_name
121+
122+
123+
class TestAgentAsToolIntegration:
124+
"""Integration tests for Agent.as_tool() with real Agent instances."""
125+
126+
def test_as_tool_on_real_agent(self):
127+
"""Test as_tool() on a real Agent instance (no LLM calls)."""
128+
from praisonaiagents import Agent
129+
from praisonaiagents.agent.handoff import Handoff, ContextPolicy
130+
131+
# Create a real agent (no LLM calls needed for this test)
132+
agent = Agent(
133+
name="TestResearcher",
134+
instructions="Research topics thoroughly",
135+
)
136+
137+
# Call as_tool()
138+
result = agent.as_tool("Research a topic and return findings")
139+
140+
# Verify result
141+
assert isinstance(result, Handoff)
142+
assert result.config.context_policy == ContextPolicy.NONE
143+
assert result.tool_name == "invoke_testresearcher"
144+
assert "Research a topic" in result.tool_description
145+
146+
def test_as_tool_with_custom_params(self):
147+
"""Test as_tool() with custom tool name and description."""
148+
from praisonaiagents import Agent
149+
from praisonaiagents.agent.handoff import Handoff, ContextPolicy
150+
151+
agent = Agent(
152+
name="Coder",
153+
instructions="Write clean Python code",
154+
)
155+
156+
result = agent.as_tool(
157+
description="Generate Python code for any programming task",
158+
tool_name="generate_code",
159+
)
160+
161+
assert isinstance(result, Handoff)
162+
assert result.tool_name == "generate_code"
163+
assert "Generate Python code" in result.tool_description
164+
165+
def test_as_tool_in_tools_list(self):
166+
"""Test that as_tool() result can be added to another agent's tools."""
167+
from praisonaiagents import Agent
168+
from praisonaiagents.agent.handoff import Handoff
169+
170+
# Create specialist agents
171+
researcher = Agent(name="Researcher", instructions="Research topics")
172+
coder = Agent(name="Coder", instructions="Write code")
173+
174+
# Create parent agent with specialists as tools
175+
writer = Agent(
176+
name="Writer",
177+
instructions="Write articles using your tools",
178+
tools=[
179+
researcher.as_tool("Research a topic"),
180+
coder.as_tool("Write Python code"),
181+
],
182+
)
183+
184+
# Verify tools were added (they get converted to tool functions)
185+
# The handoffs should be processed into the tools list
186+
assert writer.name == "Writer"
187+
# Note: Handoffs are processed in _process_handoffs, so we check the agent was created

0 commit comments

Comments
 (0)