tinyflow-llm 0.1.0__py3-none-any.whl

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.
Files changed (43) hide show
  1. tinyflow/__init__.py +53 -0
  2. tinyflow/config/helpers.py +62 -0
  3. tinyflow/config/settings.py +80 -0
  4. tinyflow/core/__init__.py +31 -0
  5. tinyflow/core/agent.py +457 -0
  6. tinyflow/core/exceptions.py +63 -0
  7. tinyflow/core/logger.py +13 -0
  8. tinyflow/core/message.py +6 -0
  9. tinyflow/core/protocol.py +81 -0
  10. tinyflow/core/tools.py +200 -0
  11. tinyflow/core/types.py +252 -0
  12. tinyflow/embeddings/__init__.py +4 -0
  13. tinyflow/embeddings/base.py +32 -0
  14. tinyflow/embeddings/factory.py +140 -0
  15. tinyflow/embeddings/local_embedding.py +65 -0
  16. tinyflow/embeddings/openai_embedding.py +70 -0
  17. tinyflow/memory/__init__.py +5 -0
  18. tinyflow/memory/base.py +16 -0
  19. tinyflow/memory/simple.py +34 -0
  20. tinyflow/memory/vector.py +26 -0
  21. tinyflow/providers/anthropic_llm.py +132 -0
  22. tinyflow/providers/base/factory.py +81 -0
  23. tinyflow/providers/base/llm.py +37 -0
  24. tinyflow/providers/gemini_llm.py +130 -0
  25. tinyflow/providers/openai_llm.py +198 -0
  26. tinyflow/tools/builtin/__init__.py +36 -0
  27. tinyflow/tools/builtin/code_execution.py +143 -0
  28. tinyflow/tools/builtin/search.py +145 -0
  29. tinyflow/tools/builtin/web_reader.py +88 -0
  30. tinyflow/vector/__init__.py +4 -0
  31. tinyflow/vector/base.py +65 -0
  32. tinyflow/vector/chroma_db.py +134 -0
  33. tinyflow/vector/factory.py +84 -0
  34. tinyflow/vector/qdrant_db.py +198 -0
  35. tinyflow/workflow/__init__.py +21 -0
  36. tinyflow/workflow/executor.py +272 -0
  37. tinyflow/workflow/hooks.py +191 -0
  38. tinyflow/workflow/state.py +148 -0
  39. tinyflow/workflow/step.py +74 -0
  40. tinyflow_llm-0.1.0.dist-info/METADATA +243 -0
  41. tinyflow_llm-0.1.0.dist-info/RECORD +43 -0
  42. tinyflow_llm-0.1.0.dist-info/WHEEL +4 -0
  43. tinyflow_llm-0.1.0.dist-info/licenses/LICENSE +21 -0
