avo 0.1.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 (89) hide show
  1. avo/__init__.py +53 -0
  2. avo/_pytest_plugin.py +58 -0
  3. avo/app_tools/__init__.py +14 -0
  4. avo/app_tools/approval.py +98 -0
  5. avo/app_tools/edit_file.py +130 -0
  6. avo/app_tools/file_tools.py +145 -0
  7. avo/app_tools/git_status.py +80 -0
  8. avo/app_tools/glob_tool.py +96 -0
  9. avo/app_tools/grep_tool.py +159 -0
  10. avo/app_tools/plan_tasks.py +116 -0
  11. avo/app_tools/plan_tool.py +57 -0
  12. avo/app_tools/sandbox.py +230 -0
  13. avo/app_tools/shell_tool.py +115 -0
  14. avo/app_tools/task_tool.py +142 -0
  15. avo/app_tools/web_fetch.py +114 -0
  16. avo/app_tools/web_search.py +188 -0
  17. avo/app_tools/workspace.py +190 -0
  18. avo/app_tools/workspace_map.py +69 -0
  19. avo/audit.py +186 -0
  20. avo/budget.py +99 -0
  21. avo/chat.py +566 -0
  22. avo/chat_session.py +297 -0
  23. avo/chat_setup.py +242 -0
  24. avo/chat_shell_rc.py +125 -0
  25. avo/checkpoint.py +196 -0
  26. avo/cli.py +155 -0
  27. avo/compact.py +112 -0
  28. avo/concurrency.py +72 -0
  29. avo/config.py +142 -0
  30. avo/doctor.py +240 -0
  31. avo/eval.py +162 -0
  32. avo/events.py +128 -0
  33. avo/exceptions.py +70 -0
  34. avo/hooks.py +179 -0
  35. avo/integrations/__init__.py +5 -0
  36. avo/integrations/lethe.py +56 -0
  37. avo/ledger.py +210 -0
  38. avo/mcp.py +321 -0
  39. avo/mcp_servers/__init__.py +25 -0
  40. avo/mcp_servers/_stdio.py +390 -0
  41. avo/mcp_servers/filesystem.py +188 -0
  42. avo/mcp_servers/git.py +172 -0
  43. avo/mcp_servers/http_fetch.py +89 -0
  44. avo/mcp_servers/sqlite.py +186 -0
  45. avo/metrics.py +242 -0
  46. avo/models.py +219 -0
  47. avo/notifications.py +156 -0
  48. avo/permissions.py +258 -0
  49. avo/planning.py +145 -0
  50. avo/plugins.py +110 -0
  51. avo/policies.py +55 -0
  52. avo/progress.py +102 -0
  53. avo/providers/__init__.py +17 -0
  54. avo/providers/anthropic.py +290 -0
  55. avo/providers/base.py +28 -0
  56. avo/providers/fake.py +142 -0
  57. avo/providers/http_common.py +160 -0
  58. avo/providers/minimax.py +362 -0
  59. avo/providers/ollama.py +289 -0
  60. avo/providers/openai.py +168 -0
  61. avo/providers/openai_compatible.py +175 -0
  62. avo/providers/streaming.py +72 -0
  63. avo/py.typed +1 -0
  64. avo/rate_limit.py +110 -0
  65. avo/retry.py +88 -0
  66. avo/runtime.py +446 -0
  67. avo/runtime_handlers.py +407 -0
  68. avo/runtime_persistence.py +318 -0
  69. avo/schemas.py +97 -0
  70. avo/skills.py +92 -0
  71. avo/snapshot.py +83 -0
  72. avo/state.py +184 -0
  73. avo/storage/__init__.py +7 -0
  74. avo/storage/base.py +62 -0
  75. avo/storage/conversations.py +215 -0
  76. avo/storage/memory.py +221 -0
  77. avo/storage/sqlite.py +386 -0
  78. avo/storage/sqlite_checkpoints.py +99 -0
  79. avo/subagent.py +93 -0
  80. avo/tools.py +200 -0
  81. avo/tracing.py +181 -0
  82. avo/usage.py +130 -0
  83. avo/workspace/git.py +176 -0
  84. avo/workspace/indexer.py +128 -0
  85. avo-0.1.0.dist-info/METADATA +504 -0
  86. avo-0.1.0.dist-info/RECORD +89 -0
  87. avo-0.1.0.dist-info/WHEEL +4 -0
  88. avo-0.1.0.dist-info/entry_points.txt +5 -0
  89. avo-0.1.0.dist-info/licenses/LICENSE +21 -0
