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/server/app.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""FastAPI REST and SSE Streaming API Server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from fastapi import FastAPI, HTTPException
|
|
12
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
13
|
+
from fastapi.responses import StreamingResponse
|
|
14
|
+
except ImportError:
|
|
15
|
+
FastAPI = None
|
|
16
|
+
|
|
17
|
+
from flowai import __version__
|
|
18
|
+
from flowai.agents.standard import Agent
|
|
19
|
+
from flowai.config.settings import Settings
|
|
20
|
+
from flowai.errors import FlowAIError
|
|
21
|
+
from flowai.tools.builtin import default_system_prompt, default_toolset
|
|
22
|
+
from flowai.tools.registry import ToolRegistry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChatRequest(BaseModel):
|
|
26
|
+
message: str
|
|
27
|
+
agent: str = "default"
|
|
28
|
+
provider: str | None = None
|
|
29
|
+
model: str | None = None
|
|
30
|
+
stream: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AgentCreateRequest(BaseModel):
|
|
34
|
+
name: str
|
|
35
|
+
provider: str = "openai"
|
|
36
|
+
model: str = "gpt-4o"
|
|
37
|
+
system_prompt: str = "You are a helpful AI assistant."
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_app(settings: Settings | None = None) -> Any:
|
|
41
|
+
if FastAPI is None:
|
|
42
|
+
raise ImportError("FastAPI is required to run the server. Install with: pip install 'flowai-cli[server]'")
|
|
43
|
+
|
|
44
|
+
cfg = settings or Settings.load()
|
|
45
|
+
app = FastAPI(title="FlowAI Agent Harness API", version=__version__)
|
|
46
|
+
|
|
47
|
+
# Agents here can run shell commands, so only local pages may call the API.
|
|
48
|
+
app.add_middleware(
|
|
49
|
+
CORSMiddleware,
|
|
50
|
+
allow_origins=["http://localhost", "http://127.0.0.1"],
|
|
51
|
+
allow_origin_regex=r"^http://(localhost|127\.0\.0\.1)(:\d+)?$",
|
|
52
|
+
allow_methods=["*"],
|
|
53
|
+
allow_headers=["*"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Active runtime registries
|
|
57
|
+
tool_registry = ToolRegistry(default_toolset())
|
|
58
|
+
agents_store: dict[str, Agent] = {
|
|
59
|
+
"default": Agent(
|
|
60
|
+
name="DefaultAgent",
|
|
61
|
+
provider=cfg.provider,
|
|
62
|
+
model=cfg.model,
|
|
63
|
+
system_prompt=default_system_prompt(),
|
|
64
|
+
tools=tool_registry,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@app.get("/health")
|
|
69
|
+
async def health():
|
|
70
|
+
return {"status": "ok", "version": __version__, "project": cfg.project_name}
|
|
71
|
+
|
|
72
|
+
@app.get("/tools")
|
|
73
|
+
async def list_tools():
|
|
74
|
+
return {"tools": tool_registry.get_schemas(format="openai")}
|
|
75
|
+
|
|
76
|
+
@app.get("/agents")
|
|
77
|
+
async def list_agents():
|
|
78
|
+
return {
|
|
79
|
+
"agents": [
|
|
80
|
+
{
|
|
81
|
+
"name": a.name,
|
|
82
|
+
"model": a.model,
|
|
83
|
+
"provider": a.provider.__class__.__name__,
|
|
84
|
+
"system_prompt": a.system_prompt,
|
|
85
|
+
}
|
|
86
|
+
for a in agents_store.values()
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@app.post("/agents")
|
|
91
|
+
async def create_agent(req: AgentCreateRequest):
|
|
92
|
+
agent = Agent(
|
|
93
|
+
name=req.name,
|
|
94
|
+
provider=req.provider,
|
|
95
|
+
model=req.model,
|
|
96
|
+
system_prompt=req.system_prompt,
|
|
97
|
+
tools=tool_registry,
|
|
98
|
+
)
|
|
99
|
+
agents_store[req.name] = agent
|
|
100
|
+
return {"message": f"Agent '{req.name}' created successfully."}
|
|
101
|
+
|
|
102
|
+
@app.post("/chat")
|
|
103
|
+
async def chat(req: ChatRequest):
|
|
104
|
+
agent = agents_store.get(req.agent)
|
|
105
|
+
if not agent:
|
|
106
|
+
# instantiate on demand
|
|
107
|
+
agent = Agent(
|
|
108
|
+
name=req.agent,
|
|
109
|
+
provider=req.provider or cfg.provider,
|
|
110
|
+
model=req.model or cfg.model,
|
|
111
|
+
tools=tool_registry,
|
|
112
|
+
)
|
|
113
|
+
agents_store[req.agent] = agent
|
|
114
|
+
|
|
115
|
+
if not req.stream:
|
|
116
|
+
try:
|
|
117
|
+
reply = await agent.run(req.message)
|
|
118
|
+
except FlowAIError as exc:
|
|
119
|
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
120
|
+
return {"reply": reply, "agent": req.agent}
|
|
121
|
+
|
|
122
|
+
async def sse_generator():
|
|
123
|
+
try:
|
|
124
|
+
async for chunk in agent.stream(req.message):
|
|
125
|
+
if chunk.text_delta:
|
|
126
|
+
yield f"data: {json.dumps({'token': chunk.text_delta})}\n\n"
|
|
127
|
+
except FlowAIError as exc:
|
|
128
|
+
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
|
|
129
|
+
yield f"data: {json.dumps({'done': True})}\n\n"
|
|
130
|
+
|
|
131
|
+
return StreamingResponse(sse_generator(), media_type="text/event-stream")
|
|
132
|
+
|
|
133
|
+
return app
|
flowai/tools/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""FlowAI Tool System."""
|
|
2
|
+
|
|
3
|
+
from flowai.tools.base import BaseTool, ToolDefinition, ToolParameterSchema
|
|
4
|
+
from flowai.tools.builtin import (
|
|
5
|
+
bash,
|
|
6
|
+
default_system_prompt,
|
|
7
|
+
default_toolset,
|
|
8
|
+
edit_file,
|
|
9
|
+
fetch_url,
|
|
10
|
+
glob_files,
|
|
11
|
+
grep_files,
|
|
12
|
+
list_dir,
|
|
13
|
+
python_repl,
|
|
14
|
+
read_file,
|
|
15
|
+
search_web,
|
|
16
|
+
write_file,
|
|
17
|
+
)
|
|
18
|
+
from flowai.tools.decorator import tool
|
|
19
|
+
from flowai.tools.registry import ToolRegistry
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BaseTool",
|
|
23
|
+
"ToolDefinition",
|
|
24
|
+
"ToolParameterSchema",
|
|
25
|
+
"tool",
|
|
26
|
+
"ToolRegistry",
|
|
27
|
+
"bash",
|
|
28
|
+
"python_repl",
|
|
29
|
+
"search_web",
|
|
30
|
+
"fetch_url",
|
|
31
|
+
"read_file",
|
|
32
|
+
"write_file",
|
|
33
|
+
"edit_file",
|
|
34
|
+
"list_dir",
|
|
35
|
+
"glob_files",
|
|
36
|
+
"grep_files",
|
|
37
|
+
"default_toolset",
|
|
38
|
+
"default_system_prompt",
|
|
39
|
+
]
|
flowai/tools/base.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Base data classes for Tool definitions and schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolParameterSchema(BaseModel):
|
|
12
|
+
"""JSON schema representing tool function parameters."""
|
|
13
|
+
type: str = "object"
|
|
14
|
+
properties: dict[str, Any] = Field(default_factory=dict)
|
|
15
|
+
required: list[str] = Field(default_factory=list)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolDefinition(BaseModel):
|
|
19
|
+
"""OpenAI/Anthropic compatible tool metadata representation."""
|
|
20
|
+
name: str
|
|
21
|
+
description: str
|
|
22
|
+
parameters: ToolParameterSchema
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseTool(ABC):
|
|
26
|
+
"""Abstract class for all tools in FlowAI."""
|
|
27
|
+
name: str
|
|
28
|
+
description: str
|
|
29
|
+
schema_definition: ToolDefinition
|
|
30
|
+
is_async: bool = False
|
|
31
|
+
# Tools that change the machine (shell, arbitrary code) ask before running.
|
|
32
|
+
requires_approval: bool = False
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def execute(self, **kwargs: Any) -> Any:
|
|
36
|
+
"""Execute the tool with keyword arguments and return the result."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def to_openai_schema(self) -> dict[str, Any]:
|
|
40
|
+
"""Convert to standard OpenAI function-calling format."""
|
|
41
|
+
return {
|
|
42
|
+
"type": "function",
|
|
43
|
+
"function": {
|
|
44
|
+
"name": self.name,
|
|
45
|
+
"description": self.description,
|
|
46
|
+
"parameters": self.schema_definition.parameters.model_dump(),
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def to_anthropic_schema(self) -> dict[str, Any]:
|
|
51
|
+
"""Convert to Anthropic tool format."""
|
|
52
|
+
return {
|
|
53
|
+
"name": self.name,
|
|
54
|
+
"description": self.description,
|
|
55
|
+
"input_schema": self.schema_definition.parameters.model_dump(),
|
|
56
|
+
}
|
flowai/tools/builtin.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Built-in tools and the default toolset the CLI and server hand to an agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import io
|
|
7
|
+
import sys
|
|
8
|
+
from contextlib import redirect_stdout
|
|
9
|
+
|
|
10
|
+
from flowai.tools.decorator import tool
|
|
11
|
+
from flowai.tools.files import (
|
|
12
|
+
edit_file,
|
|
13
|
+
glob_files,
|
|
14
|
+
grep_files,
|
|
15
|
+
list_dir,
|
|
16
|
+
read_file,
|
|
17
|
+
workspace_root,
|
|
18
|
+
write_file,
|
|
19
|
+
)
|
|
20
|
+
from flowai.tools.web import fetch_url, search_web
|
|
21
|
+
|
|
22
|
+
BASH_TIMEOUT_SECONDS = 120
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@tool("bash", description="Run a shell command in the workspace directory.", requires_approval=True)
|
|
26
|
+
async def bash(command: str) -> str:
|
|
27
|
+
"""Run a shell command and return its combined output."""
|
|
28
|
+
try:
|
|
29
|
+
proc = await asyncio.create_subprocess_shell(
|
|
30
|
+
command,
|
|
31
|
+
stdout=asyncio.subprocess.PIPE,
|
|
32
|
+
stderr=asyncio.subprocess.PIPE,
|
|
33
|
+
cwd=str(workspace_root()),
|
|
34
|
+
)
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
return f"Failed to start command: {exc}"
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=BASH_TIMEOUT_SECONDS)
|
|
40
|
+
except asyncio.TimeoutError:
|
|
41
|
+
proc.kill()
|
|
42
|
+
await proc.wait()
|
|
43
|
+
return f"Command timed out after {BASH_TIMEOUT_SECONDS}s and was killed."
|
|
44
|
+
|
|
45
|
+
out = stdout.decode(errors="replace").strip()
|
|
46
|
+
err = stderr.decode(errors="replace").strip()
|
|
47
|
+
parts = []
|
|
48
|
+
if out:
|
|
49
|
+
parts.append(out)
|
|
50
|
+
if err:
|
|
51
|
+
parts.append(f"STDERR: {err}")
|
|
52
|
+
if proc.returncode != 0:
|
|
53
|
+
parts.append(f"(exit code {proc.returncode})")
|
|
54
|
+
return "\n".join(parts) or "(no output)"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@tool("python_repl", description="Execute Python code in a fresh namespace and return its output.", requires_approval=True)
|
|
58
|
+
def python_repl(code: str) -> str:
|
|
59
|
+
"""Execute Python code and capture standard output."""
|
|
60
|
+
buffer = io.StringIO()
|
|
61
|
+
# One namespace for globals and locals: separate ones break comprehensions
|
|
62
|
+
# and any function that refers to a name defined alongside it.
|
|
63
|
+
namespace: dict = {"__name__": "__flowai_repl__"}
|
|
64
|
+
try:
|
|
65
|
+
with redirect_stdout(buffer):
|
|
66
|
+
exec(code, namespace)
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
captured = buffer.getvalue().strip()
|
|
69
|
+
error = f"Python execution error: {type(exc).__name__}: {exc}"
|
|
70
|
+
return f"{captured}\n{error}".strip()
|
|
71
|
+
|
|
72
|
+
out = buffer.getvalue().strip()
|
|
73
|
+
if not out:
|
|
74
|
+
user_vars = {k: v for k, v in namespace.items() if not k.startswith("__")}
|
|
75
|
+
if user_vars:
|
|
76
|
+
name, value = list(user_vars.items())[-1]
|
|
77
|
+
out = f"{name} = {value!r}"
|
|
78
|
+
return out or "Executed successfully (no output)."
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def default_toolset() -> list:
|
|
82
|
+
"""The tools an interactive FlowAI agent gets unless the caller says otherwise."""
|
|
83
|
+
return [
|
|
84
|
+
read_file,
|
|
85
|
+
write_file,
|
|
86
|
+
edit_file,
|
|
87
|
+
list_dir,
|
|
88
|
+
glob_files,
|
|
89
|
+
grep_files,
|
|
90
|
+
bash,
|
|
91
|
+
python_repl,
|
|
92
|
+
search_web,
|
|
93
|
+
fetch_url,
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def default_system_prompt(workspace: str | None = None) -> str:
|
|
98
|
+
"""System prompt describing the workspace the built-in tools operate on."""
|
|
99
|
+
root = workspace or str(workspace_root())
|
|
100
|
+
return (
|
|
101
|
+
"You are FlowAI, a terminal coding assistant working inside a user's project.\n"
|
|
102
|
+
f"The workspace root is: {root}\n"
|
|
103
|
+
f"The platform is: {sys.platform} (shell commands must be valid for it).\n\n"
|
|
104
|
+
"Guidelines:\n"
|
|
105
|
+
"- Inspect before you change: use list_dir, glob_files, grep_files and read_file.\n"
|
|
106
|
+
"- Prefer edit_file for targeted changes; use write_file only for new or fully rewritten files.\n"
|
|
107
|
+
"- Paths are relative to the workspace root and must stay inside it.\n"
|
|
108
|
+
"- Run commands with bash when you need to build, test or inspect the system.\n"
|
|
109
|
+
"- Never claim you ran something you did not run, and report failures exactly as they occurred.\n"
|
|
110
|
+
"- Keep answers short and concrete; the user is reading them in a terminal."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = [
|
|
115
|
+
"bash",
|
|
116
|
+
"python_repl",
|
|
117
|
+
"search_web",
|
|
118
|
+
"fetch_url",
|
|
119
|
+
"read_file",
|
|
120
|
+
"write_file",
|
|
121
|
+
"edit_file",
|
|
122
|
+
"list_dir",
|
|
123
|
+
"glob_files",
|
|
124
|
+
"grep_files",
|
|
125
|
+
"default_toolset",
|
|
126
|
+
"default_system_prompt",
|
|
127
|
+
"BASH_TIMEOUT_SECONDS",
|
|
128
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""@tool decorator with automatic Pydantic v2 schema generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, get_type_hints
|
|
8
|
+
|
|
9
|
+
from pydantic import create_model
|
|
10
|
+
|
|
11
|
+
from flowai.tools.base import BaseTool, ToolDefinition, ToolParameterSchema
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def tool(
|
|
15
|
+
name_or_fn: str | Callable | None = None,
|
|
16
|
+
description: str | None = None,
|
|
17
|
+
requires_approval: bool = False,
|
|
18
|
+
):
|
|
19
|
+
"""Decorator to turn any standard or async Python function into a FlowAI tool.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
@tool
|
|
23
|
+
def calculate_tax(amount: float, rate: float = 0.15) -> float:
|
|
24
|
+
'''Calculate total tax for an amount.'''
|
|
25
|
+
return amount * rate
|
|
26
|
+
|
|
27
|
+
@tool("web_search", description="Search the web for query")
|
|
28
|
+
async def fetch_results(query: str, max_results: int = 5):
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
Pass requires_approval=True for a tool that changes the machine; a registry
|
|
32
|
+
with an approver attached will ask the user before each call.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_requires_approval = requires_approval
|
|
36
|
+
|
|
37
|
+
def decorator(fn: Callable[..., Any]) -> BaseTool:
|
|
38
|
+
tool_name = (name_or_fn if isinstance(name_or_fn, str) else None) or fn.__name__
|
|
39
|
+
raw_doc = fn.__doc__ or ""
|
|
40
|
+
tool_desc = (description or raw_doc.strip()) or f"Tool {tool_name}"
|
|
41
|
+
|
|
42
|
+
# Inspect parameters and types
|
|
43
|
+
sig = inspect.signature(fn)
|
|
44
|
+
type_hints = {}
|
|
45
|
+
try:
|
|
46
|
+
type_hints = get_type_hints(fn)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
fields: dict[str, Any] = {}
|
|
51
|
+
required_fields: list[str] = []
|
|
52
|
+
|
|
53
|
+
for param_name, param in sig.parameters.items():
|
|
54
|
+
if param_name in ("self", "cls"):
|
|
55
|
+
continue
|
|
56
|
+
param_type = type_hints.get(param_name, Any)
|
|
57
|
+
default = ... if param.default == inspect.Parameter.empty else param.default
|
|
58
|
+
if default is ...:
|
|
59
|
+
required_fields.append(param_name)
|
|
60
|
+
fields[param_name] = (param_type, default)
|
|
61
|
+
|
|
62
|
+
# Build dynamic Pydantic model for validation & schema generation
|
|
63
|
+
model_name = f"{tool_name.capitalize()}Args"
|
|
64
|
+
ArgsModel = create_model(model_name, **fields)
|
|
65
|
+
json_schema = ArgsModel.model_json_schema()
|
|
66
|
+
|
|
67
|
+
param_schema = ToolParameterSchema(
|
|
68
|
+
type="object",
|
|
69
|
+
properties=json_schema.get("properties", {}),
|
|
70
|
+
required=required_fields,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
tool_def = ToolDefinition(
|
|
74
|
+
name=tool_name,
|
|
75
|
+
description=tool_desc,
|
|
76
|
+
parameters=param_schema,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
class DecoratedTool(BaseTool):
|
|
80
|
+
name = tool_name
|
|
81
|
+
description = tool_desc
|
|
82
|
+
schema_definition = tool_def
|
|
83
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
84
|
+
requires_approval = _requires_approval
|
|
85
|
+
|
|
86
|
+
def __init__(self):
|
|
87
|
+
self._fn = fn
|
|
88
|
+
self._args_model = ArgsModel
|
|
89
|
+
|
|
90
|
+
async def execute(self, **kwargs: Any) -> Any:
|
|
91
|
+
validated_args = self._args_model(**kwargs).model_dump()
|
|
92
|
+
if self.is_async:
|
|
93
|
+
return await self._fn(**validated_args)
|
|
94
|
+
return self._fn(**validated_args)
|
|
95
|
+
|
|
96
|
+
def __repr__(self) -> str:
|
|
97
|
+
return f"<Tool {self.name}: {self.description}>"
|
|
98
|
+
|
|
99
|
+
return DecoratedTool()
|
|
100
|
+
|
|
101
|
+
if callable(name_or_fn):
|
|
102
|
+
return decorator(name_or_fn)
|
|
103
|
+
return decorator
|
flowai/tools/files.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""File system tools: the toolkit an agent needs to read and change a project.
|
|
2
|
+
|
|
3
|
+
Every path is resolved against the workspace root (the directory FlowAI was
|
|
4
|
+
started in, unless FLOWAI_WORKSPACE says otherwise) and must stay inside it.
|
|
5
|
+
That keeps a confused model from reading ~/.ssh or writing outside the project.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from flowai.tools.decorator import tool
|
|
16
|
+
|
|
17
|
+
# Files larger than this are refused rather than silently truncating context.
|
|
18
|
+
MAX_READ_BYTES = 400_000
|
|
19
|
+
|
|
20
|
+
# Directories never worth walking into during glob/grep.
|
|
21
|
+
SKIP_DIRS = {
|
|
22
|
+
".git", ".hg", ".svn", "__pycache__", ".venv", "venv", "node_modules",
|
|
23
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build", ".flowai",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def workspace_root() -> Path:
|
|
28
|
+
"""The directory the agent is allowed to touch."""
|
|
29
|
+
return Path(os.getenv("FLOWAI_WORKSPACE", ".")).resolve()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_in_workspace(path: str) -> Path:
|
|
33
|
+
"""Resolve a user/model supplied path, refusing anything outside the workspace."""
|
|
34
|
+
root = workspace_root()
|
|
35
|
+
candidate = Path(path)
|
|
36
|
+
if not candidate.is_absolute():
|
|
37
|
+
candidate = root / candidate
|
|
38
|
+
resolved = candidate.resolve()
|
|
39
|
+
if resolved != root and root not in resolved.parents:
|
|
40
|
+
raise ValueError(f"Path '{path}' is outside the workspace ({root}).")
|
|
41
|
+
return resolved
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _relative(path: Path) -> str:
|
|
45
|
+
try:
|
|
46
|
+
return str(path.relative_to(workspace_root()))
|
|
47
|
+
except ValueError:
|
|
48
|
+
return str(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _iter_files(root: Path):
|
|
52
|
+
"""Walk the workspace, skipping directories that are never interesting."""
|
|
53
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
54
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
|
|
55
|
+
for filename in filenames:
|
|
56
|
+
yield Path(dirpath) / filename
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@tool("read_file", description="Read a text file from the workspace. Returns numbered lines.")
|
|
60
|
+
def read_file(path: str, start_line: int = 1, max_lines: int = 500) -> str:
|
|
61
|
+
"""Read a file and return its contents with line numbers."""
|
|
62
|
+
target = resolve_in_workspace(path)
|
|
63
|
+
if not target.exists():
|
|
64
|
+
return f"Error: '{path}' does not exist."
|
|
65
|
+
if target.is_dir():
|
|
66
|
+
return f"Error: '{path}' is a directory. Use list_dir instead."
|
|
67
|
+
if target.stat().st_size > MAX_READ_BYTES:
|
|
68
|
+
return f"Error: '{path}' is larger than {MAX_READ_BYTES} bytes. Use grep_files to search it instead."
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
text = target.read_text(encoding="utf-8")
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
return f"Error: '{path}' is not a UTF-8 text file."
|
|
74
|
+
|
|
75
|
+
lines = text.splitlines()
|
|
76
|
+
start = max(1, start_line)
|
|
77
|
+
window = lines[start - 1: start - 1 + max_lines]
|
|
78
|
+
if not window:
|
|
79
|
+
return f"(no lines at or after line {start}; file has {len(lines)} lines)"
|
|
80
|
+
|
|
81
|
+
numbered = "\n".join(f"{start + i:>6}\t{line}" for i, line in enumerate(window))
|
|
82
|
+
shown_to = start + len(window) - 1
|
|
83
|
+
if shown_to < len(lines):
|
|
84
|
+
numbered += f"\n... ({len(lines) - shown_to} more lines; read again with start_line={shown_to + 1})"
|
|
85
|
+
return numbered
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@tool("write_file", description="Create or overwrite a text file in the workspace.")
|
|
89
|
+
def write_file(path: str, content: str) -> str:
|
|
90
|
+
"""Write content to a file, creating parent directories as needed."""
|
|
91
|
+
target = resolve_in_workspace(path)
|
|
92
|
+
if target.is_dir():
|
|
93
|
+
return f"Error: '{path}' is a directory."
|
|
94
|
+
existed = target.exists()
|
|
95
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
target.write_text(content, encoding="utf-8")
|
|
97
|
+
verb = "Overwrote" if existed else "Created"
|
|
98
|
+
return f"{verb} {_relative(target)} ({len(content.splitlines())} lines)."
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@tool("edit_file", description="Replace an exact string in a workspace file. The string must appear exactly once.")
|
|
102
|
+
def edit_file(path: str, old_text: str, new_text: str) -> str:
|
|
103
|
+
"""Replace one exact occurrence of old_text with new_text."""
|
|
104
|
+
target = resolve_in_workspace(path)
|
|
105
|
+
if not target.exists():
|
|
106
|
+
return f"Error: '{path}' does not exist. Use write_file to create it."
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
text = target.read_text(encoding="utf-8")
|
|
110
|
+
except UnicodeDecodeError:
|
|
111
|
+
return f"Error: '{path}' is not a UTF-8 text file."
|
|
112
|
+
|
|
113
|
+
occurrences = text.count(old_text)
|
|
114
|
+
if occurrences == 0:
|
|
115
|
+
return f"Error: old_text was not found in '{path}'. Read the file again and copy the text exactly."
|
|
116
|
+
if occurrences > 1:
|
|
117
|
+
return f"Error: old_text appears {occurrences} times in '{path}'. Include more surrounding lines to make it unique."
|
|
118
|
+
|
|
119
|
+
target.write_text(text.replace(old_text, new_text, 1), encoding="utf-8")
|
|
120
|
+
line_no = text[: text.index(old_text)].count("\n") + 1
|
|
121
|
+
return f"Edited {_relative(target)} at line {line_no}."
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@tool("list_dir", description="List the entries of a directory in the workspace.")
|
|
125
|
+
def list_dir(path: str = ".") -> str:
|
|
126
|
+
"""List files and subdirectories, directories first."""
|
|
127
|
+
target = resolve_in_workspace(path)
|
|
128
|
+
if not target.exists():
|
|
129
|
+
return f"Error: '{path}' does not exist."
|
|
130
|
+
if not target.is_dir():
|
|
131
|
+
return f"Error: '{path}' is not a directory."
|
|
132
|
+
|
|
133
|
+
entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
|
|
134
|
+
if not entries:
|
|
135
|
+
return f"{_relative(target)} is empty."
|
|
136
|
+
|
|
137
|
+
lines = []
|
|
138
|
+
for entry in entries:
|
|
139
|
+
if entry.name in SKIP_DIRS:
|
|
140
|
+
continue
|
|
141
|
+
if entry.is_dir():
|
|
142
|
+
lines.append(f"{entry.name}/")
|
|
143
|
+
else:
|
|
144
|
+
lines.append(f"{entry.name} ({entry.stat().st_size} bytes)")
|
|
145
|
+
return "\n".join(lines) or f"{_relative(target)} has no visible entries."
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@tool("glob_files", description="Find workspace files whose path matches a glob pattern, e.g. '**/*.py'.")
|
|
149
|
+
def glob_files(pattern: str, limit: int = 100) -> str:
|
|
150
|
+
"""Return paths matching a glob pattern, newest first."""
|
|
151
|
+
root = workspace_root()
|
|
152
|
+
matches = []
|
|
153
|
+
for file_path in _iter_files(root):
|
|
154
|
+
rel = file_path.relative_to(root).as_posix()
|
|
155
|
+
if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(file_path.name, pattern):
|
|
156
|
+
matches.append(file_path)
|
|
157
|
+
|
|
158
|
+
if not matches:
|
|
159
|
+
return f"No files match '{pattern}'."
|
|
160
|
+
|
|
161
|
+
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
162
|
+
shown = matches[:limit]
|
|
163
|
+
out = "\n".join(p.relative_to(root).as_posix() for p in shown)
|
|
164
|
+
if len(matches) > limit:
|
|
165
|
+
out += f"\n... ({len(matches) - limit} more matches)"
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@tool("grep_files", description="Search workspace file contents with a regular expression.")
|
|
170
|
+
def grep_files(pattern: str, path: str = ".", glob: str = "*", limit: int = 100) -> str:
|
|
171
|
+
"""Search files for a regex and return 'path:line: text' hits."""
|
|
172
|
+
try:
|
|
173
|
+
regex = re.compile(pattern)
|
|
174
|
+
except re.error as exc:
|
|
175
|
+
return f"Error: invalid regular expression: {exc}"
|
|
176
|
+
|
|
177
|
+
root = resolve_in_workspace(path)
|
|
178
|
+
if root.is_file():
|
|
179
|
+
candidates = [root]
|
|
180
|
+
else:
|
|
181
|
+
candidates = [p for p in _iter_files(root) if fnmatch.fnmatch(p.name, glob)]
|
|
182
|
+
|
|
183
|
+
hits: list[str] = []
|
|
184
|
+
for file_path in candidates:
|
|
185
|
+
try:
|
|
186
|
+
if file_path.stat().st_size > MAX_READ_BYTES:
|
|
187
|
+
continue
|
|
188
|
+
text = file_path.read_text(encoding="utf-8")
|
|
189
|
+
except (UnicodeDecodeError, OSError):
|
|
190
|
+
continue
|
|
191
|
+
for line_no, line in enumerate(text.splitlines(), 1):
|
|
192
|
+
if regex.search(line):
|
|
193
|
+
hits.append(f"{_relative(file_path)}:{line_no}: {line.strip()[:200]}")
|
|
194
|
+
if len(hits) >= limit:
|
|
195
|
+
return "\n".join(hits) + f"\n... (stopped at {limit} matches)"
|
|
196
|
+
|
|
197
|
+
return "\n".join(hits) if hits else f"No matches for '{pattern}'."
|