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,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
@@ -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