tinyflow/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """TinyFlow: A lightweight, provider-agnostic AI Agent framework.
2
+
3
+ Exposes the core components for building agents and workflows.
4
+ """
5
+
6
+ from tinyflow.core.agent import Agent
7
+ from tinyflow.core.exceptions import (
8
+ ConfigurationError,
9
+ MemoryError,
10
+ ProviderError,
11
+ TinyFlowError,
12
+ ToolError,
13
+ VectorError,
14
+ WorkflowError,
15
+ )
16
+ from tinyflow.core.message import convert_to_model_messages
17
+ from tinyflow.core.tools import Tool, tool
18
+ from tinyflow.core.types import Message, UIMessage
19
+ from tinyflow.embeddings.factory import EmbeddingFactory
20
+ from tinyflow.providers.base.factory import LLMFactory
21
+ from tinyflow.vector.factory import VectorDBFactory
22
+ from tinyflow.workflow.executor import WorkflowExecutor
23
+ from tinyflow.workflow.state import WorkflowState
24
+ from tinyflow.workflow.step import if_step, workflow_step
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ # Core
30
+ "Agent",
31
+ "Message",
32
+ "UIMessage",
33
+ "Tool",
34
+ "tool",
35
+ "convert_to_model_messages",
36
+ # Exceptions
37
+ "TinyFlowError",
38
+ "ConfigurationError",
39
+ "ProviderError",
40
+ "ToolError",
41
+ "WorkflowError",
42
+ "MemoryError",
43
+ "VectorError",
44
+ # Factories
45
+ "LLMFactory",
46
+ "EmbeddingFactory",
47
+ "VectorDBFactory",
48
+ # Workflow
49
+ "WorkflowExecutor",
50
+ "WorkflowState",
51
+ "workflow_step",
52
+ "if_step",
53
+ ]
@@ -0,0 +1,62 @@
1
+ """Configuration helper utilities.
2
+
3
+ This module provides utility functions for handling configuration precedence
4
+ (parameter > environment variable > settings > default) and parameter filtering.
5
+ """
6
+
7
+ import os
8
+ from typing import Any, Dict, Optional
9
+
10
+ from tinyflow.config.settings import settings
11
+
12
+
13
+ def get_config_value(
14
+ param_value: Optional[Any],
15
+ env_key: Optional[str] = None,
16
+ settings_field: Optional[str] = None,
17
+ default_value: Any = None,
18
+ ) -> Any:
19
+ """Get configuration value with precedence: param > env > settings > default.
20
+
21
+ Args:
22
+ param_value: Value explicitly passed as parameter (highest priority)
23
+ env_key: Environment variable name (e.g. "LLM_PROVIDER")
24
+ settings_field: Field name in settings object (e.g. "LLM_PROVIDER")
25
+ default_value: Default value if no other source provides a value
26
+
27
+ Returns:
28
+ The resolved configuration value
29
+ """
30
+ if param_value is not None:
31
+ return param_value
32
+
33
+ # Use settings (which already loads .env) before raw os.getenv
34
+ # Pydantic Settings handles .env loading better than raw os.getenv
35
+ # if python-dotenv is not explicitly loaded in main.
36
+ if settings_field and hasattr(settings, settings_field):
37
+ settings_val = getattr(settings, settings_field)
38
+ # Check for empty string specifically for API keys
39
+ if settings_val is not None and settings_val != "":
40
+ return settings_val
41
+
42
+ if env_key:
43
+ env_val = os.getenv(env_key)
44
+ if env_val is not None and env_val != "":
45
+ return env_val
46
+
47
+ return default_value
48
+
49
+
50
+ def filter_none_kwargs(**kwargs) -> Dict[str, Any]:
51
+ """Filter out None values from keyword arguments.
52
+
53
+ Useful for preparing arguments to pass to a provider that has its own
54
+ fallback logic for missing (None) parameters.
55
+
56
+ Args:
57
+ **kwargs: Keyword arguments to filter
58
+
59
+ Returns:
60
+ Dictionary containing only non-None values
61
+ """
62
+ return {k: v for k, v in kwargs.items() if v is not None}
@@ -0,0 +1,80 @@
1
+ from typing import Optional
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from pydantic import Field
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ def _get_env_file() -> str:
10
+ """
11
+ Robust .env file discovery.
12
+ """
13
+ env_file = os.getenv("ENV_FILE", ".env")
14
+
15
+ # Try to find .env file if it doesn't exist in CWD
16
+ if not os.path.exists(env_file) and not os.path.isabs(env_file):
17
+ # Look up 3 levels
18
+ current = Path.cwd()
19
+ for _ in range(3):
20
+ candidate = current / ".env"
21
+ if candidate.exists():
22
+ return str(candidate)
23
+ current = current.parent
24
+
25
+ return env_file
26
+
27
+
28
+ class Settings(BaseSettings):
29
+ # LLM Configuration
30
+ LLM_API_KEY: str = Field(default="", description="LLM API Key")
31
+ LLM_PROVIDER: str = Field(
32
+ default="openai", description="LLM Provider: openai, anthropic, gemini"
33
+ )
34
+ LLM_MODEL: str = Field(default="gpt-4o", description="LLM Model Name")
35
+ LLM_BASE_URL: Optional[str] = Field(default=None, description="LLM API Base URL")
36
+
37
+ # Embedding General Configuration
38
+ EMBEDDING_PROVIDER: str = Field(
39
+ default="openai", description="Embedding Provider: openai, local, sentence-transformers"
40
+ )
41
+
42
+ # OpenAI Embedding Configuration
43
+ EMBEDDING_MODEL: str = Field(
44
+ default="text-embedding-3-small", description="OpenAI Embedding Model"
45
+ )
46
+ EMBEDDING_BASE_URL: Optional[str] = Field(default=None, description="Embedding API Base URL")
47
+ EMBEDDING_API_KEY: str = Field(
48
+ default="", description="Embedding API Key (if different from LLM)"
49
+ )
50
+
51
+ # Local Embedding Configuration
52
+ EMBEDDING_MODEL_PATH: str = Field(
53
+ default="sentence-transformers/all-MiniLM-L6-v2", description="Local model path or name"
54
+ )
55
+ EMBEDDING_MODEL_DEVICE: str = Field(
56
+ default="auto", description="Computation device: auto, cpu, cuda, mps"
57
+ )
58
+
59
+ # Vector Database Configuration
60
+ VECTOR_DB_PROVIDER: str = Field(
61
+ default="chroma", description="Vector Database: chroma, qdrant, milvus, pinecone"
62
+ )
63
+ VECTOR_DB_URL: Optional[str] = Field(default=None, description="Cloud Vector Database URL")
64
+ VECTOR_DB_API_KEY: Optional[str] = Field(default=None, description="Cloud Vector Database API Key")
65
+ VECTOR_DB_PATH: Optional[str] = Field(default=None, description="Local Vector Database Storage Path")
66
+ VECTOR_DB_COLLECTION: str = Field(default="conversations", description="Vector Database Collection Name")
67
+
68
+ # Search Tool Configuration
69
+ TAVILY_API_KEY: Optional[str] = Field(default=None, description="Tavily Search API Key")
70
+
71
+ model_config = SettingsConfigDict(
72
+ env_file=_get_env_file(),
73
+ env_file_encoding="utf-8",
74
+ extra="ignore",
75
+ case_sensitive=False,
76
+ )
77
+
78
+
79
+ # Export configuration as singleton
80
+ settings = Settings() # type: ignore[call-arg]
@@ -0,0 +1,31 @@
1
+ """Core module for TinyFlow framework."""
2
+
3
+ from tinyflow.core.agent import Agent
4
+ from tinyflow.core.exceptions import (
5
+ ConfigurationError,
6
+ MemoryError,
7
+ ProviderError,
8
+ TinyFlowError,
9
+ ToolError,
10
+ VectorError,
11
+ WorkflowError,
12
+ )
13
+ from tinyflow.core.message import convert_to_model_messages
14
+ from tinyflow.core.tools import Tool, tool
15
+ from tinyflow.core.types import Message, UIMessage
16
+
17
+ __all__ = [
18
+ "Agent",
19
+ "Message",
20
+ "UIMessage",
21
+ "Tool",
22
+ "tool",
23
+ "convert_to_model_messages",
24
+ "TinyFlowError",
25
+ "ConfigurationError",
26
+ "ProviderError",
27
+ "ToolError",
28
+ "WorkflowError",
29
+ "MemoryError",
30
+ "VectorError",
31
+ ]
tinyflow/core/agent.py ADDED
@@ -0,0 +1,457 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import uuid
5
+ from typing import AsyncGenerator, Callable, Dict, List, Optional, Union
6
+
7
+ from tinyflow.core.tools import Tool
8
+ from tinyflow.core.tools import tool as tool_decorator
9
+ from tinyflow.core.types import (
10
+ Message,
11
+ ReasoningUIPart,
12
+ StepStartUIPart,
13
+ TextPartState,
14
+ TextUIPart,
15
+ ToolPartState,
16
+ ToolUIPart,
17
+ UIMessage,
18
+ )
19
+ from tinyflow.memory import BaseMemory
20
+ from tinyflow.providers.base.llm import BaseLLM
21
+
22
+ logger = logging.getLogger("tinyflow.core.agent")
23
+
24
+
25
+ class Agent:
26
+ def __init__(
27
+ self,
28
+ llm: BaseLLM,
29
+ tools: Optional[List[Tool]] = None,
30
+ system_prompt: str = "",
31
+ max_history: int = 10,
32
+ memory: Optional[BaseMemory] = None,
33
+ ):
34
+ self.llm = llm
35
+ self.tools: Dict[str, Tool] = {}
36
+
37
+ if tools:
38
+ for t in tools:
39
+ if isinstance(t, Tool):
40
+ self.tools[t.name] = t
41
+ elif callable(t):
42
+ wrapped_tool = tool_decorator()(t)
43
+ self.tools[wrapped_tool.name] = wrapped_tool
44
+ else:
45
+ raise TypeError(f"Tool must be Tool or Callable, got {type(t)}")
46
+
47
+ self.history: List[Message] = []
48
+ self.max_history = max_history
49
+ self.system_prompt = system_prompt
50
+ self.memory = memory
51
+ if system_prompt:
52
+ self.history.append(Message(role="system", content=system_prompt))
53
+
54
+ def _get_context_messages(self, memories: Optional[List[str]] = None) -> List[Message]:
55
+ if not self.history:
56
+ return []
57
+
58
+ system_msgs = [m for m in self.history if m.role == "system"]
59
+ other_msgs = [m for m in self.history if m.role != "system"]
60
+
61
+ if memories:
62
+ memory_str = "\n".join([f"- {m}" for m in memories])
63
+ memory_block = f"\n[CRITICAL CONTEXT MEMORY]\n{memory_str}\n[END OF CONTEXT MEMORY]"
64
+
65
+ if system_msgs:
66
+ first_system = system_msgs[0]
67
+ new_content = (first_system.content or "") + "\n" + memory_block
68
+ system_msgs[0] = Message(role="system", content=new_content)
69
+ else:
70
+ system_msgs = [
71
+ Message(
72
+ role="system",
73
+ content=f"You are a helpful assistant.{memory_block}",
74
+ )
75
+ ]
76
+
77
+ trimmed_history = other_msgs[-self.max_history :]
78
+ return system_msgs + trimmed_history
79
+
80
+ async def run(self, user_input: str, max_steps: int = 10):
81
+ """
82
+ Core loop: Thinking -> Acting -> Observing -> Thinking
83
+ """
84
+ self.history.append(Message(role="user", content=user_input))
85
+ current_step = 0
86
+
87
+ # Retrieve memories before conversation
88
+ memories = None
89
+ if self.memory:
90
+ memories = await self.memory.search(user_input)
91
+
92
+ while current_step < max_steps:
93
+ current_step += 1
94
+ try:
95
+ context = self._get_context_messages(memories)
96
+ response = await self.llm.generate(context, tools=list(self.tools.values()))
97
+
98
+ self.history.append(
99
+ Message(
100
+ role="assistant",
101
+ content=response.content,
102
+ tool_calls=response.tool_calls,
103
+ )
104
+ )
105
+
106
+ if response.tool_calls:
107
+ logger.info(
108
+ f"Step {current_step}: LLM requested {len(response.tool_calls)} tool calls"
109
+ )
110
+ tasks = [self._execute_tool(tc) for tc in response.tool_calls]
111
+ tool_messages = await asyncio.gather(*tasks)
112
+ self.history.extend(tool_messages)
113
+ else:
114
+ return response.content
115
+
116
+ except Exception as e:
117
+ logger.error(f"Error in agent loop at step {current_step}: {str(e)}")
118
+ error_msg = f"An internal error occurred: {str(e)}. Please try to recover or summarize the current state."
119
+ self.history.append(Message(role="system", content=error_msg))
120
+
121
+ if current_step >= max_steps:
122
+ return f"Agent failed after {max_steps} steps due to: {str(e)}"
123
+
124
+ return "Max steps reached."
125
+
126
+ async def _execute_tool(self, tool_call) -> Message:
127
+ func_name = tool_call["function"]["name"]
128
+ func_args = tool_call["function"]["arguments"]
129
+ call_id = tool_call["id"]
130
+
131
+ logger.info(f"Executing tool: {func_name}")
132
+
133
+ if func_name not in self.tools:
134
+ content = f"Error: Tool '{func_name}' not found."
135
+ else:
136
+ try:
137
+ args = json.loads(func_args)
138
+ content = await asyncio.wait_for(
139
+ self.tools[func_name].execute(**args), timeout=30.0
140
+ )
141
+ except asyncio.TimeoutError:
142
+ content = f"Error: Tool '{func_name}' execution timed out after 30s."
143
+ logger.warning(content)
144
+ except json.JSONDecodeError as e:
145
+ content = f"Error: Invalid JSON arguments: {str(e)}."
146
+ except Exception as e:
147
+ content = f"Error executing tool '{func_name}': {str(e)}"
148
+ logger.error(content)
149
+ # We catch exception here to return it to LLM, but we could also wrap it
150
+ # For now, keeping it as string message to LLM is better for recovery
151
+
152
+ return Message(role="tool", content=content, tool_call_id=call_id, name=func_name)
153
+
154
+ async def stream(
155
+ self, user_input: str, max_steps: int = 10
156
+ ) -> AsyncGenerator[UIMessage, None]:
157
+ """Stream chat responses as UIMessage objects with rich content types.
158
+
159
+ Yields UIMessage objects that can be directly rendered by frontend.
160
+ Each message contains typed parts (text, reasoning, tool calls, etc.)
161
+ with state information for streaming UI updates.
162
+ """
163
+ # Create and yield user message
164
+ user_message = UIMessage(
165
+ id=str(uuid.uuid4()),
166
+ role="user",
167
+ parts=[TextUIPart(type="text", text=user_input, state=TextPartState.DONE)],
168
+ )
169
+ self.history.append(Message(role="user", content=user_input))
170
+ yield user_message
171
+
172
+ # Create assistant message placeholder
173
+ assistant_message = UIMessage(
174
+ id=str(uuid.uuid4()),
175
+ role="assistant",
176
+ parts=[],
177
+ )
178
+
179
+ current_step = 0
180
+ memories = None
181
+ tool_args_buffer: Dict[str, str] = {}
182
+ if self.memory:
183
+ memories = await self.memory.search(user_input)
184
+
185
+ while current_step < max_steps:
186
+ current_step += 1
187
+
188
+ # Add step marker for multi-step execution
189
+ if current_step > 1:
190
+ assistant_message.parts.append(StepStartUIPart())
191
+ yield assistant_message
192
+
193
+ try:
194
+ context = self._get_context_messages(memories)
195
+
196
+ # Check if LLM supports stream_text
197
+ if not hasattr(self.llm, "stream_text"):
198
+ raise NotImplementedError(
199
+ f"LLM {type(self.llm).__name__} does not support stream_text. "
200
+ "Use agent.run() for non-streaming execution."
201
+ )
202
+
203
+ # Use new stream_text method
204
+ logger.info(f"Step {current_step}: Starting stream_text from LLM")
205
+
206
+ # Reset buffers for new turn
207
+ tool_args_buffer = {}
208
+ reasoning_buffer = ""
209
+
210
+ async for part in self.llm.stream_text(context, tools=list(self.tools.values())):
211
+ if part.type == "text":
212
+ if assistant_message.parts:
213
+ last = assistant_message.parts[-1]
214
+ if (
215
+ isinstance(last, ReasoningUIPart)
216
+ and last.state == TextPartState.STREAMING
217
+ ):
218
+ last.state = TextPartState.DONE
219
+
220
+ if assistant_message.parts:
221
+ last = assistant_message.parts[-1]
222
+ if (
223
+ isinstance(last, TextUIPart)
224
+ and last.state == TextPartState.STREAMING
225
+ ):
226
+ last.text += part.text
227
+ else:
228
+ assistant_message.parts.append(
229
+ TextUIPart(
230
+ type="text",
231
+ text=part.text,
232
+ state=TextPartState.STREAMING,
233
+ )
234
+ )
235
+ else:
236
+ assistant_message.parts.append(
237
+ TextUIPart(
238
+ type="text",
239
+ text=part.text,
240
+ state=TextPartState.STREAMING,
241
+ )
242
+ )
243
+ yield assistant_message
244
+
245
+ elif part.type == "reasoning":
246
+ reasoning_buffer += part.text
247
+
248
+ if assistant_message.parts:
249
+ last = assistant_message.parts[-1]
250
+ if (
251
+ isinstance(last, ReasoningUIPart)
252
+ and last.state == TextPartState.STREAMING
253
+ ):
254
+ last.text += part.text
255
+ else:
256
+ assistant_message.parts.append(
257
+ ReasoningUIPart(
258
+ type="reasoning",
259
+ text=part.text,
260
+ state=TextPartState.STREAMING,
261
+ )
262
+ )
263
+ else:
264
+ assistant_message.parts.append(
265
+ ReasoningUIPart(
266
+ type="reasoning",
267
+ text=part.text,
268
+ state=TextPartState.STREAMING,
269
+ )
270
+ )
271
+ yield assistant_message
272
+
273
+ elif part.type == "tool-call-streaming-start":
274
+ if assistant_message.parts:
275
+ last = assistant_message.parts[-1]
276
+ if (
277
+ isinstance(last, (TextUIPart, ReasoningUIPart))
278
+ and last.state == TextPartState.STREAMING
279
+ ):
280
+ last.state = TextPartState.DONE
281
+
282
+ tool_args_buffer[part.tool_call_id] = ""
283
+ new_tool_part = ToolUIPart(
284
+ type=f"tool-{part.tool_name}",
285
+ tool_call_id=part.tool_call_id,
286
+ tool_name=part.tool_name,
287
+ state=ToolPartState.INPUT_STREAMING,
288
+ )
289
+ assistant_message.parts.append(new_tool_part)
290
+ yield assistant_message
291
+
292
+ elif part.type == "tool-call-delta":
293
+ tool_args_buffer[part.tool_call_id] = (
294
+ tool_args_buffer.get(part.tool_call_id, "") + part.args_text_delta
295
+ )
296
+
297
+ elif part.type == "tool-call":
298
+ for p in assistant_message.parts:
299
+ if isinstance(p, ToolUIPart) and p.tool_call_id == part.tool_call_id:
300
+ p.state = ToolPartState.INPUT_AVAILABLE
301
+ p.input = part.input
302
+ break
303
+ yield assistant_message
304
+
305
+ for p in assistant_message.parts:
306
+ if (
307
+ isinstance(p, (TextUIPart, ReasoningUIPart))
308
+ and p.state == TextPartState.STREAMING
309
+ ):
310
+ p.state = TextPartState.DONE
311
+
312
+ # Check for tool calls to execute
313
+ tool_parts = [
314
+ p
315
+ for p in assistant_message.parts
316
+ if isinstance(p, ToolUIPart) and p.state == ToolPartState.INPUT_AVAILABLE
317
+ ]
318
+
319
+ if tool_parts:
320
+ # Add Assistant Message to History (CRITICAL for OpenAI validation)
321
+ text_content = ""
322
+ for p in assistant_message.parts:
323
+ if isinstance(p, TextUIPart):
324
+ text_content += p.text
325
+
326
+ tool_calls_payload = []
327
+ for p in tool_parts:
328
+ tool_calls_payload.append(
329
+ {
330
+ "id": p.tool_call_id,
331
+ "type": "function",
332
+ "function": {
333
+ "name": p.tool_name,
334
+ "arguments": json.dumps(p.input) if p.input else "{}",
335
+ },
336
+ }
337
+ )
338
+
339
+ assistant_msg_kwargs = {
340
+ "role": "assistant",
341
+ "content": text_content,
342
+ "tool_calls": tool_calls_payload,
343
+ }
344
+
345
+ if reasoning_buffer:
346
+ assistant_msg_kwargs["reasoning_content"] = reasoning_buffer
347
+
348
+ self.history.append(Message(**assistant_msg_kwargs))
349
+
350
+ # Execute tools
351
+ for tool_part in tool_parts:
352
+ tool_part.provider_executed = True
353
+
354
+ if tool_part.tool_name in self.tools:
355
+ try:
356
+ if tool_part.input is not None:
357
+ result = await self.tools[tool_part.tool_name].execute(
358
+ **tool_part.input
359
+ )
360
+ else:
361
+ result = await self.tools[tool_part.tool_name].execute()
362
+ tool_part.state = ToolPartState.OUTPUT_AVAILABLE
363
+ tool_part.output = result
364
+ except Exception as e:
365
+ tool_part.state = ToolPartState.OUTPUT_ERROR
366
+ tool_part.error_text = str(e)
367
+ else:
368
+ tool_part.state = ToolPartState.OUTPUT_ERROR
369
+ tool_part.error_text = f"Tool '{tool_part.tool_name}' not found"
370
+
371
+ yield assistant_message
372
+
373
+ # Add tool results to history
374
+ for tool_part in tool_parts:
375
+ self.history.append(
376
+ Message(
377
+ role="tool",
378
+ content=str(tool_part.output)
379
+ if tool_part.state == ToolPartState.OUTPUT_AVAILABLE
380
+ else tool_part.error_text or "Error",
381
+ tool_call_id=tool_part.tool_call_id,
382
+ name=tool_part.tool_name,
383
+ )
384
+ )
385
+
386
+ # Continue for next step
387
+ continue
388
+ else:
389
+ text_content = ""
390
+ for p in assistant_message.parts:
391
+ if isinstance(p, TextUIPart):
392
+ text_content += p.text
393
+
394
+ if reasoning_buffer:
395
+ self.history.append(
396
+ Message(
397
+ role="assistant",
398
+ content=text_content,
399
+ reasoning_content=reasoning_buffer,
400
+ )
401
+ )
402
+ else:
403
+ self.history.append(Message(role="assistant", content=text_content))
404
+ yield assistant_message
405
+ return
406
+
407
+ except Exception as e:
408
+ logger.error(f"Error in stream loop at step {current_step}: {str(e)}")
409
+ error_part = TextUIPart(
410
+ type="text",
411
+ text=f"\n[Error]: {str(e)}. Attempting to recover...",
412
+ state=TextPartState.DONE,
413
+ )
414
+ assistant_message.parts.append(error_part)
415
+ yield assistant_message
416
+
417
+ if current_step >= max_steps:
418
+ fatal_part = TextUIPart(
419
+ type="text",
420
+ text="\n[Fatal]: Maximum steps reached with errors.",
421
+ state=TextPartState.DONE,
422
+ )
423
+ assistant_message.parts.append(fatal_part)
424
+ yield assistant_message
425
+ return
426
+
427
+ def use_llm(self, llm: BaseLLM) -> None:
428
+ """Dynamically switch LLM Provider."""
429
+ self.llm = llm
430
+
431
+ def add_tool(self, tool_item: Union[Tool, Callable]) -> None:
432
+ if isinstance(tool_item, Tool):
433
+ self.tools[tool_item.name] = tool_item
434
+ elif callable(tool_item):
435
+ wrapped = tool_decorator()(tool_item)
436
+ self.tools[wrapped.name] = wrapped
437
+ else:
438
+ raise TypeError(f"Tool must be Tool or Callable, got {type(tool_item)}")
439
+
440
+ def add_tools(self, tools: List[Union[Tool, Callable]]) -> None:
441
+ for t in tools:
442
+ self.add_tool(t)
443
+
444
+ def save_state(self, file_path: str):
445
+ state = {"history": [m.model_dump() for m in self.history]}
446
+ with open(file_path, "w") as f:
447
+ json.dump(state, f, ensure_ascii=False, indent=2)
448
+
449
+ def load_state(self, file_path: str):
450
+ import os
451
+
452
+ from tinyflow.core.types import Message
453
+
454
+ if os.path.exists(file_path):
455
+ with open(file_path, "r") as f:
456
+ state = json.load(f)
457
+ self.history = [Message(**m) for m in state.get("history", [])]