avo/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """Public API for Avo 0.1."""
2
+
3
+ from avo.events import AgentEvent, EventType
4
+ from avo.integrations import LetheMemoryAdapter, MemoryProvider
5
+ from avo.models import (
6
+ Checkpoint,
7
+ ModelRequest,
8
+ ModelResponse,
9
+ RunRecord,
10
+ RunResult,
11
+ TokenUsage,
12
+ ToolCall,
13
+ ToolMetadata,
14
+ ToolResult,
15
+ )
16
+ from avo.policies import LoopPolicy
17
+ from avo.progress import ProgressDetector
18
+ from avo.providers.fake import FakeProvider, ScriptItem
19
+ from avo.runtime import AgentRuntime
20
+ from avo.state import RunState, StopReason
21
+ from avo.tools import FunctionTool, Tool, ToolRegistry
22
+ from avo.tracing import RunTrace, TraceEntry, TraceInspector
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ "AgentEvent",
28
+ "AgentRuntime",
29
+ "Checkpoint",
30
+ "EventType",
31
+ "FakeProvider",
32
+ "FunctionTool",
33
+ "LetheMemoryAdapter",
34
+ "LoopPolicy",
35
+ "MemoryProvider",
36
+ "ModelRequest",
37
+ "ModelResponse",
38
+ "ProgressDetector",
39
+ "RunRecord",
40
+ "RunResult",
41
+ "RunState",
42
+ "RunTrace",
43
+ "ScriptItem",
44
+ "StopReason",
45
+ "TokenUsage",
46
+ "Tool",
47
+ "ToolCall",
48
+ "ToolMetadata",
49
+ "ToolRegistry",
50
+ "ToolResult",
51
+ "TraceEntry",
52
+ "TraceInspector",
53
+ ]
avo/_pytest_plugin.py ADDED
@@ -0,0 +1,58 @@
1
+ """pytest plugin entry point that registers the ``--run-live`` flag.
2
+
3
+ Registered via ``pyproject.toml`` ``entry_points`` so ``pytest --run-live``
4
+ becomes a first-class CLI option without needing a ``conftest.py`` at
5
+ the repo root. Without the flag the integration tests stay skipped by
6
+ the ``--ignore`` default in ``[tool.pytest.ini_options]`` — this hook
7
+ re-enables them when the operator opts in.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ # Locate the integration test directory *relative to this plugin* so the
15
+ # plugin works regardless of pytest's rootdir resolution.
16
+ INTEGRATION_DIR = Path(__file__).resolve().parent.parent.parent / "tests" / "integration"
17
+
18
+
19
+ def pytest_addoption(parser: object) -> None:
20
+ """Register ``--run-live`` so opt-in integration tests can be enabled."""
21
+
22
+ group = getattr(parser, "getgroup", lambda _name: parser)("avo")
23
+ add_option = getattr(group, "addoption", None)
24
+ if add_option is None: # pragma: no cover - argparse fallback path
25
+ add_option = parser.add_option # type: ignore[attr-defined]
26
+ add_option(
27
+ "--run-live",
28
+ action="store_true",
29
+ default=False,
30
+ help="Run live HTTP integration tests against real provider endpoints.",
31
+ )
32
+
33
+
34
+ def pytest_collection_modifyitems(config: object, items: list[object]) -> None:
35
+ """Skip live tests unless ``--run-live`` was passed."""
36
+
37
+ run_live = bool(getattr(config, "getoption", lambda _n: False)("--run-live"))
38
+ if run_live:
39
+ return
40
+ skip = _build_skipper()
41
+ for item in items:
42
+ path = getattr(item, "fspath", None) or getattr(item, "path", None)
43
+ if path is None:
44
+ continue
45
+ try:
46
+ is_integration = INTEGRATION_DIR in Path(str(path)).resolve().parents
47
+ except OSError: # pragma: no cover
48
+ is_integration = False
49
+ if is_integration:
50
+ mark = getattr(item, "add_marker", None)
51
+ if mark is not None:
52
+ mark(skip)
53
+
54
+
55
+ def _build_skipper() -> object:
56
+ import pytest
57
+
58
+ return pytest.mark.skip(reason="--run-live not enabled; offline suite skips live tests")
@@ -0,0 +1,14 @@
1
+ """Application tools: workspace sandboxing, shell execution, and approval.
2
+
3
+ Application tools build on the existing ``FunctionTool`` / ``ToolRegistry``
4
+ contract documented in ``src/avo/tools.py``. They do not modify
5
+ ``AgentRuntime`` or the state machine; they plug in via the existing
6
+ ``approval_callback`` parameter on ``AgentRuntime``.
7
+
8
+ The sandbox module is optional and lives behind the ``[sandbox]`` extra in
9
+ ``pyproject.toml`` because it requires the ``docker`` package.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __all__ = []
@@ -0,0 +1,98 @@
1
+ """Approval policy builder for application tools.
2
+
3
+ The runtime invokes ``approval_callback(call)`` while in the
4
+ ``APPROVAL_PENDING`` state (see ``src/avo/runtime.py`` and the
5
+ state-machine reference). This module produces a callable that consults
6
+ the ``AVO_TOOLS_REQUIRE_APPROVAL`` environment variable to decide
7
+ which tool names must wait for explicit operator approval.
8
+
9
+ The variable is a comma- or whitespace-separated list of tool names.
10
+ Tool names not present are auto-approved without invoking any
11
+ callback machinery.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from collections.abc import Awaitable, Callable
18
+ from typing import Protocol
19
+
20
+ from avo.models import ToolCall
21
+
22
+
23
+ class ApprovalDecision(Protocol):
24
+ """A user-provided callable that returns True to approve a tool call."""
25
+
26
+ def __call__(self, call: ToolCall) -> bool | Awaitable[bool]: ...
27
+
28
+
29
+ def _parse_env_list(environ: os._Environ[str] | dict[str, str]) -> set[str]:
30
+ """Parse ``AVO_TOOLS_REQUIRE_APPROVAL`` into a normalized name set."""
31
+
32
+ raw = environ.get("AVO_TOOLS_REQUIRE_APPROVAL", "")
33
+ if not raw:
34
+ return set()
35
+ tokens: list[str] = []
36
+ for chunk in raw.replace("\n", ",").split(","):
37
+ tokens.extend(chunk.split())
38
+ return {name.strip() for name in tokens if name.strip()}
39
+
40
+
41
+ def build_approval_callback(
42
+ environ: os._Environ[str] | dict[str, str] | None = None,
43
+ *,
44
+ on_require: Callable[[ToolCall], None] | None = None,
45
+ ) -> ApprovalDecision:
46
+ """Build an ``approval_callback`` that consults ``AVO_TOOLS_REQUIRE_APPROVAL``.
47
+
48
+ Args:
49
+ environ: Mapping to read from. Defaults to ``os.environ``.
50
+ on_require: Optional callback invoked whenever a tool requires
51
+ approval. The runtime-supplied approval callback may be a
52
+ coroutine; the operator-supplied one returned here is always
53
+ synchronous.
54
+
55
+ Behavior:
56
+ - Tool names listed in ``AVO_TOOLS_REQUIRE_APPROVAL`` → return
57
+ ``False`` so the runtime stops the run with ``POLICY_DENIED``.
58
+ This matches the documented behavior of a synchronous denial —
59
+ the operator is expected to gate the actual decision outside the
60
+ runtime. (The runtime treats ``False`` as denial.)
61
+ - Tool names not in the list → return ``True`` immediately.
62
+
63
+ Notes:
64
+ For 0.1 this returns ``False`` rather than raising so the
65
+ runtime stops deterministically. Operators wanting to inject a
66
+ real interactive prompt can wrap this callback with their own
67
+ ``on_require`` to escalate.
68
+ """
69
+
70
+ env: os._Environ[str] | dict[str, str] = os.environ if environ is None else environ
71
+ required = _parse_env_list(env)
72
+
73
+ def _callback(call: ToolCall) -> bool:
74
+ if call.name not in required:
75
+ return True
76
+ if on_require is not None:
77
+ on_require(call)
78
+ # 0.1 behavior: required tools are denied. Operators wanting
79
+ # interactive approval must wrap this callback.
80
+ return False
81
+
82
+ return _callback
83
+
84
+
85
+ def required_tool_names(
86
+ environ: os._Environ[str] | dict[str, str] | None = None,
87
+ ) -> set[str]:
88
+ """Return the parsed set of tool names that require approval."""
89
+
90
+ env: os._Environ[str] | dict[str, str] = os.environ if environ is None else environ
91
+ return _parse_env_list(env)
92
+
93
+
94
+ __all__ = [
95
+ "ApprovalDecision",
96
+ "build_approval_callback",
97
+ "required_tool_names",
98
+ ]
@@ -0,0 +1,130 @@
1
+ """``edit_file`` tool: surgical string replacement inside a file.
2
+
3
+ Like ``write_file``, the path is resolved through the active
4
+ :class:`Workspace` and the write itself opens the file with
5
+ ``O_NOFOLLOW`` to refuse symlink targets. Unlike ``write_file``, this
6
+ tool only changes the matching region and is safe for the model to use
7
+ on partial edits without rewriting the whole file.
8
+
9
+ Behaviour:
10
+
11
+ * ``replace_all=False`` (default) requires exactly one match of
12
+ ``old_string``. Zero matches and multiple matches both raise
13
+ :class:`EditFileError` with a precise reason.
14
+ * ``replace_all=True`` replaces every non-overlapping match. Zero
15
+ matches still raises — a no-op edit should not silently succeed.
16
+ * ``old_string`` and ``new_string`` must differ.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from typing import Any
23
+
24
+ from pydantic import BaseModel, Field, model_validator
25
+
26
+ from avo import FunctionTool as PublicFunctionTool
27
+
28
+ from .file_tools import _workspace_stack
29
+ from .workspace import Workspace
30
+
31
+
32
+ class EditFileError(RuntimeError):
33
+ """Raised when ``edit_file`` cannot satisfy the requested edit."""
34
+
35
+
36
+ class EditFileArguments(BaseModel):
37
+ """Arguments for the ``edit_file`` tool."""
38
+
39
+ path: str = Field(min_length=1, description="Workspace-relative or absolute path")
40
+ old_string: str = Field(min_length=1, description="Literal text to replace")
41
+ new_string: str = Field(description="Replacement text")
42
+ replace_all: bool = Field(
43
+ default=False,
44
+ description="Replace every match; otherwise the match must be unique",
45
+ )
46
+
47
+ @model_validator(mode="after")
48
+ def _require_difference(self) -> EditFileArguments:
49
+ """Reject edits where replacement equals the original text."""
50
+
51
+ if self.old_string == self.new_string:
52
+ raise ValueError("old_string and new_string must differ")
53
+ return self
54
+
55
+
56
+ def _current_edit_workspace() -> Workspace:
57
+ if not _workspace_stack:
58
+ raise EditFileError(
59
+ "edit_file invoked without an active workspace; wrap the run in "
60
+ "avo.app_tools.file_tools.bind_workspace(...)"
61
+ )
62
+ return _workspace_stack[-1]
63
+
64
+
65
+ def _apply_edit(text: str, old_string: str, new_string: str, replace_all: bool) -> str:
66
+ """Return ``text`` with ``old_string`` replaced by ``new_string``.
67
+
68
+ Raises:
69
+ EditFileError: when ``old_string`` does not appear, or appears
70
+ more than once and ``replace_all=False``.
71
+ """
72
+
73
+ occurrences = text.count(old_string)
74
+ if occurrences == 0:
75
+ raise EditFileError("old_string not found in file")
76
+ if occurrences > 1 and not replace_all:
77
+ raise EditFileError(
78
+ f"old_string matches {occurrences} locations; pass replace_all=True "
79
+ "to replace all matches, or narrow old_string to a single location"
80
+ )
81
+ return text.replace(old_string, new_string)
82
+
83
+
84
+ async def _edit_file(arguments: EditFileArguments) -> dict[str, Any]:
85
+ workspace = _current_edit_workspace()
86
+ resolved = workspace.validate_for_write(arguments.path)
87
+ encoding = "utf-8"
88
+ original_text = resolved.read_text(encoding=encoding)
89
+
90
+ updated_text = _apply_edit(
91
+ original_text, arguments.old_string, arguments.new_string, arguments.replace_all
92
+ )
93
+
94
+ encoded = updated_text.encode(encoding)
95
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
96
+ if hasattr(os, "O_NOFOLLOW"):
97
+ flags |= os.O_NOFOLLOW
98
+ fd = os.open(resolved, flags, 0o644)
99
+ try:
100
+ os.write(fd, encoded)
101
+ finally:
102
+ os.close(fd)
103
+ return {
104
+ "path": str(resolved),
105
+ "size": len(encoded),
106
+ "matches_replaced": original_text.count(arguments.old_string),
107
+ }
108
+
109
+
110
+ def edit_file_tool() -> PublicFunctionTool[EditFileArguments]:
111
+ """Return a :class:`FunctionTool` that performs a surgical file edit."""
112
+
113
+ return PublicFunctionTool(
114
+ name="edit_file",
115
+ description=(
116
+ "Replace a literal string in a file inside the workspace. By "
117
+ "default the match must be unique; pass replace_all=True to "
118
+ "replace every occurrence. The path is validated against the "
119
+ "workspace and the write refuses to follow symlinks."
120
+ ),
121
+ arguments_model=EditFileArguments,
122
+ function=_edit_file,
123
+ )
124
+
125
+
126
+ __all__ = [
127
+ "EditFileArguments",
128
+ "EditFileError",
129
+ "edit_file_tool",
130
+ ]
@@ -0,0 +1,145 @@
1
+ """``read_file`` and ``write_file`` tools bound to a fixed workspace.
2
+
3
+ Both tools resolve the model-supplied path through
4
+ :class:`avo.app_tools.workspace.Workspace` before any I/O. The
5
+ returned :class:`FunctionTool` instances plug into
6
+ ``AgentRuntime(tools=[...])`` without any change to the runtime itself.
7
+
8
+ Approval and timeout policy are configured at the ``AgentRuntime`` level
9
+ via ``approval_callback=`` and ``LoopPolicy.tool_timeout_seconds=``. This
10
+ module does **not** introduce its own timeout or approval mechanism.
11
+
12
+ The workspace itself is bound per call site via :func:`bind_workspace`,
13
+ which keeps the tools stateless across runs and avoids relying on
14
+ thread-locals or mutable module state that a hostile prompt could
15
+ mutate.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from collections.abc import Generator
22
+ from contextlib import contextmanager
23
+ from typing import Any
24
+
25
+ from pydantic import BaseModel, Field
26
+
27
+ from avo import FunctionTool as PublicFunctionTool
28
+
29
+ from .workspace import Workspace
30
+
31
+
32
+ class ReadFileArguments(BaseModel):
33
+ """Arguments for the ``read_file`` tool."""
34
+
35
+ path: str = Field(min_length=1, description="Workspace-relative or absolute path")
36
+ encoding: str = Field(default="utf-8", min_length=1)
37
+
38
+
39
+ class WriteFileArguments(BaseModel):
40
+ """Arguments for the ``write_file`` tool."""
41
+
42
+ path: str = Field(min_length=1, description="Workspace-relative or absolute path")
43
+ content: str = Field(description="Full file content to write")
44
+ encoding: str = Field(default="utf-8", min_length=1)
45
+
46
+
47
+ class WorkspaceNotBoundError(RuntimeError):
48
+ """Raised when a file tool is invoked without an active workspace binding."""
49
+
50
+
51
+ _workspace_stack: list[Workspace] = []
52
+
53
+
54
+ @contextmanager
55
+ def bind_workspace(workspace: Workspace) -> Generator[None, None, None]:
56
+ """Push ``workspace`` onto the active binding for the duration of the block."""
57
+
58
+ _workspace_stack.append(workspace)
59
+ try:
60
+ yield
61
+ finally:
62
+ _workspace_stack.pop()
63
+
64
+
65
+ def _current_workspace() -> Workspace:
66
+ if not _workspace_stack:
67
+ raise WorkspaceNotBoundError(
68
+ "file tool invoked without an active workspace; wrap the run in "
69
+ "avo.app_tools.file_tools.bind_workspace(...)"
70
+ )
71
+ return _workspace_stack[-1]
72
+
73
+
74
+ async def _read_file(arguments: ReadFileArguments) -> dict[str, Any]:
75
+ workspace = _current_workspace()
76
+ resolved = workspace.validate_path(arguments.path, must_exist=True)
77
+ text = resolved.read_text(encoding=arguments.encoding)
78
+ return {
79
+ "path": str(resolved),
80
+ "size": len(text.encode(arguments.encoding)),
81
+ "content": text,
82
+ }
83
+
84
+
85
+ async def _write_file(arguments: WriteFileArguments) -> dict[str, Any]:
86
+ workspace = _current_workspace()
87
+ resolved = workspace.validate_for_write(arguments.path)
88
+ encoded = arguments.content.encode(arguments.encoding)
89
+ # Open the leaf explicitly, refusing to follow symlinks at the leaf so
90
+ # a model cannot redirect a write to an external target after the
91
+ # workspace check. O_NOFOLLOW is POSIX-only; on Windows we accept the
92
+ # residual TOCTOU window.
93
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
94
+ if hasattr(os, "O_NOFOLLOW"):
95
+ flags |= os.O_NOFOLLOW
96
+ fd = os.open(resolved, flags, 0o644)
97
+ try:
98
+ os.write(fd, encoded)
99
+ finally:
100
+ os.close(fd)
101
+ return {
102
+ "path": str(resolved),
103
+ "size": len(encoded),
104
+ }
105
+
106
+
107
+ def read_file_tool() -> PublicFunctionTool[ReadFileArguments]:
108
+ """Return a :class:`FunctionTool` that reads a file inside the active workspace."""
109
+
110
+ return PublicFunctionTool(
111
+ name="read_file",
112
+ description=(
113
+ "Read the full content of a file inside the workspace. Paths "
114
+ "are resolved against the workspace root; any traversal "
115
+ "outside the workspace is rejected before the file is opened."
116
+ ),
117
+ arguments_model=ReadFileArguments,
118
+ function=_read_file,
119
+ )
120
+
121
+
122
+ def write_file_tool() -> PublicFunctionTool[WriteFileArguments]:
123
+ """Return a :class:`FunctionTool` that writes a file inside the active workspace."""
124
+
125
+ return PublicFunctionTool(
126
+ name="write_file",
127
+ description=(
128
+ "Write content to a file inside the workspace, creating the "
129
+ "file if necessary. Existing files are overwritten. Paths "
130
+ "are resolved against the workspace root; any traversal "
131
+ "outside the workspace is rejected before the file is opened."
132
+ ),
133
+ arguments_model=WriteFileArguments,
134
+ function=_write_file,
135
+ )
136
+
137
+
138
+ __all__ = [
139
+ "ReadFileArguments",
140
+ "WorkspaceNotBoundError",
141
+ "WriteFileArguments",
142
+ "bind_workspace",
143
+ "read_file_tool",
144
+ "write_file_tool",
145
+ ]
@@ -0,0 +1,80 @@
1
+ """``git_status`` tool — surface repository state to the model.
2
+
3
+ The tool binds to the active :class:`Workspace` and walks up to the
4
+ nearest git working tree. Path traversal is rejected at construction;
5
+ the tool only ever returns paths inside the workspace root.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from avo import FunctionTool as PublicFunctionTool
16
+
17
+ from ..workspace.git import GitRepository, GitStatus
18
+ from .edit_file import EditFileError
19
+ from .file_tools import _workspace_stack
20
+ from .workspace import Workspace
21
+
22
+ _DEFAULT_MAX_FILES = 20
23
+
24
+
25
+ class GitStatusArguments(BaseModel):
26
+ """Arguments for the ``git_status`` tool."""
27
+
28
+ include_untracked: bool = Field(default=True)
29
+ max_files: int = Field(default=_DEFAULT_MAX_FILES, gt=0, le=500)
30
+
31
+
32
+ def _current_workspace() -> Workspace:
33
+ if not _workspace_stack:
34
+ raise EditFileError(
35
+ "git_status invoked without an active workspace; wrap the run in "
36
+ "avo.app_tools.file_tools.bind_workspace(...)"
37
+ )
38
+ return _workspace_stack[-1]
39
+
40
+
41
+ def _summarise(status: GitStatus, arguments: GitStatusArguments) -> dict[str, Any]:
42
+ payload: dict[str, Any] = {
43
+ "root": str(status.root),
44
+ "branch": status.branch,
45
+ "clean": status.clean,
46
+ "modified_count": len(status.modified),
47
+ "untracked_count": len(status.untracked),
48
+ }
49
+ if arguments.include_untracked:
50
+ payload["untracked"] = list(status.untracked[: arguments.max_files])
51
+ payload["modified"] = list(status.modified[: arguments.max_files])
52
+ payload["truncated"] = len(status.modified) > arguments.max_files
53
+ return payload
54
+
55
+
56
+ async def _git_status(arguments: GitStatusArguments) -> dict[str, Any]:
57
+ workspace = _current_workspace()
58
+ repo = GitRepository(workspace.root)
59
+ status = repo.status()
60
+ return _summarise(status, arguments)
61
+
62
+
63
+ def git_status_tool() -> PublicFunctionTool[GitStatusArguments]:
64
+ """Return a :class:`FunctionTool` that surfaces git state."""
65
+
66
+ return PublicFunctionTool(
67
+ name="git_status",
68
+ description=(
69
+ "Return the git status of the active workspace — branch, "
70
+ "modified files, and (optionally) untracked files. Read-only."
71
+ ),
72
+ arguments_model=GitStatusArguments,
73
+ function=_git_status,
74
+ )
75
+
76
+
77
+ __all__ = ["GitStatusArguments", "git_status_tool"]
78
+
79
+
80
+ _ = Path # re-export for type checker
@@ -0,0 +1,96 @@
1
+ """``glob`` tool: enumerate workspace files matching a pattern.
2
+
3
+ The tool resolves ``path`` against the active :class:`Workspace`,
4
+ rejects any traversal, then walks with :meth:`pathlib.Path.glob` (or
5
+ :meth:`rglob` when ``recursive=True``). Results are returned as paths
6
+ relative to the workspace root so the model never sees absolute
7
+ locations outside the workspace.
8
+
9
+ Output is sorted and capped at ``max_results`` (default 200) so a
10
+ generous pattern cannot flood the context window.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from avo import FunctionTool as PublicFunctionTool
20
+
21
+ from .edit_file import EditFileError # reuse the workspace-binding contract
22
+ from .file_tools import _workspace_stack
23
+ from .workspace import Workspace
24
+
25
+ _DEFAULT_MAX_RESULTS = 200
26
+
27
+
28
+ class GlobArguments(BaseModel):
29
+ """Arguments for the ``glob`` tool."""
30
+
31
+ pattern: str = Field(min_length=1, description="Glob pattern, e.g. ``**/*.py``")
32
+ path: str = Field(
33
+ default=".",
34
+ description="Workspace-relative or absolute directory to search in",
35
+ )
36
+ recursive: bool = Field(default=True)
37
+ max_results: int = Field(default=_DEFAULT_MAX_RESULTS, gt=0, le=10_000)
38
+
39
+
40
+ def _current_glob_workspace() -> Workspace:
41
+ if not _workspace_stack:
42
+ raise EditFileError(
43
+ "glob invoked without an active workspace; wrap the run in "
44
+ "avo.app_tools.file_tools.bind_workspace(...)"
45
+ )
46
+ return _workspace_stack[-1]
47
+
48
+
49
+ async def _glob(arguments: GlobArguments) -> dict[str, Any]:
50
+ workspace = _current_glob_workspace()
51
+ base = workspace.validate_path(arguments.path, must_exist=True)
52
+ if not base.is_dir():
53
+ raise EditFileError(f"glob path is not a directory: {arguments.path}")
54
+
55
+ walker = base.rglob if arguments.recursive else base.glob
56
+ matches = sorted(walker(arguments.pattern))
57
+
58
+ relative: list[str] = []
59
+ truncated = False
60
+ for candidate in matches:
61
+ if not candidate.is_file() and not candidate.is_dir():
62
+ continue
63
+ try:
64
+ relative_path = candidate.resolve().relative_to(workspace.root)
65
+ except ValueError as exc: # pragma: no cover - guarded by validate_path
66
+ raise EditFileError(f"escape detected: {candidate}") from exc
67
+ if len(relative) >= arguments.max_results:
68
+ truncated = True
69
+ break
70
+ relative.append(relative_path.as_posix())
71
+
72
+ return {
73
+ "pattern": arguments.pattern,
74
+ "base": str(base),
75
+ "count": len(relative),
76
+ "truncated": truncated,
77
+ "matches": relative,
78
+ }
79
+
80
+
81
+ def glob_tool() -> PublicFunctionTool[GlobArguments]:
82
+ """Return a :class:`FunctionTool` that enumerates workspace files."""
83
+
84
+ return PublicFunctionTool(
85
+ name="glob",
86
+ description=(
87
+ "List files inside the workspace matching a glob pattern (e.g. "
88
+ "``**/*.py``). Paths are returned relative to the workspace root; "
89
+ "any traversal that escapes the root is rejected before the walk."
90
+ ),
91
+ arguments_model=GlobArguments,
92
+ function=_glob,
93
+ )
94
+
95
+
96
+ __all__ = ["GlobArguments", "glob_tool"]