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.
@@ -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)