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 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