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
flowai/tools/registry.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Tool Registry for tool registration, lookup, schema export, and safe execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from flowai.tools.base import BaseTool
|
|
10
|
+
from flowai.tools.decorator import tool
|
|
11
|
+
|
|
12
|
+
# Called with (tool_name, arguments); return False to refuse the call.
|
|
13
|
+
Approver = Callable[[str, dict], Any]
|
|
14
|
+
|
|
15
|
+
DENIED_MESSAGE = "The user declined this call. Do not retry it; ask what they would prefer instead."
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolRegistry:
|
|
19
|
+
"""Central store for agent tools.
|
|
20
|
+
|
|
21
|
+
A registry with an `approver` asks before running any tool marked
|
|
22
|
+
`requires_approval`. Library callers get no approver (they wrote the code
|
|
23
|
+
themselves); the CLI attaches one, because there the model picks the
|
|
24
|
+
commands and the user should see them first.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
tools: Sequence[BaseTool | Callable[..., Any]] | None = None,
|
|
30
|
+
approver: Approver | None = None,
|
|
31
|
+
):
|
|
32
|
+
self._tools: dict[str, BaseTool] = {}
|
|
33
|
+
self.approver = approver
|
|
34
|
+
if tools:
|
|
35
|
+
for t in tools:
|
|
36
|
+
self.register(t)
|
|
37
|
+
|
|
38
|
+
def register(self, t: BaseTool | Callable[..., Any]) -> BaseTool:
|
|
39
|
+
"""Register a tool instance or raw function."""
|
|
40
|
+
if not isinstance(t, BaseTool):
|
|
41
|
+
t = tool(t)
|
|
42
|
+
self._tools[t.name] = t
|
|
43
|
+
return t
|
|
44
|
+
|
|
45
|
+
def set_approver(self, approver: Approver | None) -> None:
|
|
46
|
+
"""Attach (or clear) the callback consulted before approval-gated tools run."""
|
|
47
|
+
self.approver = approver
|
|
48
|
+
|
|
49
|
+
def get(self, name: str) -> BaseTool | None:
|
|
50
|
+
"""Retrieve tool by name."""
|
|
51
|
+
return self._tools.get(name)
|
|
52
|
+
|
|
53
|
+
def list_tools(self) -> list[BaseTool]:
|
|
54
|
+
"""Return list of all registered tools."""
|
|
55
|
+
return list(self._tools.values())
|
|
56
|
+
|
|
57
|
+
def get_schemas(self, format: str = "openai") -> list[dict[str, Any]]:
|
|
58
|
+
"""Export tool definitions in openai or anthropic schema format."""
|
|
59
|
+
if format == "anthropic":
|
|
60
|
+
return [t.to_anthropic_schema() for t in self._tools.values()]
|
|
61
|
+
return [t.to_openai_schema() for t in self._tools.values()]
|
|
62
|
+
|
|
63
|
+
async def _approved(self, tool_instance: BaseTool, kwargs: dict[str, Any]) -> bool:
|
|
64
|
+
if not tool_instance.requires_approval or self.approver is None:
|
|
65
|
+
return True
|
|
66
|
+
verdict = self.approver(tool_instance.name, kwargs)
|
|
67
|
+
if asyncio.iscoroutine(verdict):
|
|
68
|
+
verdict = await verdict
|
|
69
|
+
return bool(verdict)
|
|
70
|
+
|
|
71
|
+
async def execute(self, tool_name: str, **kwargs: Any) -> Any:
|
|
72
|
+
"""Execute tool safely, surfacing errors rather than crashing the harness."""
|
|
73
|
+
tool_instance = self.get(tool_name)
|
|
74
|
+
if not tool_instance:
|
|
75
|
+
return (
|
|
76
|
+
f"Error: tool '{tool_name}' does not exist. "
|
|
77
|
+
f"Available tools: {', '.join(self._tools) or '(none)'}"
|
|
78
|
+
)
|
|
79
|
+
if not await self._approved(tool_instance, kwargs):
|
|
80
|
+
return DENIED_MESSAGE
|
|
81
|
+
try:
|
|
82
|
+
return await tool_instance.execute(**kwargs)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
return f"Error executing {tool_name}: {type(exc).__name__}: {exc}"
|
flowai/tools/web.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Web tools: a real search over DuckDuckGo's HTML endpoint, and a page fetcher.
|
|
2
|
+
|
|
3
|
+
Neither tool invents results. If the network call fails, the agent is told it
|
|
4
|
+
failed, which is far more useful to it than a plausible-looking fabrication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
import re
|
|
11
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from flowai.tools.decorator import tool
|
|
16
|
+
|
|
17
|
+
SEARCH_URL = "https://html.duckduckgo.com/html/"
|
|
18
|
+
USER_AGENT = "Mozilla/5.0 (compatible; FlowAI/0.1; +https://github.com/Ajayramaswamy2005/FlowAI)"
|
|
19
|
+
|
|
20
|
+
_RESULT_RE = re.compile(
|
|
21
|
+
r'<a[^>]+class="result__a"[^>]+href="(?P<href>[^"]+)"[^>]*>(?P<title>.*?)</a>',
|
|
22
|
+
re.DOTALL,
|
|
23
|
+
)
|
|
24
|
+
_SNIPPET_RE = re.compile(r'<a[^>]+class="result__snippet"[^>]*>(?P<snippet>.*?)</a>', re.DOTALL)
|
|
25
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
26
|
+
_SCRIPT_RE = re.compile(r"<(script|style)\b.*?</\1>", re.DOTALL | re.IGNORECASE)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _strip_tags(markup: str) -> str:
|
|
30
|
+
return html.unescape(_TAG_RE.sub("", markup)).strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _clean_link(href: str) -> str:
|
|
34
|
+
"""DuckDuckGo wraps results in a redirect; unwrap it back to the real URL."""
|
|
35
|
+
if href.startswith("//"):
|
|
36
|
+
href = "https:" + href
|
|
37
|
+
parsed = urlparse(href)
|
|
38
|
+
if "duckduckgo.com" in parsed.netloc and parsed.path.startswith("/l/"):
|
|
39
|
+
target = parse_qs(parsed.query).get("uddg")
|
|
40
|
+
if target:
|
|
41
|
+
return unquote(target[0])
|
|
42
|
+
return href
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@tool("search_web", description="Search the web with DuckDuckGo and return the top results.")
|
|
46
|
+
async def search_web(query: str, max_results: int = 5) -> str:
|
|
47
|
+
"""Run a web search and return titles, URLs and snippets."""
|
|
48
|
+
try:
|
|
49
|
+
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
|
50
|
+
response = await client.post(
|
|
51
|
+
SEARCH_URL,
|
|
52
|
+
data={"q": query},
|
|
53
|
+
headers={"User-Agent": USER_AGENT},
|
|
54
|
+
)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
body = response.text
|
|
57
|
+
except httpx.HTTPError as exc:
|
|
58
|
+
return f"Web search failed ({type(exc).__name__}: {exc}). No results were retrieved."
|
|
59
|
+
|
|
60
|
+
titles = _RESULT_RE.findall(body)
|
|
61
|
+
snippets = _SNIPPET_RE.findall(body)
|
|
62
|
+
|
|
63
|
+
if not titles:
|
|
64
|
+
return f"No results found for '{query}'."
|
|
65
|
+
|
|
66
|
+
lines = []
|
|
67
|
+
for index, (href, title) in enumerate(titles[:max_results]):
|
|
68
|
+
snippet = _strip_tags(snippets[index]) if index < len(snippets) else ""
|
|
69
|
+
lines.append(f"{index + 1}. {_strip_tags(title)}\n {_clean_link(href)}\n {snippet}".rstrip())
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@tool("fetch_url", description="Fetch a web page and return its visible text.")
|
|
74
|
+
async def fetch_url(url: str, max_chars: int = 8000) -> str:
|
|
75
|
+
"""Download a URL and strip it down to readable text."""
|
|
76
|
+
if not url.startswith(("http://", "https://")):
|
|
77
|
+
return "Error: url must start with http:// or https://."
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
81
|
+
response = await client.get(url, headers={"User-Agent": USER_AGENT})
|
|
82
|
+
response.raise_for_status()
|
|
83
|
+
except httpx.HTTPError as exc:
|
|
84
|
+
return f"Fetch failed ({type(exc).__name__}: {exc})."
|
|
85
|
+
|
|
86
|
+
content_type = response.headers.get("content-type", "")
|
|
87
|
+
if "html" not in content_type and "text" not in content_type:
|
|
88
|
+
return f"Error: {url} returned {content_type or 'an unknown content type'}, which is not readable text."
|
|
89
|
+
|
|
90
|
+
text = _strip_tags(_SCRIPT_RE.sub(" ", response.text))
|
|
91
|
+
text = re.sub(r"\n{3,}", "\n\n", re.sub(r"[ \t]{2,}", " ", text))
|
|
92
|
+
if len(text) > max_chars:
|
|
93
|
+
text = text[:max_chars] + f"\n... (truncated at {max_chars} characters)"
|
|
94
|
+
return text or f"{url} returned no readable text."
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""FlowAI Workflow Engine & State Management."""
|
|
2
|
+
|
|
3
|
+
from flowai.workflow.checkpoint import BaseCheckpointer, FileCheckpointer, InMemoryCheckpointer
|
|
4
|
+
from flowai.workflow.engine import END, START, Workflow
|
|
5
|
+
from flowai.workflow.state import AgentState
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AgentState",
|
|
9
|
+
"BaseCheckpointer",
|
|
10
|
+
"InMemoryCheckpointer",
|
|
11
|
+
"FileCheckpointer",
|
|
12
|
+
"Workflow",
|
|
13
|
+
"START",
|
|
14
|
+
"END",
|
|
15
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""State checkpointing and resumable execution persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from flowai.workflow.state import AgentState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseCheckpointer(ABC):
|
|
13
|
+
"""Abstract interface for storing state checkpoints."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def save(self, state: AgentState) -> str:
|
|
17
|
+
"""Persist state and return checkpoint ID."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def load(self, checkpoint_id: str) -> AgentState | None:
|
|
22
|
+
"""Retrieve state from checkpoint."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def list_checkpoints(self, session_id: str) -> list[str]:
|
|
27
|
+
"""List checkpoint IDs for a given session."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InMemoryCheckpointer(BaseCheckpointer):
|
|
32
|
+
"""Ephemeral in-memory checkpoint store."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self._checkpoints: dict[str, dict[str, Any]] = {}
|
|
36
|
+
self._session_index: dict[str, list[str]] = {}
|
|
37
|
+
|
|
38
|
+
async def save(self, state: AgentState) -> str:
|
|
39
|
+
checkpoint_id = f"{state.session_id}_step_{state.step}"
|
|
40
|
+
self._checkpoints[checkpoint_id] = state.model_dump()
|
|
41
|
+
self._session_index.setdefault(state.session_id, []).append(checkpoint_id)
|
|
42
|
+
return checkpoint_id
|
|
43
|
+
|
|
44
|
+
async def load(self, checkpoint_id: str) -> AgentState | None:
|
|
45
|
+
data = self._checkpoints.get(checkpoint_id)
|
|
46
|
+
return AgentState(**data) if data else None
|
|
47
|
+
|
|
48
|
+
async def list_checkpoints(self, session_id: str) -> list[str]:
|
|
49
|
+
return self._session_index.get(session_id, [])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FileCheckpointer(BaseCheckpointer):
|
|
53
|
+
"""Filesystem JSON checkpointer for local resumable workflows."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, directory: str = ".flowai/checkpoints"):
|
|
56
|
+
self.directory = Path(directory)
|
|
57
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
async def save(self, state: AgentState) -> str:
|
|
60
|
+
checkpoint_id = f"{state.session_id}_step_{state.step}"
|
|
61
|
+
path = self.directory / f"{checkpoint_id}.json"
|
|
62
|
+
path.write_text(state.model_dump_json(indent=2), encoding="utf-8")
|
|
63
|
+
return checkpoint_id
|
|
64
|
+
|
|
65
|
+
async def load(self, checkpoint_id: str) -> AgentState | None:
|
|
66
|
+
path = self.directory / f"{checkpoint_id}.json"
|
|
67
|
+
if not path.exists():
|
|
68
|
+
return None
|
|
69
|
+
return AgentState.model_validate_json(path.read_text(encoding="utf-8"))
|
|
70
|
+
|
|
71
|
+
async def list_checkpoints(self, session_id: str) -> list[str]:
|
|
72
|
+
files = self.directory.glob(f"{session_id}_step_*.json")
|
|
73
|
+
return sorted([f.stem for f in files])
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Async Workflow Engine with DAG waves, branching, loops, and parallel execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable, Coroutine
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from flowai.agents.base import BaseAgent
|
|
10
|
+
from flowai.observability.events import EventBus
|
|
11
|
+
from flowai.workflow.checkpoint import BaseCheckpointer
|
|
12
|
+
from flowai.workflow.state import AgentState
|
|
13
|
+
|
|
14
|
+
NodeFn = Callable[[AgentState], AgentState | Coroutine[Any, Any, AgentState]]
|
|
15
|
+
RouterFn = Callable[[AgentState], str | Coroutine[Any, Any, str]]
|
|
16
|
+
|
|
17
|
+
START = "START"
|
|
18
|
+
END = "END"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Workflow:
|
|
22
|
+
"""Async Graph Workflow Engine supporting branching, loops, and parallel nodes."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
name: str = "Workflow",
|
|
27
|
+
checkpointer: BaseCheckpointer | None = None,
|
|
28
|
+
event_bus: EventBus | None = None,
|
|
29
|
+
max_steps: int = 50,
|
|
30
|
+
):
|
|
31
|
+
self.name = name
|
|
32
|
+
self.nodes: dict[str, NodeFn] = {}
|
|
33
|
+
self.edges: dict[str, list[str]] = {}
|
|
34
|
+
self.routers: dict[str, RouterFn] = {}
|
|
35
|
+
self.checkpointer = checkpointer
|
|
36
|
+
self.event_bus = event_bus or EventBus()
|
|
37
|
+
self.max_steps = max_steps
|
|
38
|
+
|
|
39
|
+
def add_node(self, name: str, fn_or_agent: NodeFn | BaseAgent) -> Workflow:
|
|
40
|
+
"""Register a node function or agent."""
|
|
41
|
+
if isinstance(fn_or_agent, BaseAgent):
|
|
42
|
+
agent = fn_or_agent
|
|
43
|
+
|
|
44
|
+
async def agent_node(state: AgentState) -> AgentState:
|
|
45
|
+
prompt = state.get("task", "") or (state.messages[-1].content if state.messages else "")
|
|
46
|
+
reply = await agent.run(prompt)
|
|
47
|
+
return state.set(f"{name}_output", reply).append_message({"role": "assistant", "content": reply})
|
|
48
|
+
|
|
49
|
+
self.nodes[name] = agent_node
|
|
50
|
+
else:
|
|
51
|
+
self.nodes[name] = fn_or_agent
|
|
52
|
+
self.edges.setdefault(name, [])
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def add_agent(self, agent: BaseAgent, name: str | None = None) -> Workflow:
|
|
56
|
+
"""Convenience method to register an agent as a workflow node."""
|
|
57
|
+
node_name = name or agent.name
|
|
58
|
+
return self.add_node(node_name, agent)
|
|
59
|
+
|
|
60
|
+
def add_edge(self, from_node: str, to_node: str) -> Workflow:
|
|
61
|
+
"""Add a directed edge from one node to another."""
|
|
62
|
+
self.edges.setdefault(from_node, []).append(to_node)
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def add_conditional_edge(self, from_node: str, router: RouterFn) -> Workflow:
|
|
66
|
+
"""Add a dynamic routing function to branch or loop based on state."""
|
|
67
|
+
self.routers[from_node] = router
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
async def execute_parallel(self, node_names: list[str], state: AgentState) -> AgentState:
|
|
71
|
+
"""Run multiple ready nodes in parallel and merge disjoint state updates."""
|
|
72
|
+
tasks = []
|
|
73
|
+
for name in node_names:
|
|
74
|
+
fn = self.nodes[name]
|
|
75
|
+
if asyncio.iscoroutinefunction(fn):
|
|
76
|
+
tasks.append(fn(state))
|
|
77
|
+
else:
|
|
78
|
+
tasks.append(asyncio.to_thread(fn, state))
|
|
79
|
+
|
|
80
|
+
results: list[AgentState] = await asyncio.gather(*tasks)
|
|
81
|
+
merged = state
|
|
82
|
+
for res in results:
|
|
83
|
+
merged = merged.update_values(res.values)
|
|
84
|
+
for m in res.messages[len(state.messages):]:
|
|
85
|
+
merged = merged.append_message(m)
|
|
86
|
+
return merged
|
|
87
|
+
|
|
88
|
+
async def run(self, initial_input: str | dict[str, Any] | AgentState, entry_point: str = "START") -> AgentState:
|
|
89
|
+
"""Execute the workflow starting from entry_point until END or max_steps."""
|
|
90
|
+
if isinstance(initial_input, AgentState):
|
|
91
|
+
state = initial_input
|
|
92
|
+
elif isinstance(initial_input, str):
|
|
93
|
+
state = AgentState(values={"task": initial_input}).append_message({"role": "user", "content": initial_input})
|
|
94
|
+
else:
|
|
95
|
+
state = AgentState(values=initial_input)
|
|
96
|
+
|
|
97
|
+
current_node = entry_point
|
|
98
|
+
# If START is not an explicit node, jump to first edge from START or first registered node
|
|
99
|
+
if current_node == START and START not in self.nodes:
|
|
100
|
+
if START in self.edges and self.edges[START]:
|
|
101
|
+
current_node = self.edges[START][0]
|
|
102
|
+
elif self.nodes:
|
|
103
|
+
current_node = list(self.nodes.keys())[0]
|
|
104
|
+
else:
|
|
105
|
+
return state
|
|
106
|
+
|
|
107
|
+
step_count = 0
|
|
108
|
+
while current_node != END and step_count < self.max_steps:
|
|
109
|
+
step_count += 1
|
|
110
|
+
await self.event_bus.publish("node_start", {"node": current_node, "step": step_count})
|
|
111
|
+
|
|
112
|
+
if self.checkpointer:
|
|
113
|
+
await self.checkpointer.save(state)
|
|
114
|
+
|
|
115
|
+
fn = self.nodes.get(current_node)
|
|
116
|
+
if fn:
|
|
117
|
+
if asyncio.iscoroutinefunction(fn):
|
|
118
|
+
state = await fn(state)
|
|
119
|
+
else:
|
|
120
|
+
state = await asyncio.to_thread(fn, state)
|
|
121
|
+
|
|
122
|
+
await self.event_bus.publish("node_end", {"node": current_node, "step": step_count})
|
|
123
|
+
|
|
124
|
+
# Check dynamic router
|
|
125
|
+
if current_node in self.routers:
|
|
126
|
+
router = self.routers[current_node]
|
|
127
|
+
next_node = await router(state) if asyncio.iscoroutinefunction(router) else router(state)
|
|
128
|
+
current_node = next_node
|
|
129
|
+
# Check static edges
|
|
130
|
+
elif self.edges.get(current_node):
|
|
131
|
+
targets = self.edges[current_node]
|
|
132
|
+
if len(targets) > 1:
|
|
133
|
+
# Parallel branch execution
|
|
134
|
+
state = await self.execute_parallel(targets, state)
|
|
135
|
+
current_node = END
|
|
136
|
+
else:
|
|
137
|
+
current_node = targets[0]
|
|
138
|
+
else:
|
|
139
|
+
current_node = END
|
|
140
|
+
|
|
141
|
+
if self.checkpointer:
|
|
142
|
+
await self.checkpointer.save(state)
|
|
143
|
+
|
|
144
|
+
return state
|
flowai/workflow/state.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""State Management - Pydantic-based State containers and history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from flowai.providers.base import Message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentState(BaseModel):
|
|
15
|
+
"""Immutable state container for LangGraph-style workflows."""
|
|
16
|
+
session_id: str = Field(default_factory=lambda: f"ses_{uuid.uuid4().hex[:8]}")
|
|
17
|
+
step: int = 0
|
|
18
|
+
messages: list[Message] = Field(default_factory=list)
|
|
19
|
+
values: dict[str, Any] = Field(default_factory=dict)
|
|
20
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
23
|
+
return self.values.get(key, default)
|
|
24
|
+
|
|
25
|
+
def set(self, key: str, value: Any) -> AgentState:
|
|
26
|
+
"""Return a new copy of state with updated key-value."""
|
|
27
|
+
new_values = copy.deepcopy(self.values)
|
|
28
|
+
new_values[key] = value
|
|
29
|
+
return self.model_copy(update={"values": new_values, "step": self.step + 1})
|
|
30
|
+
|
|
31
|
+
def update_values(self, updates: dict[str, Any]) -> AgentState:
|
|
32
|
+
"""Merge a dictionary into state values."""
|
|
33
|
+
new_values = copy.deepcopy(self.values)
|
|
34
|
+
new_values.update(updates)
|
|
35
|
+
return self.model_copy(update={"values": new_values, "step": self.step + 1})
|
|
36
|
+
|
|
37
|
+
def append_message(self, message: Message | dict[str, Any]) -> AgentState:
|
|
38
|
+
new_msgs = list(self.messages)
|
|
39
|
+
if isinstance(message, dict):
|
|
40
|
+
new_msgs.append(Message(**message))
|
|
41
|
+
else:
|
|
42
|
+
new_msgs.append(message)
|
|
43
|
+
return self.model_copy(update={"messages": new_msgs})
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: flowai-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: FlowAI: an open-source, extensible Python agent harness with a terminal-first CLI.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Ajayramaswamy2005/FlowAI
|
|
6
|
+
Project-URL: Source, https://github.com/Ajayramaswamy2005/FlowAI
|
|
7
|
+
Project-URL: Issues, https://github.com/Ajayramaswamy2005/FlowAI/issues
|
|
8
|
+
Author: Ajay Rama Swamy Katragaada
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,cli,fastapi,llm,mcp,nvidia,ollama,pydantic,workflow
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Requires-Dist: pydantic>=2.7.0
|
|
24
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
25
|
+
Provides-Extra: all
|
|
26
|
+
Requires-Dist: chromadb>=0.5.0; extra == 'all'
|
|
27
|
+
Requires-Dist: fastapi>=0.111.0; extra == 'all'
|
|
28
|
+
Requires-Dist: mcp>=1.0.0; extra == 'all'
|
|
29
|
+
Requires-Dist: opentelemetry-api>=1.25.0; extra == 'all'
|
|
30
|
+
Requires-Dist: opentelemetry-sdk>=1.25.0; extra == 'all'
|
|
31
|
+
Requires-Dist: qdrant-client>=1.9.0; extra == 'all'
|
|
32
|
+
Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'all'
|
|
33
|
+
Provides-Extra: chroma
|
|
34
|
+
Requires-Dist: chromadb>=0.5.0; extra == 'chroma'
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: fastapi>=0.111.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
40
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
41
|
+
Provides-Extra: mcp
|
|
42
|
+
Requires-Dist: mcp>=1.0.0; extra == 'mcp'
|
|
43
|
+
Provides-Extra: observability
|
|
44
|
+
Requires-Dist: opentelemetry-api>=1.25.0; extra == 'observability'
|
|
45
|
+
Requires-Dist: opentelemetry-sdk>=1.25.0; extra == 'observability'
|
|
46
|
+
Provides-Extra: qdrant
|
|
47
|
+
Requires-Dist: qdrant-client>=1.9.0; extra == 'qdrant'
|
|
48
|
+
Provides-Extra: server
|
|
49
|
+
Requires-Dist: fastapi>=0.111.0; extra == 'server'
|
|
50
|
+
Requires-Dist: uvicorn[standard]>=0.30.0; extra == 'server'
|
|
51
|
+
Description-Content-Type: text/markdown
|
|
52
|
+
|
|
53
|
+
# FlowAI
|
|
54
|
+
|
|
55
|
+
FlowAI is an AI agent you run in your terminal, in the same spirit as Claude
|
|
56
|
+
Code or OpenCode. Bring your own key from Anthropic, OpenAI, NVIDIA, Google
|
|
57
|
+
Gemini or OpenRouter, or run fully local with Ollama. The agent can read, search
|
|
58
|
+
and edit the files in your project, run shell commands (after asking you), and
|
|
59
|
+
search the web.
|
|
60
|
+
|
|
61
|
+
It is also a Python library: the same agents, tools, workflows and memory the
|
|
62
|
+
CLI uses are yours to build on.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install flowai-cli # once it is published to PyPI
|
|
68
|
+
pipx install flowai-cli # same, in its own isolated environment
|
|
69
|
+
|
|
70
|
+
# from a checkout of this repository:
|
|
71
|
+
pip install .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Python 3.10 or newer. The package is `flowai-cli` (the name `flowai` is taken on PyPI);
|
|
75
|
+
the command it installs is `flowai`, and the import is `import flowai`.
|
|
76
|
+
|
|
77
|
+
## First run
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
flowai
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The first time, FlowAI asks for a provider and an API key and saves them to
|
|
84
|
+
`~/.flowai/config.json`. After that, `flowai` opens a session in the current
|
|
85
|
+
directory:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
------------------------------------------------------------
|
|
89
|
+
FlowAI 0.2.0 anthropic / claude-sonnet-5-5
|
|
90
|
+
workspace: /home/you/my-project
|
|
91
|
+
session: ses_3f9a1c2e
|
|
92
|
+
/help for commands, /exit to quit
|
|
93
|
+
------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
> why does the test in tests/test_api.py fail?
|
|
96
|
+
|
|
97
|
+
[tool] grep_files(pattern=def test_, path=tests)
|
|
98
|
+
[done] tests/test_api.py:12: def test_create_user(): (+4 more lines)
|
|
99
|
+
[tool] read_file(path=tests/test_api.py)
|
|
100
|
+
...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Set up non-interactively instead (for scripts or CI):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
flowai configure --provider anthropic --key "$ANTHROPIC_API_KEY"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Or skip configuration entirely: if `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
|
|
110
|
+
`NVIDIA_API_KEY`, `GEMINI_API_KEY` or `OPENROUTER_API_KEY` is set, FlowAI uses
|
|
111
|
+
it.
|
|
112
|
+
|
|
113
|
+
## Commands
|
|
114
|
+
|
|
115
|
+
| Command | What it does |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `flowai` | Interactive session in the current directory |
|
|
118
|
+
| `flowai "add type hints to utils.py"` | Run one task and exit |
|
|
119
|
+
| `flowai chat --resume` (or `-c`) | Continue the most recent session |
|
|
120
|
+
| `flowai run -q "..."` | One task, print only the final answer (good for scripts) |
|
|
121
|
+
| `echo "..." \| flowai run` | Read the prompt from stdin |
|
|
122
|
+
| `flowai configure` | Choose provider, model and API key |
|
|
123
|
+
| `flowai init [dir]` | Create a `flowai.yaml` and a starter `main.py` |
|
|
124
|
+
| `flowai inspect` | Show config status, saved sessions, checkpoints |
|
|
125
|
+
| `flowai tools` | List the built-in tools |
|
|
126
|
+
| `flowai serve` | HTTP API with streaming (`pip install 'flowai-cli[server]'`) |
|
|
127
|
+
|
|
128
|
+
Options for `chat` and `run`: `--provider`, `--model`, `-w/--workspace DIR`,
|
|
129
|
+
`--yolo` (do not ask before shell/python tools run), `--no-tools`,
|
|
130
|
+
`--max-iterations N`.
|
|
131
|
+
|
|
132
|
+
Inside a session: `/help`, `/model <name>`, `/provider <name> [model]`,
|
|
133
|
+
`/tools`, `/cost`, `/new`, `/reset`, `/yolo`, `/exit`.
|
|
134
|
+
|
|
135
|
+
## What the agent can do
|
|
136
|
+
|
|
137
|
+
| Tool | Notes |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `read_file`, `list_dir`, `glob_files`, `grep_files` | Read-only; never asks |
|
|
140
|
+
| `write_file`, `edit_file` | Changes files inside the workspace only |
|
|
141
|
+
| `bash` | Asks first. Runs in the workspace, 120 s timeout |
|
|
142
|
+
| `python_repl` | Asks first. Runs code in a fresh namespace |
|
|
143
|
+
| `search_web`, `fetch_url` | DuckDuckGo search and page fetch |
|
|
144
|
+
|
|
145
|
+
**Safety.** Every file path is resolved against the workspace (the directory
|
|
146
|
+
you started in, or `-w`) and refused if it points outside. Shell, Python and
|
|
147
|
+
MCP tools show you exactly what they will run and wait for `y`, `n` or `a`
|
|
148
|
+
(always, for that tool, this session). With no terminal attached, those tools
|
|
149
|
+
are refused rather than run. `--yolo` or `approval: "auto"` in `flowai.yaml`
|
|
150
|
+
turns the prompt off; use it only in a sandbox.
|
|
151
|
+
|
|
152
|
+
## Configuration
|
|
153
|
+
|
|
154
|
+
Settings are layered, later wins:
|
|
155
|
+
|
|
156
|
+
1. `~/.flowai/config.json`, written by `flowai configure`
|
|
157
|
+
2. `flowai.yaml` (or `.yml`, `.toml`, `.json`) in the current directory or any
|
|
158
|
+
parent, so it works from a subdirectory of your project
|
|
159
|
+
3. Environment: `FLOWAI_PROVIDER`, `FLOWAI_MODEL`, and the provider key variables
|
|
160
|
+
|
|
161
|
+
See [flowai.yaml](flowai.yaml) for every option, including `mcp_servers` for
|
|
162
|
+
borrowing tools from Model Context Protocol servers (`pip install 'flowai-cli[mcp]'`).
|
|
163
|
+
|
|
164
|
+
## Using it as a library
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
import asyncio
|
|
168
|
+
from flowai import Agent, tool
|
|
169
|
+
|
|
170
|
+
@tool
|
|
171
|
+
def compound_interest(principal: float, rate: float, years: int) -> float:
|
|
172
|
+
"""Balance after compounding yearly."""
|
|
173
|
+
return round(principal * (1 + rate) ** years, 2)
|
|
174
|
+
|
|
175
|
+
agent = Agent(
|
|
176
|
+
name="Advisor",
|
|
177
|
+
provider="anthropic", # nvidia, ollama, openai, gemini, openrouter
|
|
178
|
+
model="claude-sonnet-5-5",
|
|
179
|
+
tools=[compound_interest],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
print(asyncio.run(agent.run("What will $5,000 be after 10 years at 8%?")))
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`agent.stream(prompt)` yields tokens as they arrive and runs tool calls along
|
|
186
|
+
the way. Tools you register in your own code run without an approval prompt
|
|
187
|
+
(you wrote them); give a `ToolRegistry` an `approver` callback to change that.
|
|
188
|
+
|
|
189
|
+
Beyond a single agent the library has:
|
|
190
|
+
|
|
191
|
+
- **Specialised agents**: `PlannerAgent`, `CriticAgent`, `ToolAgent`, `SupervisorAgent`
|
|
192
|
+
- **Patterns**: `ReflectionPattern`, `PlanAndExecutePattern`, `DebatePattern`, `Swarm`
|
|
193
|
+
- **Workflows**: a graph engine with conditional edges, loops, parallel
|
|
194
|
+
branches and checkpointing (`Workflow`, `FileCheckpointer`)
|
|
195
|
+
- **Memory**: `InMemory`, `QdrantMemory` (`flowai-cli[qdrant]`), `ChromaMemory` (`flowai-cli[chroma]`)
|
|
196
|
+
- **Observability**: per-span token counts and estimated cost (`agent.tracer`),
|
|
197
|
+
an event bus, and an OpenTelemetry exporter (`flowai-cli[observability]`)
|
|
198
|
+
- **Plugins**: third-party providers via the `flowai.plugins` entry point
|
|
199
|
+
|
|
200
|
+
Runnable examples are in [examples/](examples/).
|
|
201
|
+
|
|
202
|
+
## Developing FlowAI
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
pip install -e '.[dev]'
|
|
206
|
+
pytest
|
|
207
|
+
ruff check flowai tests
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The tests are offline: providers are faked at the HTTP layer, so no key is needed.
|
|
211
|
+
|
|
212
|
+
## Releasing to PyPI
|
|
213
|
+
|
|
214
|
+
1. Bump `version` in `pyproject.toml` and `__version__` in `flowai/__init__.py`.
|
|
215
|
+
2. Push a tag `flowai-vX.Y.Z`. The `flowai-release` workflow builds this
|
|
216
|
+
directory and publishes it with PyPI Trusted Publishing; configure the
|
|
217
|
+
`flowai-cli` project on PyPI to trust this repository and workflow first.
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT
|