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.
Files changed (56) hide show
  1. flowai/__init__.py +72 -0
  2. flowai/__main__.py +8 -0
  3. flowai/agents/__init__.py +21 -0
  4. flowai/agents/base.py +61 -0
  5. flowai/agents/patterns/__init__.py +11 -0
  6. flowai/agents/patterns/debate.py +79 -0
  7. flowai/agents/patterns/reflection.py +74 -0
  8. flowai/agents/specialized.py +87 -0
  9. flowai/agents/standard.py +176 -0
  10. flowai/cli/__init__.py +5 -0
  11. flowai/cli/main.py +562 -0
  12. flowai/cli/ui.py +98 -0
  13. flowai/config/__init__.py +5 -0
  14. flowai/config/settings.py +204 -0
  15. flowai/errors.py +30 -0
  16. flowai/mcp/__init__.py +5 -0
  17. flowai/mcp/client.py +77 -0
  18. flowai/memory/__init__.py +16 -0
  19. flowai/memory/base.py +45 -0
  20. flowai/memory/chroma.py +97 -0
  21. flowai/memory/in_memory.py +65 -0
  22. flowai/memory/qdrant.py +104 -0
  23. flowai/observability/__init__.py +15 -0
  24. flowai/observability/events.py +45 -0
  25. flowai/observability/opentelemetry.py +56 -0
  26. flowai/observability/tracer.py +80 -0
  27. flowai/plugins/__init__.py +5 -0
  28. flowai/plugins/loader.py +31 -0
  29. flowai/providers/__init__.py +35 -0
  30. flowai/providers/anthropic.py +268 -0
  31. flowai/providers/base.py +180 -0
  32. flowai/providers/gemini.py +230 -0
  33. flowai/providers/nvidia.py +57 -0
  34. flowai/providers/ollama.py +237 -0
  35. flowai/providers/openai.py +274 -0
  36. flowai/providers/registry.py +50 -0
  37. flowai/runtime/__init__.py +5 -0
  38. flowai/runtime/session.py +76 -0
  39. flowai/server/__init__.py +5 -0
  40. flowai/server/app.py +133 -0
  41. flowai/tools/__init__.py +39 -0
  42. flowai/tools/base.py +56 -0
  43. flowai/tools/builtin.py +128 -0
  44. flowai/tools/decorator.py +103 -0
  45. flowai/tools/files.py +197 -0
  46. flowai/tools/registry.py +84 -0
  47. flowai/tools/web.py +94 -0
  48. flowai/workflow/__init__.py +15 -0
  49. flowai/workflow/checkpoint.py +73 -0
  50. flowai/workflow/engine.py +144 -0
  51. flowai/workflow/state.py +43 -0
  52. flowai_cli-0.2.0.dist-info/METADATA +223 -0
  53. flowai_cli-0.2.0.dist-info/RECORD +56 -0
  54. flowai_cli-0.2.0.dist-info/WHEEL +4 -0
  55. flowai_cli-0.2.0.dist-info/entry_points.txt +2 -0
  56. 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
@@ -0,0 +1,5 @@
1
+ """FlowAI MCP Support."""
2
+
3
+ from flowai.mcp.client import MCPClient
4
+
5
+ __all__ = ["MCPClient"]
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
+ ...
@@ -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)
@@ -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
+ ]