shuvi 0.1.0__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.
- shuvi-0.1.0/LICENSE +21 -0
- shuvi-0.1.0/PKG-INFO +19 -0
- shuvi-0.1.0/README.md +1 -0
- shuvi-0.1.0/pyproject.toml +35 -0
- shuvi-0.1.0/shuvi/agent.py +280 -0
- shuvi-0.1.0/shuvi/context.py +46 -0
- shuvi-0.1.0/shuvi/swarm.py +55 -0
- shuvi-0.1.0/shuvi/types.py +188 -0
shuvi-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 waifu lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
shuvi-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: shuvi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary:
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: phillychi3
|
|
7
|
+
Author-email: phillychi3@gmail.com
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: hydrogen-ai (>=0.1.0,<0.2.0)
|
|
16
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# SHUVI
|
shuvi-0.1.0/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SHUVI
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
packages = [{ include = "shuvi" }]
|
|
3
|
+
|
|
4
|
+
[tool.poetry.group.dev.dependencies]
|
|
5
|
+
pytest-asyncio = "^1.3.0"
|
|
6
|
+
python-dotenv = ">=1.0.1,<1.1.0"
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "shuvi"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = ""
|
|
12
|
+
authors = [{ name = "phillychi3", email = "phillychi3@gmail.com" }]
|
|
13
|
+
license = { text = "MIT License" }
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
requires-python = ">=3.10"
|
|
16
|
+
dependencies = ["pydantic (>=2.12.5,<3.0.0)", "hydrogen-ai (>=0.1.0,<0.2.0)"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
[tool.pytest.ini_options]
|
|
20
|
+
pythonpath = ["."]
|
|
21
|
+
testpaths = ["test"]
|
|
22
|
+
asyncio_mode = "auto"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["poetry-core"]
|
|
26
|
+
build-backend = "poetry.core.masonry.api"
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
lint.select = ["ASYNC", "ARG", "B", "C", "C4", "E", "F", "Q", "W", "I"]
|
|
30
|
+
|
|
31
|
+
lint.ignore = ["F401"]
|
|
32
|
+
|
|
33
|
+
lint.unfixable = ["F401"]
|
|
34
|
+
|
|
35
|
+
line-length = 120
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple
|
|
5
|
+
|
|
6
|
+
from hai.providers.providers import ProviderText
|
|
7
|
+
from hai.tool import Tool
|
|
8
|
+
from shuvi.types import (
|
|
9
|
+
AGENT_OUTCOME,
|
|
10
|
+
AgentCallbacks,
|
|
11
|
+
AgentConfig,
|
|
12
|
+
AgentResult,
|
|
13
|
+
AppMessage,
|
|
14
|
+
AssistantMessage,
|
|
15
|
+
ExecutionTrace,
|
|
16
|
+
HandoffSignal,
|
|
17
|
+
LoopConfig,
|
|
18
|
+
LoopState,
|
|
19
|
+
TokenUsage,
|
|
20
|
+
ToolCall,
|
|
21
|
+
ToolCallFunction,
|
|
22
|
+
ToolCallTrace,
|
|
23
|
+
ToolMessage,
|
|
24
|
+
UserMessage,
|
|
25
|
+
VerificationRecord,
|
|
26
|
+
VerifyFn,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from shuvi.context import ContextManager
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# C:\Users\whitecloud\Documents\code\waifu\shuvi\agent.py:39: RuntimeWarning: coroutine 'HTTPAdapter.handle.<locals>.reply_fn' was never awaited
|
|
34
|
+
# fn(*args)
|
|
35
|
+
# RuntimeWarning: Enable tracemalloc to get the object allocation traceback
|
|
36
|
+
async def _emit(fn: Optional[Any], *args: Any) -> None:
|
|
37
|
+
if fn is None:
|
|
38
|
+
return
|
|
39
|
+
if inspect.iscoroutinefunction(fn):
|
|
40
|
+
await fn(*args)
|
|
41
|
+
else:
|
|
42
|
+
fn(*args)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _to_llm(messages: Sequence[AppMessage]) -> List[Dict[str, Any]]:
|
|
46
|
+
return [m.model_dump(exclude_none=True) for m in messages]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Agent:
|
|
50
|
+
def __init__(self, config: AgentConfig) -> None:
|
|
51
|
+
self._provider: ProviderText = config.provider
|
|
52
|
+
self._system_prompt: str = config.system_prompt
|
|
53
|
+
self._loop: LoopConfig = config.loop
|
|
54
|
+
self._callbacks: AgentCallbacks = config.callbacks
|
|
55
|
+
self._session_id: Optional[str] = config.session_id
|
|
56
|
+
self._tools: List[Tool] = list(config.tools)
|
|
57
|
+
self._tool_map: Dict[str, Tool] = {t.name: t for t in self._tools}
|
|
58
|
+
self._message_queue: asyncio.Queue[AppMessage] = asyncio.Queue()
|
|
59
|
+
self._context_manager: Optional["ContextManager"] = config.context_manager
|
|
60
|
+
self._state = LoopState()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def state(self) -> LoopState:
|
|
64
|
+
return self._state
|
|
65
|
+
|
|
66
|
+
def reset_state(self) -> None:
|
|
67
|
+
self._state = LoopState()
|
|
68
|
+
|
|
69
|
+
async def run(self, messages: Optional[Sequence[AppMessage]] = None) -> AgentResult:
|
|
70
|
+
"""
|
|
71
|
+
If ContextManager is present, messages param is ignored — append to
|
|
72
|
+
context_manager before calling run(). Otherwise messages must be provided.
|
|
73
|
+
"""
|
|
74
|
+
if self._context_manager is not None:
|
|
75
|
+
initial_messages = await self._context_manager.prepare()
|
|
76
|
+
else:
|
|
77
|
+
if messages is None:
|
|
78
|
+
raise ValueError("messages required when no ContextManager is configured")
|
|
79
|
+
initial_messages = list(messages)
|
|
80
|
+
|
|
81
|
+
start = datetime.now()
|
|
82
|
+
trace = ExecutionTrace(
|
|
83
|
+
start_time=start.isoformat(),
|
|
84
|
+
session_id=self._session_id,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
current_messages = initial_messages
|
|
88
|
+
verify_fn = self._loop.verify_completion
|
|
89
|
+
max_outer = self._loop.max_outer_retries
|
|
90
|
+
handoff_target: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
while True:
|
|
94
|
+
try:
|
|
95
|
+
final_msgs, iterations, tool_traces, usage, hit_max = await self._inner_loop(current_messages)
|
|
96
|
+
except HandoffSignal as hs:
|
|
97
|
+
handoff_target = hs.target
|
|
98
|
+
self._state.transition = hs.target
|
|
99
|
+
await _emit(self._callbacks.state_update, self._state)
|
|
100
|
+
trace.outcome = AGENT_OUTCOME.completed
|
|
101
|
+
final_msgs = current_messages
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
trace.inner_iterations += iterations
|
|
105
|
+
trace.tool_calls.extend(tool_traces)
|
|
106
|
+
trace.token_usage.input += usage.input
|
|
107
|
+
trace.token_usage.output += usage.output
|
|
108
|
+
trace.token_usage.total += usage.total
|
|
109
|
+
|
|
110
|
+
if hit_max:
|
|
111
|
+
trace.outcome = AGENT_OUTCOME.max_iterations
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
if verify_fn is None:
|
|
115
|
+
trace.outcome = AGENT_OUTCOME.completed
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
verification = await _call_verify(verify_fn, final_msgs)
|
|
119
|
+
trace.verifications.append(verification)
|
|
120
|
+
|
|
121
|
+
if verification.passed:
|
|
122
|
+
trace.outcome = AGENT_OUTCOME.completed
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
if trace.outer_retries >= max_outer:
|
|
126
|
+
trace.outcome = AGENT_OUTCOME.verify_failed
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
trace.outer_retries += 1
|
|
130
|
+
|
|
131
|
+
current_messages = final_msgs + [
|
|
132
|
+
UserMessage(content=[{"type": "text", "text": f"驗證未通過:{verification.reason},請修正"}])
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
trace.outcome = AGENT_OUTCOME.error
|
|
137
|
+
await _emit(self._callbacks.error, e)
|
|
138
|
+
final_msgs = current_messages
|
|
139
|
+
if self._callbacks.error is None:
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
end = datetime.now()
|
|
143
|
+
trace.end_time = end.isoformat()
|
|
144
|
+
trace.duration_ms = (end - start).total_seconds() * 1000
|
|
145
|
+
|
|
146
|
+
if self._context_manager is not None:
|
|
147
|
+
new_msgs = final_msgs[len(initial_messages) :]
|
|
148
|
+
self._context_manager.extend(new_msgs)
|
|
149
|
+
|
|
150
|
+
return AgentResult(messages=final_msgs, trace=trace, outcome=trace.outcome, handoff=handoff_target)
|
|
151
|
+
|
|
152
|
+
def queue_message(self, message: AppMessage) -> None:
|
|
153
|
+
self._message_queue.put_nowait(message)
|
|
154
|
+
|
|
155
|
+
def add_tool(self, tool: Tool) -> None:
|
|
156
|
+
self._tools.append(tool)
|
|
157
|
+
self._tool_map[tool.name] = tool
|
|
158
|
+
|
|
159
|
+
def remove_tool(self, name: str) -> None:
|
|
160
|
+
self._tools = [t for t in self._tools if t.name != name]
|
|
161
|
+
self._tool_map.pop(name, None)
|
|
162
|
+
|
|
163
|
+
def set_tools(self, tools: List[Tool]) -> None:
|
|
164
|
+
self._tools = list(tools)
|
|
165
|
+
self._tool_map = {t.name: t for t in self._tools}
|
|
166
|
+
|
|
167
|
+
async def _inner_loop(
|
|
168
|
+
self,
|
|
169
|
+
messages: Sequence[AppMessage],
|
|
170
|
+
) -> Tuple[List[AppMessage], int, List[ToolCallTrace], TokenUsage, bool]:
|
|
171
|
+
msgs: List[AppMessage] = list(messages)
|
|
172
|
+
iterations = 0
|
|
173
|
+
tool_traces: List[ToolCallTrace] = []
|
|
174
|
+
total_usage = TokenUsage()
|
|
175
|
+
max_iter = self._loop.max_inner_iterations
|
|
176
|
+
|
|
177
|
+
while True:
|
|
178
|
+
while not self._message_queue.empty():
|
|
179
|
+
msgs.append(self._message_queue.get_nowait())
|
|
180
|
+
|
|
181
|
+
if self._state.stop_active:
|
|
182
|
+
return msgs, iterations, tool_traces, total_usage, False
|
|
183
|
+
|
|
184
|
+
if max_iter is not None and iterations >= max_iter:
|
|
185
|
+
return msgs, iterations, tool_traces, total_usage, True
|
|
186
|
+
|
|
187
|
+
turn = await self._provider.call_once(
|
|
188
|
+
messages=_to_llm(msgs),
|
|
189
|
+
tools=self._tools or None,
|
|
190
|
+
system_prompt=self._system_prompt or None,
|
|
191
|
+
on_chunk=self._callbacks.on_chunk,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
total_usage.input += turn.usage.get("input", 0)
|
|
195
|
+
total_usage.output += turn.usage.get("output", 0)
|
|
196
|
+
total_usage.total += turn.usage.get("total", 0)
|
|
197
|
+
|
|
198
|
+
self._state.turn_count += 1
|
|
199
|
+
iterations += 1
|
|
200
|
+
await _emit(self._callbacks.state_update, self._state)
|
|
201
|
+
|
|
202
|
+
if turn.content:
|
|
203
|
+
await _emit(
|
|
204
|
+
self._callbacks.assistant_message,
|
|
205
|
+
AssistantMessage(content=turn.content),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
tool_calls: Optional[List[ToolCall]] = None
|
|
209
|
+
if turn.tool_calls:
|
|
210
|
+
tool_calls = [
|
|
211
|
+
ToolCall(
|
|
212
|
+
id=tc["id"],
|
|
213
|
+
function=ToolCallFunction(name=tc["function"]["name"], arguments=tc["function"]["arguments"]),
|
|
214
|
+
)
|
|
215
|
+
for tc in turn.tool_calls
|
|
216
|
+
]
|
|
217
|
+
msgs.append(AssistantMessage(content=turn.content, tool_calls=tool_calls))
|
|
218
|
+
else:
|
|
219
|
+
msgs.append(AssistantMessage(content=turn.content))
|
|
220
|
+
|
|
221
|
+
if not tool_calls:
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
results = await asyncio.gather(*[self._execute_tool_call(tc) for tc in tool_calls])
|
|
225
|
+
|
|
226
|
+
for tc, (result, trace) in zip(tool_calls, results, strict=False):
|
|
227
|
+
tool_traces.append(trace)
|
|
228
|
+
msgs.append(ToolMessage(tool_call_id=tc.id, content=result))
|
|
229
|
+
await _emit(self._callbacks.tool_result, tc.function.name, result)
|
|
230
|
+
|
|
231
|
+
await _emit(self._callbacks.iteration_end, iterations, tool_traces)
|
|
232
|
+
|
|
233
|
+
return msgs, iterations, tool_traces, total_usage, False
|
|
234
|
+
|
|
235
|
+
async def _execute_tool_call(self, tool_call: ToolCall) -> Tuple[str, ToolCallTrace]:
|
|
236
|
+
name = tool_call.function.name
|
|
237
|
+
arguments = tool_call.function.arguments
|
|
238
|
+
|
|
239
|
+
await _emit(self._callbacks.tool_call, name, arguments)
|
|
240
|
+
|
|
241
|
+
t_start = datetime.now()
|
|
242
|
+
tool = self._tool_map.get(name)
|
|
243
|
+
|
|
244
|
+
if tool is None:
|
|
245
|
+
error = f"Tool '{name}' not found"
|
|
246
|
+
duration = (datetime.now() - t_start).total_seconds() * 1000
|
|
247
|
+
return error, ToolCallTrace(name=name, input=arguments, output=error, duration_ms=duration, error=error)
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
result = await tool.call(arguments)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
duration = (datetime.now() - t_start).total_seconds() * 1000
|
|
253
|
+
error_msg = f"Error executing tool '{name}': {e}"
|
|
254
|
+
await _emit(self._callbacks.on_tool_error, name, e)
|
|
255
|
+
return error_msg, ToolCallTrace(
|
|
256
|
+
name=name, input=arguments, output=error_msg, duration_ms=duration, error=error_msg
|
|
257
|
+
)
|
|
258
|
+
duration = (datetime.now() - t_start).total_seconds() * 1000
|
|
259
|
+
return result, ToolCallTrace(name=name, input=arguments, output=result, duration_ms=duration)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _call_verify(verify_fn: List[VerifyFn], messages: Sequence[AppMessage]) -> VerificationRecord:
|
|
263
|
+
last: VerificationRecord = VerificationRecord(passed=True)
|
|
264
|
+
for fn in verify_fn:
|
|
265
|
+
if inspect.iscoroutinefunction(fn):
|
|
266
|
+
raw = await fn(messages)
|
|
267
|
+
else:
|
|
268
|
+
raw = fn(messages)
|
|
269
|
+
|
|
270
|
+
if isinstance(raw, VerificationRecord):
|
|
271
|
+
record = raw
|
|
272
|
+
elif isinstance(raw, dict):
|
|
273
|
+
record = VerificationRecord(**raw)
|
|
274
|
+
else:
|
|
275
|
+
record = VerificationRecord(passed=bool(raw))
|
|
276
|
+
|
|
277
|
+
if not record.passed:
|
|
278
|
+
return record
|
|
279
|
+
last = record
|
|
280
|
+
return last
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import Awaitable, Callable, List, Optional
|
|
2
|
+
|
|
3
|
+
from shuvi.types import AppMessage
|
|
4
|
+
|
|
5
|
+
_LLM_ROLES = {"user", "assistant", "tool"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def _default_convert(messages: List[AppMessage]) -> List[AppMessage]:
|
|
9
|
+
return [m for m in messages if m.role in _LLM_ROLES]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ContextManager:
|
|
13
|
+
"""
|
|
14
|
+
Manages app-level message history and the two-step conversion to LLM format.
|
|
15
|
+
|
|
16
|
+
transform_context: trim/inject/window at the AppMessage layer before LLM call
|
|
17
|
+
convert_to_llm: filter custom message subtypes, produce the list the LLM sees
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
transform_context: Optional[Callable[[List[AppMessage]], Awaitable[List[AppMessage]]]] = None,
|
|
23
|
+
convert_to_llm: Optional[Callable[[List[AppMessage]], Awaitable[List[AppMessage]]]] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._transform = transform_context
|
|
26
|
+
self._convert = convert_to_llm or _default_convert
|
|
27
|
+
self._history: List[AppMessage] = []
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def history(self) -> List[AppMessage]:
|
|
31
|
+
return self._history
|
|
32
|
+
|
|
33
|
+
def append(self, message: AppMessage) -> None:
|
|
34
|
+
self._history.append(message)
|
|
35
|
+
|
|
36
|
+
def extend(self, messages: List[AppMessage]) -> None:
|
|
37
|
+
self._history.extend(messages)
|
|
38
|
+
|
|
39
|
+
def clear(self) -> None:
|
|
40
|
+
self._history.clear()
|
|
41
|
+
|
|
42
|
+
async def prepare(self) -> List[AppMessage]:
|
|
43
|
+
messages = list(self._history)
|
|
44
|
+
if self._transform:
|
|
45
|
+
messages = await self._transform(messages)
|
|
46
|
+
return await self._convert(messages)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from shuvi.agent import Agent
|
|
4
|
+
from shuvi.types import AgentResult, AppMessage
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Swarm:
|
|
8
|
+
"""
|
|
9
|
+
Routes messages across multiple named agents using the handoff mechanism.
|
|
10
|
+
|
|
11
|
+
An agent signals a handoff by raising HandoffSignal(target) inside a tool call.
|
|
12
|
+
Swarm catches the result.handoff field and re-runs with the target agent,
|
|
13
|
+
passing the current message history forward.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, agents: Dict[str, Agent], max_handoffs: int = 10) -> None:
|
|
17
|
+
self._agents = agents
|
|
18
|
+
self._max_handoffs = max_handoffs
|
|
19
|
+
|
|
20
|
+
def add_agent(self, name: str, agent: Agent) -> None:
|
|
21
|
+
self._agents[name] = agent
|
|
22
|
+
|
|
23
|
+
async def run(
|
|
24
|
+
self,
|
|
25
|
+
entry: str,
|
|
26
|
+
messages: Optional[List[AppMessage]] = None,
|
|
27
|
+
) -> AgentResult:
|
|
28
|
+
current_name = entry
|
|
29
|
+
current_messages = messages
|
|
30
|
+
last_result: Optional[AgentResult] = None
|
|
31
|
+
handoffs = 0
|
|
32
|
+
|
|
33
|
+
while True:
|
|
34
|
+
agent = self._agents.get(current_name)
|
|
35
|
+
if agent is None:
|
|
36
|
+
raise KeyError(f"Agent '{current_name}' not found in swarm")
|
|
37
|
+
|
|
38
|
+
if agent._context_manager is not None:
|
|
39
|
+
result = await agent.run()
|
|
40
|
+
else:
|
|
41
|
+
result = await agent.run(current_messages)
|
|
42
|
+
|
|
43
|
+
last_result = result
|
|
44
|
+
|
|
45
|
+
if result.handoff is None:
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
if handoffs >= self._max_handoffs:
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
handoffs += 1
|
|
52
|
+
current_name = result.handoff
|
|
53
|
+
current_messages = result.messages
|
|
54
|
+
|
|
55
|
+
return last_result
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import (
|
|
3
|
+
Any,
|
|
4
|
+
Awaitable,
|
|
5
|
+
Callable,
|
|
6
|
+
Dict,
|
|
7
|
+
List,
|
|
8
|
+
Literal,
|
|
9
|
+
Optional,
|
|
10
|
+
Protocol,
|
|
11
|
+
Sequence,
|
|
12
|
+
Union,
|
|
13
|
+
runtime_checkable,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
from hai.providers.providers import ProviderText
|
|
19
|
+
from hai.tool import Tool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolCallFunction(BaseModel):
|
|
23
|
+
name: str
|
|
24
|
+
arguments: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolCall(BaseModel):
|
|
28
|
+
id: str
|
|
29
|
+
type: Literal["function"] = "function"
|
|
30
|
+
function: ToolCallFunction
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class AppMessage(Protocol):
|
|
35
|
+
@property
|
|
36
|
+
def role(self) -> str: ...
|
|
37
|
+
@property
|
|
38
|
+
def content(self) -> Any: ...
|
|
39
|
+
|
|
40
|
+
def model_dump(self, *, exclude_none: bool = False) -> Dict[str, Any]: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UserMessage(BaseModel):
|
|
44
|
+
role: Literal["user"] = "user"
|
|
45
|
+
content: List[Dict[str, Any]]
|
|
46
|
+
|
|
47
|
+
def model_dump(self, *, exclude_none: bool = False) -> Dict[str, Any]:
|
|
48
|
+
return {"role": self.role, "content": self.content}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AssistantMessage(BaseModel):
|
|
52
|
+
role: Literal["assistant"] = "assistant"
|
|
53
|
+
content: Optional[str] = None
|
|
54
|
+
tool_calls: Optional[List[ToolCall]] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ToolMessage(BaseModel):
|
|
58
|
+
role: Literal["tool"] = "tool"
|
|
59
|
+
content: str
|
|
60
|
+
tool_call_id: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SystemMessage(BaseModel):
|
|
64
|
+
role: Literal["system"] = "system"
|
|
65
|
+
content: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
LLMMessage = Union[UserMessage, AssistantMessage, ToolMessage]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AGENT_EVENT(str, Enum):
|
|
72
|
+
agent_start = "agent_start"
|
|
73
|
+
agent_end = "agent_end"
|
|
74
|
+
tool_start = "tool_start"
|
|
75
|
+
tool_end = "tool_end"
|
|
76
|
+
message_update = "message_update"
|
|
77
|
+
context_building = "context_building"
|
|
78
|
+
context_building_end = "context_building_end"
|
|
79
|
+
context_compacting = "context_compacting"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AGENT_OUTCOME(str, Enum):
|
|
83
|
+
completed = "completed"
|
|
84
|
+
max_iterations = "max_iterations"
|
|
85
|
+
error = "error"
|
|
86
|
+
verify_failed = "verify_failed"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ToolCallTrace(BaseModel):
|
|
90
|
+
name: str
|
|
91
|
+
input: str
|
|
92
|
+
output: str
|
|
93
|
+
duration_ms: float
|
|
94
|
+
error: Optional[str] = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class VerificationRecord(BaseModel):
|
|
98
|
+
passed: bool
|
|
99
|
+
reason: Optional[str] = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TokenUsage(BaseModel):
|
|
103
|
+
input: int = 0
|
|
104
|
+
output: int = 0
|
|
105
|
+
total: int = 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ExecutionTrace(BaseModel):
|
|
109
|
+
start_time: str
|
|
110
|
+
end_time: str = ""
|
|
111
|
+
duration_ms: float = 0.0
|
|
112
|
+
token_usage: TokenUsage = Field(default_factory=TokenUsage)
|
|
113
|
+
inner_iterations: int = 0
|
|
114
|
+
outer_retries: int = 0
|
|
115
|
+
outcome: AGENT_OUTCOME = AGENT_OUTCOME.completed
|
|
116
|
+
tool_calls: List[ToolCallTrace] = []
|
|
117
|
+
verifications: List[VerificationRecord] = []
|
|
118
|
+
session_id: Optional[str] = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AgentResult(BaseModel):
|
|
122
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
123
|
+
|
|
124
|
+
messages: List[AppMessage]
|
|
125
|
+
trace: ExecutionTrace
|
|
126
|
+
outcome: AGENT_OUTCOME
|
|
127
|
+
handoff: Optional[str] = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class HandoffSignal(Exception):
|
|
131
|
+
"""Raised inside Agent to signal control transfer to another agent."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, target: str, context: Optional[str] = None) -> None:
|
|
134
|
+
self.target = target
|
|
135
|
+
self.context = context # optional summary passed to the next agent
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
VerifyResult = Union[VerificationRecord, bool]
|
|
139
|
+
VerifyFn = Union[
|
|
140
|
+
Callable[[Sequence[AppMessage]], VerifyResult],
|
|
141
|
+
Callable[[Sequence[AppMessage]], "Awaitable[VerifyResult]"],
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class LoopState(BaseModel):
|
|
146
|
+
"""Mutable state carried across iterations of the agent loop."""
|
|
147
|
+
|
|
148
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
149
|
+
|
|
150
|
+
turn_count: int = 0
|
|
151
|
+
pending_summary: Optional[str] = None # accumulated summary for context compaction
|
|
152
|
+
stop_active: bool = False # a stop hook / signal has fired
|
|
153
|
+
transition: Optional[str] = None # handoff target agent name
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class LoopConfig(BaseModel):
|
|
157
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
158
|
+
|
|
159
|
+
max_inner_iterations: Optional[int] = None # None = unlimited
|
|
160
|
+
max_outer_retries: int = 3
|
|
161
|
+
verify_completion: Optional[List[VerifyFn]] = None # fn(messages) -> VerificationRecord | bool
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AgentCallbacks(BaseModel):
|
|
165
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
166
|
+
|
|
167
|
+
tool_call: Optional[Callable[[str, str], None]] = None
|
|
168
|
+
tool_result: Optional[Callable[[str, str], None]] = None
|
|
169
|
+
on_tool_error: Optional[Union[Callable[[str, Exception], None], Callable[[str, Exception], Awaitable[None]]]] = None
|
|
170
|
+
assistant_message: Optional[Callable[[AppMessage], None]] = None
|
|
171
|
+
on_chunk: Optional[Union[Callable[[str], None], Callable[[str], Awaitable[None]]]] = (
|
|
172
|
+
None # called per streaming token
|
|
173
|
+
)
|
|
174
|
+
iteration_end: Optional[Callable[[int, List[ToolCallTrace]], None]] = None
|
|
175
|
+
error: Optional[Callable[[Exception], None]] = None
|
|
176
|
+
state_update: Optional[Callable[["LoopState"], None]] = None # fired after each state mutation
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class AgentConfig(BaseModel):
|
|
180
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
181
|
+
|
|
182
|
+
provider: ProviderText
|
|
183
|
+
system_prompt: str = ""
|
|
184
|
+
tools: List[Tool] = []
|
|
185
|
+
loop: LoopConfig = LoopConfig()
|
|
186
|
+
callbacks: AgentCallbacks = AgentCallbacks()
|
|
187
|
+
session_id: Optional[str] = None
|
|
188
|
+
context_manager: Optional[Any] = None # shuvi.context.ContextManager; Any avoids circular import
|