aap-core 0.1.1.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aap_core-0.1.1.dev1/PKG-INFO +14 -0
- aap_core-0.1.1.dev1/README.md +0 -0
- aap_core-0.1.1.dev1/pyproject.toml +25 -0
- aap_core-0.1.1.dev1/src/aap_core/__init__.py +0 -0
- aap_core-0.1.1.dev1/src/aap_core/agent.py +150 -0
- aap_core-0.1.1.dev1/src/aap_core/chain.py +148 -0
- aap_core-0.1.1.dev1/src/aap_core/guardrail.py +14 -0
- aap_core-0.1.1.dev1/src/aap_core/orchestration.py +597 -0
- aap_core-0.1.1.dev1/src/aap_core/prompt_enhancer.py +209 -0
- aap_core-0.1.1.dev1/src/aap_core/py.typed +0 -0
- aap_core-0.1.1.dev1/src/aap_core/types.py +106 -0
- aap_core-0.1.1.dev1/src/aap_core/utils.py +16 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: aap-core
|
|
3
|
+
Version: 0.1.1.dev1
|
|
4
|
+
Summary: Agent design pattern core framework
|
|
5
|
+
Author: Ly Hon Quang
|
|
6
|
+
Author-email: Ly Hon Quang <lyhonquang@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: a2a-sdk>=0.3.12
|
|
9
|
+
Requires-Dist: pandas>=2.0.0
|
|
10
|
+
Requires-Dist: rouge-score>=0.1.2
|
|
11
|
+
Requires-Dist: sacrebleu>=2.5.1
|
|
12
|
+
Requires-Python: >=3.10.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aap_core"
|
|
3
|
+
version = "0.1.1.dev1"
|
|
4
|
+
description = "Agent design pattern core framework"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Ly Hon Quang", email = "lyhonquang@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.10.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"a2a-sdk>=0.3.12",
|
|
13
|
+
"pandas>=2.0.0",
|
|
14
|
+
"rouge-score>=0.1.2",
|
|
15
|
+
"sacrebleu>=2.5.1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
lint = [
|
|
20
|
+
"ruff==0.14.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.8.14,<0.9.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
File without changes
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from collections.abc import Callable, Sequence
|
|
3
|
+
from typing import Any, Dict, Literal
|
|
4
|
+
|
|
5
|
+
from a2a.types import AgentCard
|
|
6
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
7
|
+
|
|
8
|
+
from .chain import BaseLLMChain
|
|
9
|
+
from .types import AgentMessage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseAgent(abc.ABC, BaseModel):
|
|
13
|
+
"""An interface for agent implementations."""
|
|
14
|
+
|
|
15
|
+
_state: str = PrivateAttr("idle")
|
|
16
|
+
_composed_state: str = PrivateAttr("idle")
|
|
17
|
+
|
|
18
|
+
card: AgentCard = Field(
|
|
19
|
+
...,
|
|
20
|
+
description="""
|
|
21
|
+
A self-describing manifest for an agent.
|
|
22
|
+
It provides essential metadata including the agent's identity, capabilities, skills, supported communication methods, and security requirements.""",
|
|
23
|
+
)
|
|
24
|
+
state_change_callback: Callable[[str], None] | None = Field(
|
|
25
|
+
default=None,
|
|
26
|
+
description="The callback function to update the state of the agent.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def __init__(self, **kwargs):
|
|
30
|
+
super().__init__(**kwargs)
|
|
31
|
+
self._set_composed_state()
|
|
32
|
+
for _, value in self:
|
|
33
|
+
self.__value_deco(value)
|
|
34
|
+
|
|
35
|
+
@abc.abstractmethod
|
|
36
|
+
def execute(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
37
|
+
"""The execution logic of the agent.
|
|
38
|
+
Depends on the detail implementation by subclass, agent can calls tools or RAG or MCP during execution.
|
|
39
|
+
And agent may or may not utilize LLM, depends on the implementation. A lot of agents are pure orchestration without LLM in it.
|
|
40
|
+
During execution, the implementation should update the state of the agent
|
|
41
|
+
and/or push notification to target to update the progress.
|
|
42
|
+
There are 3 types of agent:
|
|
43
|
+
- Remote agent: agent that is running on other server.
|
|
44
|
+
Example: For Google's A2A, we will act as a client agent and communicate with agent using A2A protocol and getback result
|
|
45
|
+
- Local agent: self-defined agent that is running on a this machine.
|
|
46
|
+
- Orchestration agent: itself is a agent, which can perform what normal agent can do, and orchestrate other agents to complete the provided task.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
message (AgentMessage): The message to execute the agent with.
|
|
50
|
+
**kwargs: Additional keyword arguments to pass to the LLM generation method.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
AgentMessage: The response from the agent.
|
|
54
|
+
"""
|
|
55
|
+
return message
|
|
56
|
+
|
|
57
|
+
async def aexecute(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
58
|
+
"""The asynchronous version of method execute."""
|
|
59
|
+
return self.execute(message, **kwargs)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def state(self) -> str:
|
|
63
|
+
"""Get the current state of the agent."""
|
|
64
|
+
return self._state
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def composed_state(self) -> str:
|
|
68
|
+
"""Get the current whole state of the agent hierarchy."""
|
|
69
|
+
return self._composed_state
|
|
70
|
+
|
|
71
|
+
def _set_composed_state(self) -> None:
|
|
72
|
+
# A subchild class that have child agent in their implementation may need to override this
|
|
73
|
+
self._composed_state = BaseAgent.build_composed_state(self, [], "sequential")
|
|
74
|
+
|
|
75
|
+
def _sync_state(self, state: str = "") -> None:
|
|
76
|
+
self._set_composed_state()
|
|
77
|
+
if self.state_change_callback:
|
|
78
|
+
self.state_change_callback(self._composed_state)
|
|
79
|
+
|
|
80
|
+
@state.setter
|
|
81
|
+
def state(self, value: str) -> None:
|
|
82
|
+
self._state = value
|
|
83
|
+
self._sync_state()
|
|
84
|
+
|
|
85
|
+
def __value_deco(self, value: Any):
|
|
86
|
+
if isinstance(value, str):
|
|
87
|
+
pass
|
|
88
|
+
elif isinstance(value, BaseAgent):
|
|
89
|
+
value.state_change_callback = self._sync_state
|
|
90
|
+
elif isinstance(
|
|
91
|
+
value, BaseLLMChain
|
|
92
|
+
): # force name of the chain is the same as the agent
|
|
93
|
+
value.name = self.card.name
|
|
94
|
+
elif isinstance(value, Sequence):
|
|
95
|
+
for agent in value:
|
|
96
|
+
if isinstance(agent, BaseAgent):
|
|
97
|
+
agent.state_change_callback = self._sync_state
|
|
98
|
+
elif isinstance(value, BaseLLMChain):
|
|
99
|
+
value.name = self.card.name
|
|
100
|
+
elif isinstance(value, Dict):
|
|
101
|
+
for agent in value.values():
|
|
102
|
+
if isinstance(agent, BaseAgent):
|
|
103
|
+
agent.state_change_callback = self._sync_state
|
|
104
|
+
elif isinstance(value, BaseLLMChain):
|
|
105
|
+
value.name = self.card.name
|
|
106
|
+
|
|
107
|
+
def __setattr__(self, name: str, value: Any):
|
|
108
|
+
self.__value_deco(value)
|
|
109
|
+
super().__setattr__(name, value)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def build_composed_state(
|
|
113
|
+
cls,
|
|
114
|
+
parent_agent: "BaseAgent",
|
|
115
|
+
child_agents: Sequence["BaseAgent"],
|
|
116
|
+
connect_type: Literal["sequential", "parallel"],
|
|
117
|
+
) -> str:
|
|
118
|
+
"""
|
|
119
|
+
Build a composed state string for the given agent and its children.
|
|
120
|
+
The composition rule is following:
|
|
121
|
+
- The order of the agent hierarchy is topdown.
|
|
122
|
+
- A state of a agent is with format: <agent_name>:<agent_state>
|
|
123
|
+
- The level separator is "/". For example: <parent_agent>:<parent_state>/<child_agent1>:<child_state1>
|
|
124
|
+
- If a child is made up of multiple agents, there are 2 type of connections which currently support are "sequential" and "parallel".
|
|
125
|
+
+ "sequential": the children are connected with "-" separator and encapsulate with bracket, for example: <parent_agent>:<parent_state>/((<child_agent1>:<child_state1>)-(<child_agent2>:<child_state2>))
|
|
126
|
+
+ "parallel": the children are connected with "|" separator and encapsulate with bracket, for example: <parent_agent>:<parent_state>/((<child_agent1>:<child_state1>)|(<child_agent2>:<child_state2>))
|
|
127
|
+
- Lower level of hierarchy will be constructed the same way to form a complete tree of agent state.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
parent_agent: The parent agent.
|
|
131
|
+
child_agents: A list of child agents. This list is a direct child only. The function will use the composed_state of the direct child to form the tree. The composed state of the child is assumed to be already built by the child before.
|
|
132
|
+
connect_type: The type of connection between the parent and children, either "sequential" or "parallel".
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
str: The composed state string.
|
|
136
|
+
"""
|
|
137
|
+
self_state = f"{parent_agent.card.name}:{parent_agent.state}"
|
|
138
|
+
child_states = [child_agent.composed_state for child_agent in child_agents]
|
|
139
|
+
if len(child_states) == 0:
|
|
140
|
+
return self_state
|
|
141
|
+
elif len(child_states) == 1:
|
|
142
|
+
return f"{self_state}/{child_states[0]}"
|
|
143
|
+
else:
|
|
144
|
+
childs = [f"({state})" for state in child_states]
|
|
145
|
+
if connect_type == "sequential":
|
|
146
|
+
return f"{self_state}/({'-'.join(childs)})"
|
|
147
|
+
elif connect_type == "parallel":
|
|
148
|
+
return f"{self_state}/({'|'.join(childs)})"
|
|
149
|
+
else:
|
|
150
|
+
raise ValueError(f"Invalid connect_type: {connect_type}")
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from typing import Callable, Generic, List, Tuple
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, PrivateAttr
|
|
6
|
+
|
|
7
|
+
from .guardrail import BaseGuardRail, PassGuardRail
|
|
8
|
+
from .prompt_enhancer import (
|
|
9
|
+
BasePromptEnhancer,
|
|
10
|
+
IdentityPromptEnhancer,
|
|
11
|
+
)
|
|
12
|
+
from .types import AgentMessage, BaseChain, ChainMessage, ChainResponse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseLLMChain(BaseChain):
|
|
16
|
+
"""
|
|
17
|
+
Base class for LLM chains.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str = Field(
|
|
21
|
+
"chain",
|
|
22
|
+
description="The name of the chain. Should be same at agent who hold this chain for easy to operate.",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@abc.abstractmethod
|
|
26
|
+
def invoke(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
async def ainvoke(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
30
|
+
return self.invoke(message, **kwargs)
|
|
31
|
+
|
|
32
|
+
def __call__(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
33
|
+
return self.invoke(message, **kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BaseCausalMultiTurnsChain(BaseLLMChain, Generic[ChainMessage, ChainResponse]):
|
|
37
|
+
"""
|
|
38
|
+
Base class for multi-turns chains.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
_last_response_as_context: str | None = PrivateAttr(default=None)
|
|
42
|
+
include_history: int = Field(
|
|
43
|
+
default=0, description="number of history message turns to include"
|
|
44
|
+
)
|
|
45
|
+
store_immediate_steps: bool = Field(
|
|
46
|
+
default=False, description="store intermediate steps output"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def final_response_as_context(self, key: str) -> None:
|
|
50
|
+
self._last_response_as_context = key.replace("context_", "")
|
|
51
|
+
|
|
52
|
+
@abc.abstractmethod
|
|
53
|
+
def _prepare_conversation(self, message: AgentMessage) -> List[ChainMessage]:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@abc.abstractmethod
|
|
57
|
+
def _generate_response(
|
|
58
|
+
self, conversation: List[ChainMessage], **kwargs
|
|
59
|
+
) -> Tuple[List[ChainMessage], ChainResponse, bool]:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@abc.abstractmethod
|
|
63
|
+
def _process_tools(
|
|
64
|
+
self,
|
|
65
|
+
conversation: List[ChainMessage],
|
|
66
|
+
response: ChainResponse,
|
|
67
|
+
) -> List[ChainMessage]:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
@abc.abstractmethod
|
|
71
|
+
def _append_responses(
|
|
72
|
+
self, message: AgentMessage, conversation: List[ChainMessage]
|
|
73
|
+
) -> AgentMessage:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def invoke(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
77
|
+
# Template method
|
|
78
|
+
conversation = self._prepare_conversation(message)
|
|
79
|
+
conversation, response, has_tool = self._generate_response(
|
|
80
|
+
conversation, **kwargs
|
|
81
|
+
)
|
|
82
|
+
if has_tool:
|
|
83
|
+
conversation = self._process_tools(conversation, response)
|
|
84
|
+
conversation, _, _ = self._generate_response(conversation, **kwargs)
|
|
85
|
+
|
|
86
|
+
message = self._append_responses(message, conversation)
|
|
87
|
+
|
|
88
|
+
if self._last_response_as_context is not None:
|
|
89
|
+
if message.context is None:
|
|
90
|
+
message.context = {}
|
|
91
|
+
message.context[self._last_response_as_context] = message.responses.pop()[1]
|
|
92
|
+
|
|
93
|
+
message.origin = self.name
|
|
94
|
+
message.execution_result = "success"
|
|
95
|
+
return message
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TypicalLLMChain(BaseLLMChain):
|
|
99
|
+
"""
|
|
100
|
+
A LLM chain that consists of all components in a typical response generation.
|
|
101
|
+
The chain have 4 steps:
|
|
102
|
+
- input guardrail: validate the input message
|
|
103
|
+
- prompt enhancement: add more context to the prompt or rewrite / refine the prompt
|
|
104
|
+
- generate: generate the response. This steps should also handle the tool calling procedure and finalize the response
|
|
105
|
+
- output guardrail: validate the output message"""
|
|
106
|
+
|
|
107
|
+
input_guardrail: BaseGuardRail = Field(
|
|
108
|
+
default=PassGuardRail(),
|
|
109
|
+
description="validate the input message",
|
|
110
|
+
)
|
|
111
|
+
prompt_enhancer: BasePromptEnhancer = Field(
|
|
112
|
+
default=IdentityPromptEnhancer(),
|
|
113
|
+
description="add more context to the prompt or rewrite / refine the prompt",
|
|
114
|
+
)
|
|
115
|
+
tools: Sequence[Callable] = Field(
|
|
116
|
+
default=[],
|
|
117
|
+
description="tools to call for the LLM",
|
|
118
|
+
)
|
|
119
|
+
output_guardrail: BaseGuardRail = Field(
|
|
120
|
+
default=PassGuardRail(),
|
|
121
|
+
description="validate the output message",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@abc.abstractmethod
|
|
125
|
+
def generate(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
126
|
+
# call tool if any and finalize response
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
def invoke(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
130
|
+
message = self.input_guardrail(message)
|
|
131
|
+
message = self.prompt_enhancer(message)
|
|
132
|
+
message = self.generate(message, **kwargs)
|
|
133
|
+
message = self.output_guardrail(message)
|
|
134
|
+
return message
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class LLMPromptEnhancer(BasePromptEnhancer):
|
|
138
|
+
"""A prompt enhancer that use an LLM chain to rewrite the prompt."""
|
|
139
|
+
|
|
140
|
+
chain: BaseLLMChain = Field(..., description="LLM chain that rewrite the prompt")
|
|
141
|
+
|
|
142
|
+
def __call__(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
143
|
+
message = self.chain.invoke(message, **kwargs)
|
|
144
|
+
if message.execution_result != "success":
|
|
145
|
+
return message
|
|
146
|
+
_, message.query = message.responses[-1]
|
|
147
|
+
message.responses = []
|
|
148
|
+
return message
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
|
|
3
|
+
from .types import AgentMessage, BaseChain
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseGuardRail(BaseChain):
|
|
7
|
+
@abc.abstractmethod
|
|
8
|
+
def __call__(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PassGuardRail(BaseGuardRail):
|
|
13
|
+
def __call__(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
14
|
+
return message
|
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
import random
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Generator, Iterator, Sequence
|
|
5
|
+
from typing import Callable, List, Literal, Tuple
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, field_validator
|
|
8
|
+
from rouge_score import rouge_scorer
|
|
9
|
+
from sacrebleu import sentence_bleu
|
|
10
|
+
|
|
11
|
+
from aap_core.types import AgentResponse
|
|
12
|
+
|
|
13
|
+
from .agent import AgentMessage, BaseAgent
|
|
14
|
+
from .chain import BaseLLMChain
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReflectionAgent(BaseAgent):
|
|
18
|
+
"""
|
|
19
|
+
An agent that is capable of self-reflection.
|
|
20
|
+
|
|
21
|
+
This agent will first execute the given message and then reflect on the
|
|
22
|
+
result. If the reflection is successful, the agent will return a new
|
|
23
|
+
message with the reflection result. If the reflection is not successful,
|
|
24
|
+
the agent will return the original message.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
chain: The BaseLLMChain to use for executing the message.
|
|
28
|
+
chain_reflection: The prompt to use for self-reflection.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
chain_task: BaseLLMChain = Field(
|
|
32
|
+
..., description="LLM chain that perform the main task"
|
|
33
|
+
)
|
|
34
|
+
chain_reflection: BaseLLMChain = Field(
|
|
35
|
+
...,
|
|
36
|
+
description="LLM chain that perform reflection on the result of the main task",
|
|
37
|
+
)
|
|
38
|
+
task_response_key: str = Field(
|
|
39
|
+
default="context_response",
|
|
40
|
+
description="""The key of which to store the context (e.g. intermediate result) during generation.
|
|
41
|
+
Note that the key need to start with 'context_'""",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@field_validator("task_response_key")
|
|
45
|
+
@classmethod
|
|
46
|
+
def check_starts_with_prefix(cls, v: str) -> str:
|
|
47
|
+
if not v.startswith("context_"):
|
|
48
|
+
raise ValueError("task_response_key must start with 'context_'")
|
|
49
|
+
return v
|
|
50
|
+
|
|
51
|
+
def execute(
|
|
52
|
+
self, message: AgentMessage, keep_original_response: bool = True, **kwargs
|
|
53
|
+
) -> AgentMessage:
|
|
54
|
+
self.state = "running"
|
|
55
|
+
message = self.chain_task.invoke(message, **kwargs)
|
|
56
|
+
message.execution_result = message.execution_result or "error"
|
|
57
|
+
if message.execution_result != "success":
|
|
58
|
+
message.error_message = (
|
|
59
|
+
"Call chain not success. Received: " + message.execution_result
|
|
60
|
+
)
|
|
61
|
+
return message
|
|
62
|
+
|
|
63
|
+
self.state = "reflecting"
|
|
64
|
+
task_response_key = self.task_response_key.replace("context_", "")
|
|
65
|
+
reflect_message = message.model_copy(deep=True)
|
|
66
|
+
reflect_message.context = {}
|
|
67
|
+
_, reflect_message.context[task_response_key] = reflect_message.responses[-1] # type: ignore
|
|
68
|
+
reflect_message.responses = []
|
|
69
|
+
result_message = self.chain_reflection.invoke(reflect_message, **kwargs)
|
|
70
|
+
self.state = "idle"
|
|
71
|
+
result_message.origin = self.card.name
|
|
72
|
+
if result_message.execution_result == "success" and keep_original_response:
|
|
73
|
+
result_message.responses.insert(-1, message.responses[-1])
|
|
74
|
+
|
|
75
|
+
return result_message
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LoopAgent(BaseAgent):
|
|
79
|
+
"""
|
|
80
|
+
An agent that will loop until a certain condition is met.
|
|
81
|
+
|
|
82
|
+
This agent takes two parameters: an agent to loop and a condition to stop the loop.
|
|
83
|
+
The condition to stop the loop should be a function that takes an AgentMessage as an argument
|
|
84
|
+
and returns a boolean indicating whether the loop should stop or not.
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
def is_stop(message: AgentMessage) -> bool:
|
|
88
|
+
# stop when the message query is "stop"
|
|
89
|
+
return message.query == "stop"
|
|
90
|
+
|
|
91
|
+
agent = LoopAgent(
|
|
92
|
+
agent=Agent1(),
|
|
93
|
+
is_stop=is_stop
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
message = AgentMessage(query="hello")
|
|
97
|
+
result = agent.execute(message)
|
|
98
|
+
|
|
99
|
+
Attributes:
|
|
100
|
+
agent: The agent to loop.
|
|
101
|
+
is_stop: The condition to stop the loop.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
agent: BaseAgent = Field(..., description="The agent to loop.")
|
|
105
|
+
is_stop: Callable[[AgentMessage], bool] | Generator[bool] = Field(
|
|
106
|
+
...,
|
|
107
|
+
description="The condition to stop the loop. It should be a function that takes an AgentMessage as an argument and returns a boolean.",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def execute(
|
|
111
|
+
self,
|
|
112
|
+
message: AgentMessage,
|
|
113
|
+
keep_result: int | Callable[[Sequence[AgentResponse]], List[AgentResponse]] = 1,
|
|
114
|
+
**kwargs,
|
|
115
|
+
) -> AgentMessage:
|
|
116
|
+
self.state = "running"
|
|
117
|
+
origin_len = len(message.responses)
|
|
118
|
+
responses = message.model_copy(deep=True)
|
|
119
|
+
|
|
120
|
+
def update_responses():
|
|
121
|
+
if isinstance(keep_result, int) and keep_result > 0:
|
|
122
|
+
responses.responses = responses.responses[-keep_result - origin_len :]
|
|
123
|
+
elif isinstance(keep_result, Callable):
|
|
124
|
+
responses.responses = keep_result(responses.responses)
|
|
125
|
+
|
|
126
|
+
i = 0
|
|
127
|
+
if isinstance(self.is_stop, Callable):
|
|
128
|
+
while self.is_stop(message) is False:
|
|
129
|
+
self.state = "running#" + str(i)
|
|
130
|
+
i += 1
|
|
131
|
+
message = self.agent.execute(message, **kwargs)
|
|
132
|
+
if message.execution_result != "success":
|
|
133
|
+
break
|
|
134
|
+
else:
|
|
135
|
+
responses.responses.append(message.responses.pop())
|
|
136
|
+
update_responses()
|
|
137
|
+
elif isinstance(self.is_stop, Iterator):
|
|
138
|
+
try:
|
|
139
|
+
while next(self.is_stop) is False:
|
|
140
|
+
self.state = "running#" + str(i)
|
|
141
|
+
i += 1
|
|
142
|
+
message = self.agent.execute(message, **kwargs)
|
|
143
|
+
if message.execution_result != "success":
|
|
144
|
+
break
|
|
145
|
+
else:
|
|
146
|
+
responses.responses.append(message.responses.pop())
|
|
147
|
+
update_responses()
|
|
148
|
+
except StopIteration:
|
|
149
|
+
pass
|
|
150
|
+
else:
|
|
151
|
+
raise TypeError(
|
|
152
|
+
f"is_stop must be a callable or a Generator, received {type(self.is_stop)}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
self.state = "idle"
|
|
156
|
+
responses.origin = self.card.name
|
|
157
|
+
return responses
|
|
158
|
+
|
|
159
|
+
def _set_composed_state(self) -> None:
|
|
160
|
+
self._composed_state = BaseAgent.build_composed_state(
|
|
161
|
+
self, [self.agent], "sequential"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class SequentialAgent(BaseAgent):
|
|
166
|
+
"""
|
|
167
|
+
A sequential agent is an agent that will execute a list of agents in a particular order.
|
|
168
|
+
|
|
169
|
+
The order of the agents is the order of the execution. This means that the first agent will be executed first, then the second agent and so on.
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
agent1 = Agent1()
|
|
173
|
+
agent2 = Agent2()
|
|
174
|
+
|
|
175
|
+
sequential_agent = SequentialAgent([agent1, agent2])
|
|
176
|
+
|
|
177
|
+
message = AgentMessage(query="hello")
|
|
178
|
+
result = sequential_agent.execute(message)
|
|
179
|
+
|
|
180
|
+
Attributes:
|
|
181
|
+
agents: The list of agents to execute in sequence.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
agents: Sequence[BaseAgent] = Field(
|
|
185
|
+
...,
|
|
186
|
+
description="""The list of agents to execute in sequence.
|
|
187
|
+
The order of the sequence is the order of the execution.""",
|
|
188
|
+
min_length=1,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def execute(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
192
|
+
self.state = "running"
|
|
193
|
+
for agent in self.agents:
|
|
194
|
+
message = agent.execute(message, **kwargs)
|
|
195
|
+
if message.execution_result != "success":
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
self.state = "idle"
|
|
199
|
+
message.origin = self.card.name
|
|
200
|
+
return message
|
|
201
|
+
|
|
202
|
+
def _set_composed_state(self) -> None:
|
|
203
|
+
self._composed_state = BaseAgent.build_composed_state(
|
|
204
|
+
self, self.agents, "sequential"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class ParallelAgent(BaseAgent):
|
|
209
|
+
"""
|
|
210
|
+
A parallel agent is an agent that will execute a list of agents in parallel.
|
|
211
|
+
The agent will return a list of AgentMessage where each message is the result of the corresponding agent in the list.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
agent1 = Agent1()
|
|
215
|
+
agent2 = Agent2()
|
|
216
|
+
|
|
217
|
+
parallel_agent = ParallelAgent([agent1, agent2])
|
|
218
|
+
|
|
219
|
+
messages = [AgentMessage(query="hello"), AgentMessage(query="world")]
|
|
220
|
+
results = parallel_agent.execute(messages)
|
|
221
|
+
|
|
222
|
+
Attributes:
|
|
223
|
+
agents: The list of agents to execute in parallel.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
agents: Sequence[BaseAgent] = Field(
|
|
227
|
+
..., description="""The list of agents to execute in parallel""", min_length=1
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def execute( # type: ignore
|
|
231
|
+
self, messages: AgentMessage | Sequence[AgentMessage], **kwargs
|
|
232
|
+
) -> AgentMessage:
|
|
233
|
+
# If received a single message, all agents will process the same message
|
|
234
|
+
if isinstance(messages, AgentMessage):
|
|
235
|
+
messages = [messages.model_copy(deep=True)] * len(self.agents)
|
|
236
|
+
elif len(messages) != len(self.agents):
|
|
237
|
+
raise ValueError(
|
|
238
|
+
"messages must be a list of AgentMessage with the same length as the number of agents"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
self.state = "running"
|
|
242
|
+
# if torch is used, we can use torch.multiprocessing
|
|
243
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
244
|
+
futures = [
|
|
245
|
+
executor.submit(agent.execute, message, **kwargs)
|
|
246
|
+
for agent, message in zip(self.agents, messages)
|
|
247
|
+
]
|
|
248
|
+
responses = [
|
|
249
|
+
future.result() for future in concurrent.futures.as_completed(futures)
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
result_message = AgentMessage(
|
|
253
|
+
query="",
|
|
254
|
+
origin=self.card.name,
|
|
255
|
+
responses=[message.responses[-1] for message in responses],
|
|
256
|
+
execution_result="success",
|
|
257
|
+
) # type: ignore
|
|
258
|
+
self.state = "idle"
|
|
259
|
+
return result_message
|
|
260
|
+
|
|
261
|
+
def _set_composed_state(self) -> None:
|
|
262
|
+
self._composed_state = BaseAgent.build_composed_state(
|
|
263
|
+
self, self.agents, "parallel"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class CoordinatorAgent(BaseAgent):
|
|
268
|
+
"""
|
|
269
|
+
This coordinator agent compose of 3 stages:
|
|
270
|
+
- Planning: a planner agent will plan the steps to be executed.
|
|
271
|
+
The planner recieves input query and returns a list of steps to be executed.
|
|
272
|
+
For each step, it contains 3 fields:
|
|
273
|
+
+ worker: the worker agent that will execute the step
|
|
274
|
+
+ message: the instruction for the worker agent
|
|
275
|
+
+ dependencies: the dependencies of the step, this is picked from the result of the previous steps. The planner is responsible for choosing which results are needed for current step
|
|
276
|
+
- Execution: For each step, an assigned worker will execute the step. In current version, steps are performed sequentially.
|
|
277
|
+
- Summary: a summary agent will summarize the results so far and generate final answer.
|
|
278
|
+
The summary agent recieves all results and write a final answer.
|
|
279
|
+
This stage is optional.
|
|
280
|
+
|
|
281
|
+
Example:
|
|
282
|
+
planner_agent = PlannerAgent()
|
|
283
|
+
worker_agent = WorkerAgent()
|
|
284
|
+
summary_agent = SummaryAgent()
|
|
285
|
+
|
|
286
|
+
coordinator_agent = CoordinatorAgent(planner_agent, worker_agent, summary_agent)
|
|
287
|
+
|
|
288
|
+
message = AgentMessage(query="hello")
|
|
289
|
+
result = coordinator_agent.execute(message)
|
|
290
|
+
|
|
291
|
+
Attributes:
|
|
292
|
+
planner_agent: The agent that will plan the steps to be executed.
|
|
293
|
+
workers: The list of agents that will execute the steps.
|
|
294
|
+
summary_agent: The agent that will summarize the results so far and generate final answer.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
planner_agent: BaseAgent = Field(
|
|
298
|
+
..., description="""The agent that will plan the steps to be executed"""
|
|
299
|
+
)
|
|
300
|
+
parse_plan: Callable[
|
|
301
|
+
[AgentMessage, Sequence[BaseAgent]],
|
|
302
|
+
Sequence[Tuple[AgentMessage, BaseAgent, List]],
|
|
303
|
+
] = Field(
|
|
304
|
+
...,
|
|
305
|
+
description="""The function that will parse the plan and return the list of steps to be executed.""",
|
|
306
|
+
)
|
|
307
|
+
workers: Sequence[BaseAgent] = Field(
|
|
308
|
+
...,
|
|
309
|
+
description="""The list of agents that will execute the steps based on the plan""",
|
|
310
|
+
min_length=1,
|
|
311
|
+
)
|
|
312
|
+
summary_chain: BaseLLMChain | None = Field(
|
|
313
|
+
default=None,
|
|
314
|
+
description="""The summary chain that will summarize the results so far and generate final answer""",
|
|
315
|
+
)
|
|
316
|
+
summary_prompt: str | None = Field(
|
|
317
|
+
default=None,
|
|
318
|
+
description="""The prompt that will be used by the summary chain""",
|
|
319
|
+
)
|
|
320
|
+
summary_steps_key: str = Field(
|
|
321
|
+
"context_results",
|
|
322
|
+
description="""The key of which to store the context (e.g. intermediate result) during generation.
|
|
323
|
+
Note that the key need to start with 'context_'""",
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
@field_validator("summary_steps_key")
|
|
327
|
+
@classmethod
|
|
328
|
+
def check_starts_with_prefix(cls, v: str) -> str:
|
|
329
|
+
if not v.startswith("context_"):
|
|
330
|
+
raise ValueError("summary_steps_key must start with 'context_'")
|
|
331
|
+
return v
|
|
332
|
+
|
|
333
|
+
def execute(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
334
|
+
self.state = "planning"
|
|
335
|
+
# TODO: abstract structured output
|
|
336
|
+
message = self.planner_agent.execute(message, **kwargs)
|
|
337
|
+
|
|
338
|
+
if message.execution_result != "success":
|
|
339
|
+
return message
|
|
340
|
+
|
|
341
|
+
steps = self.parse_plan(message, self.workers)
|
|
342
|
+
sub_task_result = []
|
|
343
|
+
# TODO: check step dependency and parallelize steps to speed up
|
|
344
|
+
summary_steps_key = self.summary_steps_key.replace("context_", "")
|
|
345
|
+
for i, (step_msg, step_agent, dependencies) in enumerate(steps):
|
|
346
|
+
step_msg.context = {summary_steps_key: sub_task_result}
|
|
347
|
+
self.state = f"step {i}: worker {step_agent.card.name} running"
|
|
348
|
+
sub_task_result.append(step_agent.execute(step_msg, **kwargs))
|
|
349
|
+
|
|
350
|
+
if self.summary_chain is None:
|
|
351
|
+
if self.summary_prompt is not None:
|
|
352
|
+
# Use coordinator as summary agent
|
|
353
|
+
self.state = f"{self.planner_agent.card.name} finalizing"
|
|
354
|
+
summary_message = AgentMessage(
|
|
355
|
+
query=self.summary_prompt,
|
|
356
|
+
context={summary_steps_key: sub_task_result},
|
|
357
|
+
responses=[],
|
|
358
|
+
) # type: ignore
|
|
359
|
+
summary_message = self.planner_agent.execute(summary_message, **kwargs)
|
|
360
|
+
else:
|
|
361
|
+
return message
|
|
362
|
+
# if no summary prompt is provided, just return the result from workers
|
|
363
|
+
else:
|
|
364
|
+
self.state = "finalizing"
|
|
365
|
+
summary_message = AgentMessage(
|
|
366
|
+
query=message.query,
|
|
367
|
+
context={summary_steps_key: sub_task_result},
|
|
368
|
+
responses=[],
|
|
369
|
+
) # type: ignore
|
|
370
|
+
summary_message = self.summary_chain.invoke(summary_message, **kwargs)
|
|
371
|
+
|
|
372
|
+
self.state = "idle"
|
|
373
|
+
summary_message.origin = self.card.name
|
|
374
|
+
return summary_message
|
|
375
|
+
|
|
376
|
+
def _set_composed_state(self) -> None:
|
|
377
|
+
self._composed_state = BaseAgent.build_composed_state(
|
|
378
|
+
self, [self.planner_agent, *self.workers], "sequential"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class DebateAgent(BaseAgent):
|
|
383
|
+
"""
|
|
384
|
+
Multiple agents participate in a discussion to solve a problem.
|
|
385
|
+
The DebateAgent is designed to facilitate a discussion between multiple agents.
|
|
386
|
+
It acts like a conversation manager who will decide the conversation flow
|
|
387
|
+
|
|
388
|
+
Note that for the expandability, this agent is reponsible only for expanding the discussing topic, not the summary one.
|
|
389
|
+
For the complete flow that performs topic rollout and finalize the solution, it is necessary to combine with other agent.
|
|
390
|
+
There are several ways to achieve this: either by define a new agent that coodinate the actions or chain multiple agents together.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
agents: Sequence[BaseAgent] = Field(
|
|
394
|
+
...,
|
|
395
|
+
description="""The list of agents participate in the conversation""",
|
|
396
|
+
min_length=1,
|
|
397
|
+
)
|
|
398
|
+
pick_strategy: (
|
|
399
|
+
Literal["round_robin", "random", "simultaneous"]
|
|
400
|
+
| Callable[[Sequence[BaseAgent]], BaseAgent]
|
|
401
|
+
) = Field(..., description="The strategy to pick the agent to run the next turn")
|
|
402
|
+
random_seed: int | None = Field(
|
|
403
|
+
default=None, description="The random seed for the 'random' pick strategy"
|
|
404
|
+
)
|
|
405
|
+
max_turns: int = Field(
|
|
406
|
+
5,
|
|
407
|
+
description="""The maximum number of debate turns to run.
|
|
408
|
+
Note: for the simultaneous strategy, the actual number of turn will be factor of the number of agents""",
|
|
409
|
+
ge=1,
|
|
410
|
+
)
|
|
411
|
+
should_stop: Callable[[AgentMessage], bool] | None = Field(
|
|
412
|
+
default=None, description="The condition to stop the debate"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def execute(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
416
|
+
n = 0
|
|
417
|
+
if self.pick_strategy == "random" and self.random_seed is not None:
|
|
418
|
+
random.seed(self.random_seed)
|
|
419
|
+
|
|
420
|
+
# All response turns are stored in the message.responses
|
|
421
|
+
while n < self.max_turns:
|
|
422
|
+
if self.pick_strategy == "round_robin":
|
|
423
|
+
next_turn_agent = self.agents[n % len(self.agents)]
|
|
424
|
+
elif self.pick_strategy == "random":
|
|
425
|
+
next_turn_agent = random.choice(self.agents)
|
|
426
|
+
elif isinstance(self.pick_strategy, Callable):
|
|
427
|
+
next_turn_agent = self.pick_strategy(self.agents)
|
|
428
|
+
elif self.pick_strategy == "simultaneous":
|
|
429
|
+
self.state = f"turn {n}: all agents running"
|
|
430
|
+
# TODO: parallelize the execution
|
|
431
|
+
current_turn_messages = [
|
|
432
|
+
agent.execute(message.model_copy(deep=True), **kwargs)
|
|
433
|
+
for agent in self.agents
|
|
434
|
+
]
|
|
435
|
+
message.responses.extend(
|
|
436
|
+
[msg.responses[-1] for msg in current_turn_messages]
|
|
437
|
+
)
|
|
438
|
+
n += len(self.agents)
|
|
439
|
+
message.execution_result = "success"
|
|
440
|
+
else:
|
|
441
|
+
raise ValueError(
|
|
442
|
+
"pick_strategy must be one of 'round_robin', 'random', 'simutaneous' or a function"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if self.pick_strategy != "simultaneous":
|
|
446
|
+
self.state = f"turn {n}: agent {next_turn_agent.card.name} running" # type: ignore
|
|
447
|
+
message = next_turn_agent.execute(message, **kwargs) # type: ignore
|
|
448
|
+
if len(message.responses) > 0:
|
|
449
|
+
pass
|
|
450
|
+
n += 1
|
|
451
|
+
|
|
452
|
+
if message.execution_result != "success" or (
|
|
453
|
+
self.should_stop and self.should_stop(message)
|
|
454
|
+
):
|
|
455
|
+
break
|
|
456
|
+
|
|
457
|
+
self.state = "idle"
|
|
458
|
+
message.origin = self.card.name
|
|
459
|
+
message.execution_result = "success"
|
|
460
|
+
return message
|
|
461
|
+
|
|
462
|
+
def _set_composed_state(self) -> None:
|
|
463
|
+
self._composed_state = BaseAgent.build_composed_state(
|
|
464
|
+
self,
|
|
465
|
+
self.agents,
|
|
466
|
+
"parallel" if self.pick_strategy == "simultaneous" else "sequential",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class VotingAgent(BaseAgent):
|
|
471
|
+
"""
|
|
472
|
+
A voting agent selects the best answer among a list of candidates responses.
|
|
473
|
+
"""
|
|
474
|
+
|
|
475
|
+
agents: Sequence[BaseAgent] = Field(
|
|
476
|
+
...,
|
|
477
|
+
description="""The list of agents participate in the voting""",
|
|
478
|
+
min_length=1,
|
|
479
|
+
)
|
|
480
|
+
voting_method: Literal["majority_vote", "llm_score"] = Field(
|
|
481
|
+
...,
|
|
482
|
+
description="""The voting method to use.
|
|
483
|
+
1. majority_vote: It uses bleu or rouge score to calculate score for candidates
|
|
484
|
+
2. llm_score: use LLM to score the candidate messages.
|
|
485
|
+
For each message, it will score messages generated by other agents.
|
|
486
|
+
Then score for each message will be aggregated (average) to get the final score.
|
|
487
|
+
|
|
488
|
+
Note:
|
|
489
|
+
- The majority_vote method can also be known as the metric-based scorer.
|
|
490
|
+
i.e. Use a metric to score the candidate with reference message. This approach is simple and fast.
|
|
491
|
+
- The llm_score method can also be known as the model-based scorer.
|
|
492
|
+
i.e. Use a model to score the candidate message.
|
|
493
|
+
This approach is more complex and slower. But it can capture more scenarios like semantic similarity, context similarity, etc.
|
|
494
|
+
""",
|
|
495
|
+
)
|
|
496
|
+
scorer: str | Callable[[str], float] = Field(
|
|
497
|
+
...,
|
|
498
|
+
description="""1. If voting_method is 'llm_score', the function to use to convert LLM response to score.
|
|
499
|
+
Note that LLM may generate reponse that could not convert to score.
|
|
500
|
+
In these cases, there are various ways to handle.
|
|
501
|
+
For example, we just ignore it and assign the score to 0
|
|
502
|
+
|
|
503
|
+
2. If voting_method is 'majority_vote'. scorer accept bleu or rouge score
|
|
504
|
+
- 'bleu' or 'agent_forest': Use bleu score to calculate voting score. https://arxiv.org/pdf/2402.05120.
|
|
505
|
+
- 'rougeL': use rouge-L (rouge longest common sequence)
|
|
506
|
+
- 'rougeN': N can be an integer, e.g. rouge1, rouge2,...""",
|
|
507
|
+
)
|
|
508
|
+
voting_prompt: str | None = Field(
|
|
509
|
+
default=None,
|
|
510
|
+
description="The prompt to use for the voting. This parameter is used when voting_method is 'llm_score'",
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def execute(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
514
|
+
self.state = "running"
|
|
515
|
+
if self.voting_method == "majority_vote":
|
|
516
|
+
# sanity check
|
|
517
|
+
if not isinstance(self.scorer, str):
|
|
518
|
+
raise TypeError(
|
|
519
|
+
"When voting_method is 'majority_vote'. scorer need to be one of bleu, agent_forest, rougeL or rouge[N]"
|
|
520
|
+
)
|
|
521
|
+
elif self.scorer == "agent_forest" or self.scorer == "bleu":
|
|
522
|
+
# Use the same library and code snippet as the author
|
|
523
|
+
# Source: https://github.com/MoreAgentsIsAllYouNeed/AgentForest/blob/master/src/utils.py#L220
|
|
524
|
+
def bleu_score(hyp: str, ref: str) -> float:
|
|
525
|
+
return sentence_bleu(hyp, [ref], lowercase=True).score
|
|
526
|
+
|
|
527
|
+
score_func = bleu_score
|
|
528
|
+
elif re.match(r"^rouge(L|\d+)$", self.scorer):
|
|
529
|
+
scorer = rouge_scorer.RougeScorer([self.scorer], use_stemmer=True)
|
|
530
|
+
|
|
531
|
+
def rouge_score(hyp: str, ref: str) -> float:
|
|
532
|
+
return scorer.score(ref, hyp)[self.scorer]
|
|
533
|
+
|
|
534
|
+
score_func = rouge_score
|
|
535
|
+
else:
|
|
536
|
+
raise ValueError("Scorer not supported")
|
|
537
|
+
|
|
538
|
+
scores = {agent.card.name: 0.0 for agent in self.agents}
|
|
539
|
+
message_map = {name: msg for name, msg in message.responses}
|
|
540
|
+
for agent_name, msg in message.responses:
|
|
541
|
+
total_score = 0
|
|
542
|
+
for other_agent in self.agents:
|
|
543
|
+
if agent_name == other_agent.card.name:
|
|
544
|
+
continue
|
|
545
|
+
total_score += score_func(message_map[other_agent.card.name], msg)
|
|
546
|
+
scores[agent_name] = total_score
|
|
547
|
+
highest_score_agent = max(scores, key=scores.get) # type: ignore
|
|
548
|
+
message.responses.append((self.card.name, message_map[highest_score_agent]))
|
|
549
|
+
|
|
550
|
+
elif self.voting_method == "llm_score":
|
|
551
|
+
if self.voting_prompt is None:
|
|
552
|
+
raise ValueError(
|
|
553
|
+
"voting_prompt is required for llm_score voting method"
|
|
554
|
+
)
|
|
555
|
+
elif not isinstance(self.scorer, Callable):
|
|
556
|
+
raise TypeError(
|
|
557
|
+
"scorer must be a Callable when voting_method is 'llm_score'"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
scores = {agent.card.name: 0.0 for agent in self.agents}
|
|
561
|
+
message_map = {name: msg for name, msg in message.responses}
|
|
562
|
+
for response in message.responses:
|
|
563
|
+
total_score = 0
|
|
564
|
+
for agent in self.agents:
|
|
565
|
+
if response[0] == agent.card.name:
|
|
566
|
+
continue
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
voting_message = AgentMessage(
|
|
570
|
+
query=self.voting_prompt, responses=[]
|
|
571
|
+
) # type: ignore
|
|
572
|
+
voting_message = agent.execute(voting_message, **kwargs)
|
|
573
|
+
total_score += self.scorer(voting_message.responses[-1][1])
|
|
574
|
+
except Exception:
|
|
575
|
+
# unable to parse the score
|
|
576
|
+
pass
|
|
577
|
+
scores[response[0]] = total_score
|
|
578
|
+
# The highest or average score will return the same response
|
|
579
|
+
# Additional note: because of the score, we even can rank all candidate responses and output a leaderboard.
|
|
580
|
+
highest_score_agent = max(scores, key=scores.get) # type: ignore
|
|
581
|
+
message.responses.append((self.card.name, message_map[highest_score_agent]))
|
|
582
|
+
|
|
583
|
+
else:
|
|
584
|
+
raise ValueError(
|
|
585
|
+
"voting_method must be one of 'llm_score', 'majority_vote'"
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
self.state = "idle"
|
|
589
|
+
message.origin = self.card.name
|
|
590
|
+
message.execution_result = "success"
|
|
591
|
+
return message
|
|
592
|
+
|
|
593
|
+
def _set_composed_state(self) -> None:
|
|
594
|
+
# TODO: find way to parallel voting of all agents
|
|
595
|
+
self._composed_state = BaseAgent.build_composed_state(
|
|
596
|
+
self, self.agents, "sequential"
|
|
597
|
+
)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import tabulate
|
|
9
|
+
from pydantic import Field, PrivateAttr, field_validator
|
|
10
|
+
|
|
11
|
+
# import toon_format
|
|
12
|
+
from .types import AgentMessage, BaseChain, ContentType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BasePromptEnhancer(BaseChain):
|
|
16
|
+
"""A base class to enhance / rewrite the prompt.
|
|
17
|
+
|
|
18
|
+
There are two types of prompt enhancement:
|
|
19
|
+
- Data enhancement: Give more context to the prompt by adding external data, either by using files (CSV, JSON, Markdown, etc.), database (SQL,...) or more advanced techniques like RAG, web search.
|
|
20
|
+
- Structure enhancement: Rewrite / refine the prompt partially or entirely.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abc.abstractmethod
|
|
24
|
+
def __call__(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
25
|
+
return super().__call__(message, **kwargs)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IdentityPromptEnhancer(BasePromptEnhancer):
|
|
29
|
+
"""A prompt enhancer that does nothing.
|
|
30
|
+
This serves as a default prompt enhancer."""
|
|
31
|
+
|
|
32
|
+
def __call__(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
33
|
+
return message
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DataFramePromptEnhancer(BasePromptEnhancer):
|
|
37
|
+
"""A prompt enhancer that uses a pandas dataframe to give more context to the prompt."""
|
|
38
|
+
|
|
39
|
+
format: str = Field(
|
|
40
|
+
...,
|
|
41
|
+
description="""
|
|
42
|
+
The format of the prompt.
|
|
43
|
+
The format must contain at least {prompt} and {data}.
|
|
44
|
+
Other parameters can be used and parsed""",
|
|
45
|
+
)
|
|
46
|
+
_data: str | None = PrivateAttr(None)
|
|
47
|
+
|
|
48
|
+
@field_validator("format")
|
|
49
|
+
@classmethod
|
|
50
|
+
def check_starts_with_prompt_and_data(cls, v: str) -> str:
|
|
51
|
+
if "{prompt}" not in v or "{data}" not in v:
|
|
52
|
+
raise ValueError("The format must contain at least {prompt} and {data}")
|
|
53
|
+
return v
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_pandas(
|
|
57
|
+
cls,
|
|
58
|
+
format: str,
|
|
59
|
+
data: pd.DataFrame,
|
|
60
|
+
prettier: str | tabulate.TableFormat | None = None,
|
|
61
|
+
):
|
|
62
|
+
"""
|
|
63
|
+
Parse a pandas dataframe into a string format.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
data (pd.DataFrame): The pandas dataframe to be parsed.
|
|
67
|
+
prettier (str | tabulate.TableFormat | None, optional): The prettier to be used. Defaults to None.
|
|
68
|
+
The available table formats are from the [tabulate](https://pypi.org/project/tabulate/) library.
|
|
69
|
+
[toon](https://github.com/toon-format/spec) are also supported
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
self: The instance of the class.
|
|
73
|
+
|
|
74
|
+
Notes:
|
|
75
|
+
- If prettier is 'toon', the dataframe is converted to a string following toon specification
|
|
76
|
+
- If prettier is None, the dataframe is converted to a string using the to_string() method.
|
|
77
|
+
- If prettier is not None, the dataframe is converted to a string using the tabulate module with the specified tablefmt.
|
|
78
|
+
"""
|
|
79
|
+
enhancer = DataFramePromptEnhancer(format=format)
|
|
80
|
+
if prettier is None:
|
|
81
|
+
enhancer._data = data.to_string()
|
|
82
|
+
# elif prettier == "toon": # toon_format currently is beta release, waiting 1.0.0 release
|
|
83
|
+
# enhancer._data = toon_format.encode(data.to_dict())
|
|
84
|
+
else:
|
|
85
|
+
enhancer._data = tabulate.tabulate(
|
|
86
|
+
data.to_dict(), headers="keys", tablefmt=prettier
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return enhancer
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_string(cls, format: str, data: str):
|
|
93
|
+
"""
|
|
94
|
+
Parse a string into a format that can be used by the prompt enhancer.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
data (str): The string to be parsed.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
self: The instance of the class.
|
|
101
|
+
"""
|
|
102
|
+
enhancer = DataFramePromptEnhancer(format=format)
|
|
103
|
+
enhancer._data = data
|
|
104
|
+
return enhancer
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_dict(
|
|
108
|
+
cls, format: str, data: dict, prettier: str | tabulate.TableFormat | None = None
|
|
109
|
+
):
|
|
110
|
+
"""
|
|
111
|
+
Parse a dictionary into a format that can be used by the prompt enhancer.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
data (dict): The dictionary to be parsed.
|
|
115
|
+
prettier (str | tabulate.TableFormat | None, optional): The prettier to be used. Defaults to None.
|
|
116
|
+
The available table formats are from the [tabulate](https://pypi.org/project/tabulate/) library.
|
|
117
|
+
[toon](https://github.com/toon-format/spec) are also supported
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
self: The instance of the class.
|
|
121
|
+
|
|
122
|
+
Notes:
|
|
123
|
+
- If prettier is 'toon', the dataframe is converted to a string following toon specification
|
|
124
|
+
- If prettier is None, the dictionary is converted to a string using the str() method.
|
|
125
|
+
- If prettier is not None, the dictionary is converted to a string using the tabulate module with the specified tablefmt.
|
|
126
|
+
"""
|
|
127
|
+
enhancer = DataFramePromptEnhancer(format=format)
|
|
128
|
+
if prettier is None:
|
|
129
|
+
enhancer._data = str(data)
|
|
130
|
+
# elif prettier == "toon": # toon_format currently is beta release, waiting 1.0.0 release
|
|
131
|
+
# enhancer._data = toon_format.encode(data.to_dict())
|
|
132
|
+
else:
|
|
133
|
+
enhancer._data = tabulate.tabulate(data, headers="keys", tablefmt=prettier)
|
|
134
|
+
|
|
135
|
+
return enhancer
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_list(cls, format: str, data: List[str], bullet_char: str = "-"):
|
|
139
|
+
"""
|
|
140
|
+
Parse a list of strings into a format that can be used by the prompt enhancer.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
data (List[str]): The list of strings to be parsed.
|
|
144
|
+
bullet_char (str, optional): The character to be used as the bullet. Defaults to "-".
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
self: The instance of the class.
|
|
148
|
+
|
|
149
|
+
Notes:
|
|
150
|
+
- The list of strings is converted to a string using the join() method with the bullet character as the separator.
|
|
151
|
+
"""
|
|
152
|
+
enhancer = DataFramePromptEnhancer(format=format)
|
|
153
|
+
enhancer._data = "\n".join([f"{bullet_char} {d}" for d in data])
|
|
154
|
+
return enhancer
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_jsonl(cls, format: str, path: str | os.PathLike[str]):
|
|
158
|
+
"""
|
|
159
|
+
Parse a JSONL file into a format that can be used by the prompt enhancer.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
path (str): The path to the JSONL file.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
self: The instance of the class.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
FileNotFoundError: If the file specified by path does not exist.
|
|
169
|
+
|
|
170
|
+
Notes:
|
|
171
|
+
- The JSONL file is parsed line by line using the json.loads() method.
|
|
172
|
+
- The parsed data is converted to a pandas DataFrame and passed to the parse_pandas() method.
|
|
173
|
+
"""
|
|
174
|
+
if not os.path.exists(path):
|
|
175
|
+
raise FileNotFoundError(f"File {path} does not exist")
|
|
176
|
+
|
|
177
|
+
# Implementation provided by https://stackoverflow.com/a/74406107
|
|
178
|
+
with open(path, "r") as f:
|
|
179
|
+
lines = f.read().splitlines()
|
|
180
|
+
|
|
181
|
+
line_dicts = [json.loads(line) for line in lines]
|
|
182
|
+
return DataFramePromptEnhancer.from_pandas(format, pd.DataFrame(line_dicts))
|
|
183
|
+
|
|
184
|
+
def __call__(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
185
|
+
if self._data is None:
|
|
186
|
+
raise ValueError("Data not parsed yet")
|
|
187
|
+
message.query = self.format.format(
|
|
188
|
+
prompt=message.query, data=self._data, **kwargs
|
|
189
|
+
)
|
|
190
|
+
return message
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class BaseRAGPromptEnhancer(BasePromptEnhancer):
|
|
194
|
+
@abc.abstractmethod
|
|
195
|
+
def _search(self, query: str, **kwargs) -> Sequence[Tuple[ContentType, str]]:
|
|
196
|
+
""""""
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
@abc.abstractmethod
|
|
200
|
+
def _format(
|
|
201
|
+
self, message: AgentMessage, data: Sequence[Tuple[ContentType, str]]
|
|
202
|
+
) -> AgentMessage:
|
|
203
|
+
# For string type, the field we need to touch is `query`. For other types, the media `query_media` and `query_media_type` needed to modify
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
def __call__(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
207
|
+
data = self._search(message.query, **kwargs)
|
|
208
|
+
prompt = self._format(message, data)
|
|
209
|
+
return prompt
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any, Dict, List, Literal, Tuple, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ChainMessage = TypeVar("ChainMessage")
|
|
9
|
+
ChainResponse = TypeVar("ChainResponse")
|
|
10
|
+
AgentResponse = Tuple[str, str]
|
|
11
|
+
ContentType = Literal["text", "image", "audio", "video", "document"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentMessage(BaseModel):
|
|
15
|
+
query: str = Field(
|
|
16
|
+
...,
|
|
17
|
+
description="""The user query consumed by agent's LLM.
|
|
18
|
+
In this message class, we don't store the system prompt and user template.
|
|
19
|
+
Because each agent have their own system prompt and user template, which is not shared between agents.""",
|
|
20
|
+
)
|
|
21
|
+
query_media: List[Tuple[ContentType, str]] | None = Field(
|
|
22
|
+
default=None, description="The media content associated with the query."
|
|
23
|
+
)
|
|
24
|
+
origin: str | None = Field(
|
|
25
|
+
default=None, description="The agent that send this message"
|
|
26
|
+
)
|
|
27
|
+
responses: List[AgentResponse] = Field(
|
|
28
|
+
default=[],
|
|
29
|
+
description="""
|
|
30
|
+
If an agent generate multiple responses, either by same or different subagents, all of them will be stored here.
|
|
31
|
+
In each response tuple, the first one should be agent name or index, and second is the response""",
|
|
32
|
+
)
|
|
33
|
+
context: dict | None = Field(
|
|
34
|
+
default=None,
|
|
35
|
+
description="""
|
|
36
|
+
Agent additional material, which probably is the output of other agent or user entered.
|
|
37
|
+
There are various types of context produced by user and other agents.
|
|
38
|
+
The prompt that comsume this context need to explicitly know the format of this context.""",
|
|
39
|
+
)
|
|
40
|
+
execution_result: Literal["success", "error"] | None = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description="The execution result of the agent. Can be success or error",
|
|
43
|
+
)
|
|
44
|
+
error_message: str | None = Field(
|
|
45
|
+
default=None, description="The error message if the execution result is error."
|
|
46
|
+
)
|
|
47
|
+
media: List[Tuple[ContentType, str]] | None = Field(
|
|
48
|
+
default=None,
|
|
49
|
+
description="The additional media content. Can be image, video, audio, etc.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def flatten_dict(self, d: dict, parent_key: str = "", sep="_") -> Dict[str, Any]:
|
|
53
|
+
"""
|
|
54
|
+
Flattens a nested dictionary into a single-level dictionary.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
d (dict): The dictionary to flatten.
|
|
58
|
+
parent_key (str): The prefix for keys in the flattened dictionary.
|
|
59
|
+
sep (str): The separator used to join parent and child keys.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
dict: The flattened dictionary.
|
|
63
|
+
"""
|
|
64
|
+
items = []
|
|
65
|
+
for k, v in d.items():
|
|
66
|
+
new_key = parent_key + sep + k if parent_key else k
|
|
67
|
+
if isinstance(v, dict):
|
|
68
|
+
items.extend(self.flatten_dict(v, new_key, sep=sep).items())
|
|
69
|
+
else:
|
|
70
|
+
items.append((new_key, v))
|
|
71
|
+
return dict(items)
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
74
|
+
"""
|
|
75
|
+
Converts the AgentMessage object into a dictionary format.
|
|
76
|
+
|
|
77
|
+
Returns a dictionary that contains all the information in the AgentMessage object.
|
|
78
|
+
If the context is not None, it will be flattened and added to the returned dictionary.
|
|
79
|
+
All the data in context will have prefix of 'context_' by default.
|
|
80
|
+
For example, if the context is {'a': 1, 'b': 2}, the returned dictionary will contains {'context_a': 1, 'context_b': 2}
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
dict: A dictionary that contains all the information in the AgentMessage object.
|
|
84
|
+
"""
|
|
85
|
+
msg_json = self.model_dump(exclude_none=True, exclude={"context"})
|
|
86
|
+
if self.context:
|
|
87
|
+
context_dict = self.flatten_dict(self.context, parent_key="context")
|
|
88
|
+
for k, v in context_dict.items():
|
|
89
|
+
msg_json[k] = v
|
|
90
|
+
return msg_json
|
|
91
|
+
|
|
92
|
+
def dump_json(self) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Converts the AgentMessage object into a JSON string.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
str: A JSON string that contains all the information in the AgentMessage object.
|
|
98
|
+
"""
|
|
99
|
+
msg_dict = self.to_dict()
|
|
100
|
+
return json.dumps(msg_dict)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class BaseChain(abc.ABC, BaseModel):
|
|
104
|
+
@abc.abstractmethod
|
|
105
|
+
def __call__(self, message: AgentMessage, **kwargs) -> AgentMessage:
|
|
106
|
+
pass
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def remove_thinking(response: str) -> str:
|
|
5
|
+
"""A snippet to remove thinking
|
|
6
|
+
Note on the model Qwen 3: the openning <think> tag is missing. So we need to manually add it to beginning of the response
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
response (str): raw response
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
str: response without thinking
|
|
13
|
+
"""
|
|
14
|
+
if "</think>" in response and "<think>" not in response:
|
|
15
|
+
response = "<think>" + response
|
|
16
|
+
return re.sub(r"<think>.*?</think>", "", response, flags=re.DOTALL)
|