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
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
@@ -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
+ }
@@ -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}'."