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.
- tinyflow/__init__.py +53 -0
- tinyflow/config/helpers.py +62 -0
- tinyflow/config/settings.py +80 -0
- tinyflow/core/__init__.py +31 -0
- tinyflow/core/agent.py +457 -0
- tinyflow/core/exceptions.py +63 -0
- tinyflow/core/logger.py +13 -0
- tinyflow/core/message.py +6 -0
- tinyflow/core/protocol.py +81 -0
- tinyflow/core/tools.py +200 -0
- tinyflow/core/types.py +252 -0
- tinyflow/embeddings/__init__.py +4 -0
- tinyflow/embeddings/base.py +32 -0
- tinyflow/embeddings/factory.py +140 -0
- tinyflow/embeddings/local_embedding.py +65 -0
- tinyflow/embeddings/openai_embedding.py +70 -0
- tinyflow/memory/__init__.py +5 -0
- tinyflow/memory/base.py +16 -0
- tinyflow/memory/simple.py +34 -0
- tinyflow/memory/vector.py +26 -0
- tinyflow/providers/anthropic_llm.py +132 -0
- tinyflow/providers/base/factory.py +81 -0
- tinyflow/providers/base/llm.py +37 -0
- tinyflow/providers/gemini_llm.py +130 -0
- tinyflow/providers/openai_llm.py +198 -0
- tinyflow/tools/builtin/__init__.py +36 -0
- tinyflow/tools/builtin/code_execution.py +143 -0
- tinyflow/tools/builtin/search.py +145 -0
- tinyflow/tools/builtin/web_reader.py +88 -0
- tinyflow/vector/__init__.py +4 -0
- tinyflow/vector/base.py +65 -0
- tinyflow/vector/chroma_db.py +134 -0
- tinyflow/vector/factory.py +84 -0
- tinyflow/vector/qdrant_db.py +198 -0
- tinyflow/workflow/__init__.py +21 -0
- tinyflow/workflow/executor.py +272 -0
- tinyflow/workflow/hooks.py +191 -0
- tinyflow/workflow/state.py +148 -0
- tinyflow/workflow/step.py +74 -0
- tinyflow_llm-0.1.0.dist-info/METADATA +243 -0
- tinyflow_llm-0.1.0.dist-info/RECORD +43 -0
- tinyflow_llm-0.1.0.dist-info/WHEEL +4 -0
- 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", [])]
|