flowai-cli 0.2.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.
- flowai/__init__.py +72 -0
- flowai/__main__.py +8 -0
- flowai/agents/__init__.py +21 -0
- flowai/agents/base.py +61 -0
- flowai/agents/patterns/__init__.py +11 -0
- flowai/agents/patterns/debate.py +79 -0
- flowai/agents/patterns/reflection.py +74 -0
- flowai/agents/specialized.py +87 -0
- flowai/agents/standard.py +176 -0
- flowai/cli/__init__.py +5 -0
- flowai/cli/main.py +562 -0
- flowai/cli/ui.py +98 -0
- flowai/config/__init__.py +5 -0
- flowai/config/settings.py +204 -0
- flowai/errors.py +30 -0
- flowai/mcp/__init__.py +5 -0
- flowai/mcp/client.py +77 -0
- flowai/memory/__init__.py +16 -0
- flowai/memory/base.py +45 -0
- flowai/memory/chroma.py +97 -0
- flowai/memory/in_memory.py +65 -0
- flowai/memory/qdrant.py +104 -0
- flowai/observability/__init__.py +15 -0
- flowai/observability/events.py +45 -0
- flowai/observability/opentelemetry.py +56 -0
- flowai/observability/tracer.py +80 -0
- flowai/plugins/__init__.py +5 -0
- flowai/plugins/loader.py +31 -0
- flowai/providers/__init__.py +35 -0
- flowai/providers/anthropic.py +268 -0
- flowai/providers/base.py +180 -0
- flowai/providers/gemini.py +230 -0
- flowai/providers/nvidia.py +57 -0
- flowai/providers/ollama.py +237 -0
- flowai/providers/openai.py +274 -0
- flowai/providers/registry.py +50 -0
- flowai/runtime/__init__.py +5 -0
- flowai/runtime/session.py +76 -0
- flowai/server/__init__.py +5 -0
- flowai/server/app.py +133 -0
- flowai/tools/__init__.py +39 -0
- flowai/tools/base.py +56 -0
- flowai/tools/builtin.py +128 -0
- flowai/tools/decorator.py +103 -0
- flowai/tools/files.py +197 -0
- flowai/tools/registry.py +84 -0
- flowai/tools/web.py +94 -0
- flowai/workflow/__init__.py +15 -0
- flowai/workflow/checkpoint.py +73 -0
- flowai/workflow/engine.py +144 -0
- flowai/workflow/state.py +43 -0
- flowai_cli-0.2.0.dist-info/METADATA +223 -0
- flowai_cli-0.2.0.dist-info/RECORD +56 -0
- flowai_cli-0.2.0.dist-info/WHEEL +4 -0
- flowai_cli-0.2.0.dist-info/entry_points.txt +2 -0
- flowai_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Configuration: global user config, per-project file, and environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from flowai.errors import ConfigurationError
|
|
13
|
+
|
|
14
|
+
#: Where `flowai configure` keeps the provider and key chosen once per machine.
|
|
15
|
+
GLOBAL_CONFIG_DIR = Path.home() / ".flowai"
|
|
16
|
+
GLOBAL_CONFIG_PATH = GLOBAL_CONFIG_DIR / "config.json"
|
|
17
|
+
|
|
18
|
+
#: Project config filenames, in the order they are looked for.
|
|
19
|
+
PROJECT_CONFIG_NAMES = ("flowai.yaml", "flowai.yml", "flowai.toml", "flowai.json")
|
|
20
|
+
|
|
21
|
+
#: Environment variable holding each provider's key.
|
|
22
|
+
PROVIDER_KEY_ENV = {
|
|
23
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
24
|
+
"openai": "OPENAI_API_KEY",
|
|
25
|
+
"nvidia": "NVIDIA_API_KEY",
|
|
26
|
+
"gemini": "GEMINI_API_KEY",
|
|
27
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#: Model used when a provider is chosen but no model is named.
|
|
31
|
+
PROVIDER_DEFAULT_MODEL = {
|
|
32
|
+
"anthropic": "claude-sonnet-5-5",
|
|
33
|
+
"openai": "gpt-4o",
|
|
34
|
+
"nvidia": "deepseek-ai/deepseek-r1",
|
|
35
|
+
"gemini": "gemini-2.0-flash",
|
|
36
|
+
"openrouter": "deepseek/deepseek-r1",
|
|
37
|
+
"ollama": "llama3.2",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#: Order in which a provider is picked from whatever keys are present.
|
|
41
|
+
PROVIDER_PREFERENCE = ("anthropic", "openai", "nvidia", "gemini", "openrouter")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProviderConfig(BaseModel):
|
|
45
|
+
name: str = "openai"
|
|
46
|
+
model: str = "gpt-4o"
|
|
47
|
+
api_key: str | None = None
|
|
48
|
+
api_key_env: str | None = None
|
|
49
|
+
base_url: str | None = None
|
|
50
|
+
temperature: float = 0.7
|
|
51
|
+
max_tokens: int | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MemoryConfig(BaseModel):
|
|
55
|
+
backend: str = "in_memory" # in_memory, qdrant, chroma
|
|
56
|
+
url: str = "http://localhost:6333"
|
|
57
|
+
collection_name: str = "flowai_memory"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ServerConfig(BaseModel):
|
|
61
|
+
host: str = "127.0.0.1"
|
|
62
|
+
port: int = 8000
|
|
63
|
+
workers: int = 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MCPServerConfig(BaseModel):
|
|
67
|
+
"""An MCP server to start and borrow tools from."""
|
|
68
|
+
command: str
|
|
69
|
+
args: list[str] = Field(default_factory=list)
|
|
70
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
71
|
+
enabled: bool = True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Settings(BaseModel):
|
|
75
|
+
"""Global FlowAI configuration."""
|
|
76
|
+
project_name: str = "flowai-project"
|
|
77
|
+
provider: str = "openai"
|
|
78
|
+
model: str = "gpt-4o"
|
|
79
|
+
max_iterations: int = 10
|
|
80
|
+
temperature: float = 0.7
|
|
81
|
+
#: "ask" prompts before a tool changes the machine; "auto" never prompts.
|
|
82
|
+
approval: str = "ask"
|
|
83
|
+
providers: dict[str, ProviderConfig] = Field(default_factory=dict)
|
|
84
|
+
memory: MemoryConfig = Field(default_factory=MemoryConfig)
|
|
85
|
+
server: ServerConfig = Field(default_factory=ServerConfig)
|
|
86
|
+
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------- loading
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def load(cls, config_path: str | Path | None = None) -> Settings:
|
|
92
|
+
"""Load settings: global user config, then project file, then environment."""
|
|
93
|
+
data: dict[str, Any] = {}
|
|
94
|
+
|
|
95
|
+
data.update(cls._load_global())
|
|
96
|
+
project_data, project_path = cls._load_project(config_path)
|
|
97
|
+
data.update(project_data)
|
|
98
|
+
if project_path and "project_name" not in project_data:
|
|
99
|
+
data["project_name"] = project_path.parent.name
|
|
100
|
+
cls._apply_environment(data)
|
|
101
|
+
|
|
102
|
+
return cls(**data)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _load_global() -> dict[str, Any]:
|
|
106
|
+
"""Read ~/.flowai/config.json and export any keys it holds to the environment."""
|
|
107
|
+
if not GLOBAL_CONFIG_PATH.exists():
|
|
108
|
+
return {}
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(GLOBAL_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
111
|
+
except (OSError, json.JSONDecodeError):
|
|
112
|
+
return {}
|
|
113
|
+
if not isinstance(data, dict):
|
|
114
|
+
return {}
|
|
115
|
+
# Provider SDKs and our own providers both read keys from the environment.
|
|
116
|
+
for key, value in data.items():
|
|
117
|
+
if key.endswith("_API_KEY") and isinstance(value, str) and not os.getenv(key):
|
|
118
|
+
os.environ[key] = value
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _load_project(config_path: str | Path | None) -> tuple[dict[str, Any], Path | None]:
|
|
123
|
+
"""Find the nearest project config, walking up from the working directory."""
|
|
124
|
+
path: Path | None = None
|
|
125
|
+
if config_path:
|
|
126
|
+
path = Path(config_path)
|
|
127
|
+
if not path.exists():
|
|
128
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
129
|
+
else:
|
|
130
|
+
for directory in [Path.cwd(), *Path.cwd().parents]:
|
|
131
|
+
for name in PROJECT_CONFIG_NAMES:
|
|
132
|
+
candidate = directory / name
|
|
133
|
+
if candidate.is_file():
|
|
134
|
+
path = candidate
|
|
135
|
+
break
|
|
136
|
+
if path:
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
if not path:
|
|
140
|
+
return {}, None
|
|
141
|
+
|
|
142
|
+
content = path.read_text(encoding="utf-8")
|
|
143
|
+
suffix = path.suffix.lower()
|
|
144
|
+
if suffix in (".yaml", ".yml"):
|
|
145
|
+
import yaml
|
|
146
|
+
parsed = yaml.safe_load(content) or {}
|
|
147
|
+
elif suffix == ".toml":
|
|
148
|
+
try:
|
|
149
|
+
import tomllib
|
|
150
|
+
except ImportError as exc: # Python 3.10
|
|
151
|
+
raise ConfigurationError("flowai.toml needs Python 3.11+; use flowai.yaml instead.") from exc
|
|
152
|
+
parsed = tomllib.loads(content)
|
|
153
|
+
else:
|
|
154
|
+
parsed = json.loads(content)
|
|
155
|
+
|
|
156
|
+
if not isinstance(parsed, dict):
|
|
157
|
+
return {}, path
|
|
158
|
+
parsed.pop("version", None)
|
|
159
|
+
return parsed, path
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _apply_environment(data: dict[str, Any]) -> None:
|
|
163
|
+
"""Environment variables win, and a lone API key is enough to pick a provider."""
|
|
164
|
+
if os.getenv("FLOWAI_PROVIDER"):
|
|
165
|
+
data["provider"] = os.getenv("FLOWAI_PROVIDER")
|
|
166
|
+
data.pop("model", None)
|
|
167
|
+
elif "provider" not in data:
|
|
168
|
+
for provider in PROVIDER_PREFERENCE:
|
|
169
|
+
if os.getenv(PROVIDER_KEY_ENV[provider]):
|
|
170
|
+
data["provider"] = provider
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
provider = data.get("provider")
|
|
174
|
+
if provider and not data.get("model"):
|
|
175
|
+
data["model"] = PROVIDER_DEFAULT_MODEL.get(provider, "gpt-4o")
|
|
176
|
+
|
|
177
|
+
if os.getenv("FLOWAI_MODEL"):
|
|
178
|
+
data["model"] = os.getenv("FLOWAI_MODEL")
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------- helpers
|
|
181
|
+
|
|
182
|
+
def provider_options(self, provider: str | None = None) -> dict[str, Any]:
|
|
183
|
+
"""Keyword arguments for building this provider (key and base URL, if set)."""
|
|
184
|
+
name = provider or self.provider
|
|
185
|
+
block = self.providers.get(name)
|
|
186
|
+
options: dict[str, Any] = {}
|
|
187
|
+
if not block:
|
|
188
|
+
return options
|
|
189
|
+
if block.base_url:
|
|
190
|
+
options["base_url"] = block.base_url
|
|
191
|
+
key = block.api_key or (os.getenv(block.api_key_env) if block.api_key_env else None)
|
|
192
|
+
if key:
|
|
193
|
+
options["api_key"] = key
|
|
194
|
+
return options
|
|
195
|
+
|
|
196
|
+
def has_credentials(self, provider: str | None = None) -> bool:
|
|
197
|
+
"""True when this provider can actually be called right now."""
|
|
198
|
+
name = provider or self.provider
|
|
199
|
+
if name == "ollama":
|
|
200
|
+
return True
|
|
201
|
+
if self.provider_options(name).get("api_key"):
|
|
202
|
+
return True
|
|
203
|
+
env_name = PROVIDER_KEY_ENV.get(name)
|
|
204
|
+
return bool(env_name and os.getenv(env_name))
|
flowai/errors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Error types FlowAI raises, so callers can tell apart what went wrong.
|
|
2
|
+
|
|
3
|
+
The CLI prints these as a single clear line instead of a traceback: a missing
|
|
4
|
+
API key is a setup problem the user can fix, not a crash.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FlowAIError(Exception):
|
|
11
|
+
"""Base class for every error FlowAI raises on purpose."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigurationError(FlowAIError):
|
|
15
|
+
"""Something the user must set is missing or wrong (API key, provider name)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProviderError(FlowAIError):
|
|
19
|
+
"""An LLM provider rejected the request or returned an error response."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, provider: str, status_code: int | None, message: str):
|
|
22
|
+
self.provider = provider
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
self.message = message
|
|
25
|
+
where = f"{provider} API" if status_code is None else f"{provider} API (HTTP {status_code})"
|
|
26
|
+
super().__init__(f"{where}: {message}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ToolError(FlowAIError):
|
|
30
|
+
"""A tool could not be run at all (as opposed to running and failing)."""
|
flowai/mcp/__init__.py
ADDED
flowai/mcp/client.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Model Context Protocol (MCP) Client and Tool Discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from flowai.errors import ToolError
|
|
9
|
+
from flowai.tools.base import BaseTool, ToolDefinition, ToolParameterSchema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MCPClient:
|
|
13
|
+
"""MCP client for stdio servers: lists their tools and adapts them to FlowAI tools.
|
|
14
|
+
|
|
15
|
+
Each tool call starts the server, makes the call, and stops it again. That
|
|
16
|
+
is slower than a held-open session but needs no lifecycle management.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, command: str, args: list[str] | None = None, env: dict[str, str] | None = None):
|
|
20
|
+
self.command = command
|
|
21
|
+
self.args = args or []
|
|
22
|
+
self.env = env or {}
|
|
23
|
+
|
|
24
|
+
async def connect_and_discover_tools(self) -> list[BaseTool]:
|
|
25
|
+
"""Discover tools exposed by the MCP server and adapt them to FlowAI BaseTools."""
|
|
26
|
+
tools: list[BaseTool] = []
|
|
27
|
+
try:
|
|
28
|
+
from mcp import ClientSession, StdioServerParameters
|
|
29
|
+
from mcp.client.stdio import stdio_client
|
|
30
|
+
|
|
31
|
+
server_params = StdioServerParameters(
|
|
32
|
+
command=self.command,
|
|
33
|
+
args=self.args,
|
|
34
|
+
# None inherits the parent environment; {} would wipe PATH.
|
|
35
|
+
env={**os.environ, **self.env} if self.env else None,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# We create an MCP bridge tool wrapper
|
|
39
|
+
class MCPRemoteTool(BaseTool):
|
|
40
|
+
def __init__(self, tool_name: str, desc: str, schema: dict):
|
|
41
|
+
self.name = tool_name
|
|
42
|
+
self.description = desc
|
|
43
|
+
self.schema_definition = ToolDefinition(
|
|
44
|
+
name=tool_name,
|
|
45
|
+
description=desc,
|
|
46
|
+
parameters=ToolParameterSchema(
|
|
47
|
+
type="object",
|
|
48
|
+
properties=schema.get("properties", {}),
|
|
49
|
+
required=schema.get("required", []),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
self.is_async = True
|
|
53
|
+
# An external server can do anything, so its tools ask first.
|
|
54
|
+
self.requires_approval = True
|
|
55
|
+
|
|
56
|
+
async def execute(self, **kwargs: Any) -> Any:
|
|
57
|
+
async with stdio_client(server_params) as (read, write):
|
|
58
|
+
async with ClientSession(read, write) as session:
|
|
59
|
+
await session.initialize()
|
|
60
|
+
res = await session.call_tool(self.name, arguments=kwargs)
|
|
61
|
+
# MCP returns content blocks; hand the agent their text.
|
|
62
|
+
texts = [getattr(block, "text", None) or str(block) for block in res.content]
|
|
63
|
+
text = "\n".join(texts)
|
|
64
|
+
return f"Error from MCP tool: {text}" if getattr(res, "isError", False) else text
|
|
65
|
+
|
|
66
|
+
# Attempt listing tools
|
|
67
|
+
async with stdio_client(server_params) as (read, write):
|
|
68
|
+
async with ClientSession(read, write) as session:
|
|
69
|
+
await session.initialize()
|
|
70
|
+
mcp_tools = await session.list_tools()
|
|
71
|
+
for t in mcp_tools.tools:
|
|
72
|
+
tools.append(MCPRemoteTool(t.name, t.description or "", t.inputSchema))
|
|
73
|
+
|
|
74
|
+
except ImportError as exc:
|
|
75
|
+
raise ToolError("MCP support needs the mcp package: pip install 'flowai-cli[mcp]'") from exc
|
|
76
|
+
|
|
77
|
+
return tools
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""FlowAI Memory Subsystem."""
|
|
2
|
+
|
|
3
|
+
from flowai.memory.base import BaseMemory, MemoryRecord
|
|
4
|
+
from flowai.memory.chroma import ChromaMemory
|
|
5
|
+
from flowai.memory.in_memory import ConversationMemory, InMemory, ShortTermMemory
|
|
6
|
+
from flowai.memory.qdrant import QdrantMemory
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseMemory",
|
|
10
|
+
"MemoryRecord",
|
|
11
|
+
"InMemory",
|
|
12
|
+
"ShortTermMemory",
|
|
13
|
+
"ConversationMemory",
|
|
14
|
+
"QdrantMemory",
|
|
15
|
+
"ChromaMemory",
|
|
16
|
+
]
|
flowai/memory/base.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Memory System Base Classes and Interfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MemoryRecord(BaseModel):
|
|
14
|
+
"""Atomic unit of agent memory storage."""
|
|
15
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
16
|
+
content: str
|
|
17
|
+
role: str = "assistant" # user, assistant, system, context
|
|
18
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
19
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
20
|
+
embedding: list[float] | None = None
|
|
21
|
+
score: float | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseMemory(ABC):
|
|
25
|
+
"""Abstract interface for all memory backends in FlowAI."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def add(self, record: MemoryRecord | str, **metadata: Any) -> str:
|
|
29
|
+
"""Store a new memory item and return its identifier."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def search(self, query: str, limit: int = 5, **kwargs: Any) -> list[MemoryRecord]:
|
|
34
|
+
"""Search memory for relevant records."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def get_recent(self, limit: int = 10) -> list[MemoryRecord]:
|
|
39
|
+
"""Retrieve recent interaction history."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def clear(self) -> None:
|
|
44
|
+
"""Clear all stored memories in this instance/session."""
|
|
45
|
+
...
|
flowai/memory/chroma.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""ChromaDB memory backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from flowai.memory.base import BaseMemory, MemoryRecord
|
|
9
|
+
from flowai.providers.base import BaseProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ChromaMemory(BaseMemory):
|
|
13
|
+
"""Vector memory backed by ChromaDB."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
collection_name: str = "flowai_memory",
|
|
18
|
+
persist_directory: str | None = None,
|
|
19
|
+
embedding_provider: BaseProvider | None = None,
|
|
20
|
+
):
|
|
21
|
+
self.collection_name = collection_name
|
|
22
|
+
self.persist_directory = persist_directory
|
|
23
|
+
self.embedding_provider = embedding_provider
|
|
24
|
+
self._collection = None
|
|
25
|
+
|
|
26
|
+
def _get_collection(self):
|
|
27
|
+
if self._collection is None:
|
|
28
|
+
try:
|
|
29
|
+
import chromadb
|
|
30
|
+
if self.persist_directory:
|
|
31
|
+
client = chromadb.PersistentClient(path=self.persist_directory)
|
|
32
|
+
else:
|
|
33
|
+
client = chromadb.Client()
|
|
34
|
+
self._collection = client.get_or_create_collection(name=self.collection_name)
|
|
35
|
+
except ImportError as exc:
|
|
36
|
+
raise ImportError("chromadb is required for ChromaMemory. Install with: pip install 'flowai-cli[chroma]'") from exc
|
|
37
|
+
return self._collection
|
|
38
|
+
|
|
39
|
+
async def add(self, record: MemoryRecord | str, **metadata: Any) -> str:
|
|
40
|
+
col = self._get_collection()
|
|
41
|
+
rec = MemoryRecord(content=record, metadata=metadata) if isinstance(record, str) else record
|
|
42
|
+
rec_id = rec.id or str(uuid.uuid4())
|
|
43
|
+
|
|
44
|
+
embeddings = None
|
|
45
|
+
if self.embedding_provider:
|
|
46
|
+
vecs = await self.embedding_provider.embeddings([rec.content])
|
|
47
|
+
if vecs:
|
|
48
|
+
embeddings = vecs
|
|
49
|
+
|
|
50
|
+
col.add(
|
|
51
|
+
ids=[rec_id],
|
|
52
|
+
documents=[rec.content],
|
|
53
|
+
metadatas=[{"role": rec.role, **rec.metadata}],
|
|
54
|
+
embeddings=embeddings,
|
|
55
|
+
)
|
|
56
|
+
return rec_id
|
|
57
|
+
|
|
58
|
+
async def search(self, query: str, limit: int = 5, **kwargs: Any) -> list[MemoryRecord]:
|
|
59
|
+
col = self._get_collection()
|
|
60
|
+
query_embeddings = None
|
|
61
|
+
if self.embedding_provider:
|
|
62
|
+
vecs = await self.embedding_provider.embeddings([query])
|
|
63
|
+
if vecs:
|
|
64
|
+
query_embeddings = vecs
|
|
65
|
+
|
|
66
|
+
res = col.query(
|
|
67
|
+
query_texts=[query] if not query_embeddings else None,
|
|
68
|
+
query_embeddings=query_embeddings,
|
|
69
|
+
n_results=limit,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
records = []
|
|
73
|
+
if res and res.get("documents"):
|
|
74
|
+
docs = res["documents"][0]
|
|
75
|
+
ids = res["ids"][0]
|
|
76
|
+
metadatas = res.get("metadatas", [[]])[0]
|
|
77
|
+
for i, doc in enumerate(docs):
|
|
78
|
+
meta = metadatas[i] if i < len(metadatas) else {}
|
|
79
|
+
records.append(
|
|
80
|
+
MemoryRecord(
|
|
81
|
+
id=ids[i],
|
|
82
|
+
content=doc,
|
|
83
|
+
role=meta.get("role", "assistant"),
|
|
84
|
+
metadata=meta,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return records
|
|
88
|
+
|
|
89
|
+
async def get_recent(self, limit: int = 10) -> list[MemoryRecord]:
|
|
90
|
+
return await self.search(query="", limit=limit)
|
|
91
|
+
|
|
92
|
+
async def clear(self) -> None:
|
|
93
|
+
col = self._get_collection()
|
|
94
|
+
# delete all items
|
|
95
|
+
existing = col.get()
|
|
96
|
+
if existing and existing.get("ids"):
|
|
97
|
+
col.delete(ids=existing["ids"])
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""In-memory, Short-term, and Conversation memory implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from flowai.memory.base import BaseMemory, MemoryRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InMemory(BaseMemory):
|
|
11
|
+
"""Fast in-memory store for session or ephemeral testing."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self._records: list[MemoryRecord] = []
|
|
15
|
+
|
|
16
|
+
async def add(self, record: MemoryRecord | str, **metadata: Any) -> str:
|
|
17
|
+
if isinstance(record, str):
|
|
18
|
+
role = metadata.pop("role", "assistant")
|
|
19
|
+
rec = MemoryRecord(content=record, role=role, metadata=metadata)
|
|
20
|
+
else:
|
|
21
|
+
rec = record
|
|
22
|
+
self._records.append(rec)
|
|
23
|
+
return rec.id
|
|
24
|
+
|
|
25
|
+
async def search(self, query: str, limit: int = 5, **kwargs: Any) -> list[MemoryRecord]:
|
|
26
|
+
"""Simple keyword substring search over in-memory records."""
|
|
27
|
+
q = query.lower()
|
|
28
|
+
matched = []
|
|
29
|
+
for r in reversed(self._records):
|
|
30
|
+
if q in r.content.lower():
|
|
31
|
+
matched.append(r)
|
|
32
|
+
if len(matched) >= limit:
|
|
33
|
+
break
|
|
34
|
+
return matched
|
|
35
|
+
|
|
36
|
+
async def get_recent(self, limit: int = 10) -> list[MemoryRecord]:
|
|
37
|
+
return self._records[-limit:]
|
|
38
|
+
|
|
39
|
+
async def clear(self) -> None:
|
|
40
|
+
self._records.clear()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ShortTermMemory(InMemory):
|
|
44
|
+
"""Sliding-window short term memory buffer."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, capacity: int = 20):
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.capacity = capacity
|
|
49
|
+
|
|
50
|
+
async def add(self, record: MemoryRecord | str, **metadata: Any) -> str:
|
|
51
|
+
rec_id = await super().add(record, **metadata)
|
|
52
|
+
if len(self._records) > self.capacity:
|
|
53
|
+
self._records = self._records[-self.capacity:]
|
|
54
|
+
return rec_id
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ConversationMemory(InMemory):
|
|
58
|
+
"""Conversation history buffer preserving turn order and message roles."""
|
|
59
|
+
|
|
60
|
+
def format_history(self) -> str:
|
|
61
|
+
"""Format history into plain dialogue string."""
|
|
62
|
+
lines = []
|
|
63
|
+
for r in self._records:
|
|
64
|
+
lines.append(f"{r.role.capitalize()}: {r.content}")
|
|
65
|
+
return "\n".join(lines)
|
flowai/memory/qdrant.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Qdrant Vector Memory backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from flowai.memory.base import BaseMemory, MemoryRecord
|
|
9
|
+
from flowai.providers.base import BaseProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QdrantMemory(BaseMemory):
|
|
13
|
+
"""Semantic vector memory using Qdrant."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
url: str = "http://localhost:6333",
|
|
18
|
+
api_key: str | None = None,
|
|
19
|
+
collection_name: str = "flowai_memory",
|
|
20
|
+
vector_size: int = 1536,
|
|
21
|
+
embedding_provider: BaseProvider | None = None,
|
|
22
|
+
):
|
|
23
|
+
self.url = url
|
|
24
|
+
self.api_key = api_key
|
|
25
|
+
self.collection_name = collection_name
|
|
26
|
+
self.vector_size = vector_size
|
|
27
|
+
self.embedding_provider = embedding_provider
|
|
28
|
+
self._client = None
|
|
29
|
+
|
|
30
|
+
def _get_client(self):
|
|
31
|
+
if self._client is None:
|
|
32
|
+
try:
|
|
33
|
+
from qdrant_client import AsyncQdrantClient
|
|
34
|
+
self._client = AsyncQdrantClient(url=self.url, api_key=self.api_key)
|
|
35
|
+
except ImportError as exc:
|
|
36
|
+
raise ImportError("qdrant-client is required for QdrantMemory. Install with: pip install 'flowai-cli[qdrant]'") from exc
|
|
37
|
+
return self._client
|
|
38
|
+
|
|
39
|
+
async def add(self, record: MemoryRecord | str, **metadata: Any) -> str:
|
|
40
|
+
client = self._get_client()
|
|
41
|
+
from qdrant_client.http import models
|
|
42
|
+
|
|
43
|
+
rec = MemoryRecord(content=record, metadata=metadata) if isinstance(record, str) else record
|
|
44
|
+
|
|
45
|
+
# Generate embedding vector if provider available
|
|
46
|
+
vector = rec.embedding
|
|
47
|
+
if not vector and self.embedding_provider:
|
|
48
|
+
vecs = await self.embedding_provider.embeddings([rec.content])
|
|
49
|
+
vector = vecs[0] if vecs else [0.0] * self.vector_size
|
|
50
|
+
elif not vector:
|
|
51
|
+
vector = [0.0] * self.vector_size
|
|
52
|
+
|
|
53
|
+
point_id = str(uuid.uuid4())
|
|
54
|
+
await client.upsert(
|
|
55
|
+
collection_name=self.collection_name,
|
|
56
|
+
points=[
|
|
57
|
+
models.PointStruct(
|
|
58
|
+
id=point_id,
|
|
59
|
+
vector=vector,
|
|
60
|
+
payload={"content": rec.content, "role": rec.role, "metadata": rec.metadata},
|
|
61
|
+
)
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
return point_id
|
|
65
|
+
|
|
66
|
+
async def search(self, query: str, limit: int = 5, **kwargs: Any) -> list[MemoryRecord]:
|
|
67
|
+
client = self._get_client()
|
|
68
|
+
query_vector = [0.0] * self.vector_size
|
|
69
|
+
if self.embedding_provider:
|
|
70
|
+
vecs = await self.embedding_provider.embeddings([query])
|
|
71
|
+
if vecs:
|
|
72
|
+
query_vector = vecs[0]
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
results = await client.search(
|
|
76
|
+
collection_name=self.collection_name,
|
|
77
|
+
query_vector=query_vector,
|
|
78
|
+
limit=limit,
|
|
79
|
+
)
|
|
80
|
+
records = []
|
|
81
|
+
for hit in results:
|
|
82
|
+
payload = hit.payload or {}
|
|
83
|
+
records.append(
|
|
84
|
+
MemoryRecord(
|
|
85
|
+
id=str(hit.id),
|
|
86
|
+
content=payload.get("content", ""),
|
|
87
|
+
role=payload.get("role", "assistant"),
|
|
88
|
+
metadata=payload.get("metadata", {}),
|
|
89
|
+
score=hit.score,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return records
|
|
93
|
+
except Exception:
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
async def get_recent(self, limit: int = 10) -> list[MemoryRecord]:
|
|
97
|
+
return await self.search(query="", limit=limit)
|
|
98
|
+
|
|
99
|
+
async def clear(self) -> None:
|
|
100
|
+
client = self._get_client()
|
|
101
|
+
try:
|
|
102
|
+
await client.delete_collection(self.collection_name)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""FlowAI Observability System."""
|
|
2
|
+
|
|
3
|
+
from flowai.observability.events import EventBus, EventHandler, StreamEvent
|
|
4
|
+
from flowai.observability.opentelemetry import LangSmithExporter, OpenTelemetryExporter
|
|
5
|
+
from flowai.observability.tracer import Span, Tracer
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EventBus",
|
|
9
|
+
"StreamEvent",
|
|
10
|
+
"EventHandler",
|
|
11
|
+
"Tracer",
|
|
12
|
+
"Span",
|
|
13
|
+
"OpenTelemetryExporter",
|
|
14
|
+
"LangSmithExporter",
|
|
15
|
+
]
|