vanna 0.7.8__py3-none-any.whl → 2.0.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.
- vanna/__init__.py +167 -395
- vanna/agents/__init__.py +7 -0
- vanna/capabilities/__init__.py +17 -0
- vanna/capabilities/agent_memory/__init__.py +21 -0
- vanna/capabilities/agent_memory/base.py +103 -0
- vanna/capabilities/agent_memory/models.py +53 -0
- vanna/capabilities/file_system/__init__.py +14 -0
- vanna/capabilities/file_system/base.py +71 -0
- vanna/capabilities/file_system/models.py +25 -0
- vanna/capabilities/sql_runner/__init__.py +13 -0
- vanna/capabilities/sql_runner/base.py +37 -0
- vanna/capabilities/sql_runner/models.py +13 -0
- vanna/components/__init__.py +92 -0
- vanna/components/base.py +11 -0
- vanna/components/rich/__init__.py +83 -0
- vanna/components/rich/containers/__init__.py +7 -0
- vanna/components/rich/containers/card.py +20 -0
- vanna/components/rich/data/__init__.py +9 -0
- vanna/components/rich/data/chart.py +17 -0
- vanna/components/rich/data/dataframe.py +93 -0
- vanna/components/rich/feedback/__init__.py +21 -0
- vanna/components/rich/feedback/badge.py +16 -0
- vanna/components/rich/feedback/icon_text.py +14 -0
- vanna/components/rich/feedback/log_viewer.py +41 -0
- vanna/components/rich/feedback/notification.py +19 -0
- vanna/components/rich/feedback/progress.py +37 -0
- vanna/components/rich/feedback/status_card.py +28 -0
- vanna/components/rich/feedback/status_indicator.py +14 -0
- vanna/components/rich/interactive/__init__.py +21 -0
- vanna/components/rich/interactive/button.py +95 -0
- vanna/components/rich/interactive/task_list.py +58 -0
- vanna/components/rich/interactive/ui_state.py +93 -0
- vanna/components/rich/specialized/__init__.py +7 -0
- vanna/components/rich/specialized/artifact.py +20 -0
- vanna/components/rich/text.py +16 -0
- vanna/components/simple/__init__.py +15 -0
- vanna/components/simple/image.py +15 -0
- vanna/components/simple/link.py +15 -0
- vanna/components/simple/text.py +11 -0
- vanna/core/__init__.py +193 -0
- vanna/core/_compat.py +19 -0
- vanna/core/agent/__init__.py +10 -0
- vanna/core/agent/agent.py +1407 -0
- vanna/core/agent/config.py +123 -0
- vanna/core/audit/__init__.py +28 -0
- vanna/core/audit/base.py +299 -0
- vanna/core/audit/models.py +131 -0
- vanna/core/component_manager.py +329 -0
- vanna/core/components.py +53 -0
- vanna/core/enhancer/__init__.py +11 -0
- vanna/core/enhancer/base.py +94 -0
- vanna/core/enhancer/default.py +118 -0
- vanna/core/enricher/__init__.py +10 -0
- vanna/core/enricher/base.py +59 -0
- vanna/core/errors.py +47 -0
- vanna/core/evaluation/__init__.py +81 -0
- vanna/core/evaluation/base.py +186 -0
- vanna/core/evaluation/dataset.py +254 -0
- vanna/core/evaluation/evaluators.py +376 -0
- vanna/core/evaluation/report.py +289 -0
- vanna/core/evaluation/runner.py +313 -0
- vanna/core/filter/__init__.py +10 -0
- vanna/core/filter/base.py +67 -0
- vanna/core/lifecycle/__init__.py +10 -0
- vanna/core/lifecycle/base.py +83 -0
- vanna/core/llm/__init__.py +16 -0
- vanna/core/llm/base.py +40 -0
- vanna/core/llm/models.py +61 -0
- vanna/core/middleware/__init__.py +10 -0
- vanna/core/middleware/base.py +69 -0
- vanna/core/observability/__init__.py +11 -0
- vanna/core/observability/base.py +88 -0
- vanna/core/observability/models.py +47 -0
- vanna/core/recovery/__init__.py +11 -0
- vanna/core/recovery/base.py +84 -0
- vanna/core/recovery/models.py +32 -0
- vanna/core/registry.py +278 -0
- vanna/core/rich_component.py +156 -0
- vanna/core/simple_component.py +27 -0
- vanna/core/storage/__init__.py +14 -0
- vanna/core/storage/base.py +46 -0
- vanna/core/storage/models.py +46 -0
- vanna/core/system_prompt/__init__.py +13 -0
- vanna/core/system_prompt/base.py +36 -0
- vanna/core/system_prompt/default.py +157 -0
- vanna/core/tool/__init__.py +18 -0
- vanna/core/tool/base.py +70 -0
- vanna/core/tool/models.py +84 -0
- vanna/core/user/__init__.py +17 -0
- vanna/core/user/base.py +29 -0
- vanna/core/user/models.py +25 -0
- vanna/core/user/request_context.py +70 -0
- vanna/core/user/resolver.py +42 -0
- vanna/core/validation.py +164 -0
- vanna/core/workflow/__init__.py +12 -0
- vanna/core/workflow/base.py +254 -0
- vanna/core/workflow/default.py +789 -0
- vanna/examples/__init__.py +1 -0
- vanna/examples/__main__.py +44 -0
- vanna/examples/anthropic_quickstart.py +80 -0
- vanna/examples/artifact_example.py +293 -0
- vanna/examples/claude_sqlite_example.py +236 -0
- vanna/examples/coding_agent_example.py +300 -0
- vanna/examples/custom_system_prompt_example.py +174 -0
- vanna/examples/default_workflow_handler_example.py +208 -0
- vanna/examples/email_auth_example.py +340 -0
- vanna/examples/evaluation_example.py +269 -0
- vanna/examples/extensibility_example.py +262 -0
- vanna/examples/minimal_example.py +67 -0
- vanna/examples/mock_auth_example.py +227 -0
- vanna/examples/mock_custom_tool.py +311 -0
- vanna/examples/mock_quickstart.py +79 -0
- vanna/examples/mock_quota_example.py +145 -0
- vanna/examples/mock_rich_components_demo.py +396 -0
- vanna/examples/mock_sqlite_example.py +223 -0
- vanna/examples/openai_quickstart.py +83 -0
- vanna/examples/primitive_components_demo.py +305 -0
- vanna/examples/quota_lifecycle_example.py +139 -0
- vanna/examples/visualization_example.py +251 -0
- vanna/integrations/__init__.py +17 -0
- vanna/integrations/anthropic/__init__.py +9 -0
- vanna/integrations/anthropic/llm.py +270 -0
- vanna/integrations/azureopenai/__init__.py +9 -0
- vanna/integrations/azureopenai/llm.py +329 -0
- vanna/integrations/azuresearch/__init__.py +7 -0
- vanna/integrations/azuresearch/agent_memory.py +413 -0
- vanna/integrations/bigquery/__init__.py +5 -0
- vanna/integrations/bigquery/sql_runner.py +81 -0
- vanna/integrations/chromadb/__init__.py +104 -0
- vanna/integrations/chromadb/agent_memory.py +416 -0
- vanna/integrations/clickhouse/__init__.py +5 -0
- vanna/integrations/clickhouse/sql_runner.py +82 -0
- vanna/integrations/duckdb/__init__.py +5 -0
- vanna/integrations/duckdb/sql_runner.py +65 -0
- vanna/integrations/faiss/__init__.py +7 -0
- vanna/integrations/faiss/agent_memory.py +431 -0
- vanna/integrations/google/__init__.py +9 -0
- vanna/integrations/google/gemini.py +370 -0
- vanna/integrations/hive/__init__.py +5 -0
- vanna/integrations/hive/sql_runner.py +87 -0
- vanna/integrations/local/__init__.py +17 -0
- vanna/integrations/local/agent_memory/__init__.py +7 -0
- vanna/integrations/local/agent_memory/in_memory.py +285 -0
- vanna/integrations/local/audit.py +59 -0
- vanna/integrations/local/file_system.py +242 -0
- vanna/integrations/local/file_system_conversation_store.py +255 -0
- vanna/integrations/local/storage.py +62 -0
- vanna/integrations/marqo/__init__.py +7 -0
- vanna/integrations/marqo/agent_memory.py +354 -0
- vanna/integrations/milvus/__init__.py +7 -0
- vanna/integrations/milvus/agent_memory.py +458 -0
- vanna/integrations/mock/__init__.py +9 -0
- vanna/integrations/mock/llm.py +65 -0
- vanna/integrations/mssql/__init__.py +5 -0
- vanna/integrations/mssql/sql_runner.py +66 -0
- vanna/integrations/mysql/__init__.py +5 -0
- vanna/integrations/mysql/sql_runner.py +92 -0
- vanna/integrations/ollama/__init__.py +7 -0
- vanna/integrations/ollama/llm.py +252 -0
- vanna/integrations/openai/__init__.py +10 -0
- vanna/integrations/openai/llm.py +267 -0
- vanna/integrations/openai/responses.py +163 -0
- vanna/integrations/opensearch/__init__.py +7 -0
- vanna/integrations/opensearch/agent_memory.py +411 -0
- vanna/integrations/oracle/__init__.py +5 -0
- vanna/integrations/oracle/sql_runner.py +75 -0
- vanna/integrations/pinecone/__init__.py +7 -0
- vanna/integrations/pinecone/agent_memory.py +329 -0
- vanna/integrations/plotly/__init__.py +5 -0
- vanna/integrations/plotly/chart_generator.py +313 -0
- vanna/integrations/postgres/__init__.py +9 -0
- vanna/integrations/postgres/sql_runner.py +112 -0
- vanna/integrations/premium/agent_memory/__init__.py +7 -0
- vanna/integrations/premium/agent_memory/premium.py +186 -0
- vanna/integrations/presto/__init__.py +5 -0
- vanna/integrations/presto/sql_runner.py +107 -0
- vanna/integrations/qdrant/__init__.py +7 -0
- vanna/integrations/qdrant/agent_memory.py +461 -0
- vanna/integrations/snowflake/__init__.py +5 -0
- vanna/integrations/snowflake/sql_runner.py +147 -0
- vanna/integrations/sqlite/__init__.py +9 -0
- vanna/integrations/sqlite/sql_runner.py +65 -0
- vanna/integrations/weaviate/__init__.py +7 -0
- vanna/integrations/weaviate/agent_memory.py +428 -0
- vanna/{ZhipuAI → legacy/ZhipuAI}/ZhipuAI_embeddings.py +11 -11
- vanna/legacy/__init__.py +403 -0
- vanna/legacy/adapter.py +463 -0
- vanna/{advanced → legacy/advanced}/__init__.py +3 -1
- vanna/{anthropic → legacy/anthropic}/anthropic_chat.py +9 -7
- vanna/{azuresearch → legacy/azuresearch}/azuresearch_vector.py +79 -41
- vanna/{base → legacy/base}/base.py +247 -223
- vanna/legacy/bedrock/__init__.py +1 -0
- vanna/{bedrock → legacy/bedrock}/bedrock_converse.py +13 -12
- vanna/{chromadb → legacy/chromadb}/chromadb_vector.py +3 -1
- vanna/legacy/cohere/__init__.py +2 -0
- vanna/{cohere → legacy/cohere}/cohere_chat.py +19 -14
- vanna/{cohere → legacy/cohere}/cohere_embeddings.py +25 -19
- vanna/{deepseek → legacy/deepseek}/deepseek_chat.py +5 -6
- vanna/legacy/faiss/__init__.py +1 -0
- vanna/{faiss → legacy/faiss}/faiss.py +113 -59
- vanna/{flask → legacy/flask}/__init__.py +84 -43
- vanna/{flask → legacy/flask}/assets.py +5 -5
- vanna/{flask → legacy/flask}/auth.py +5 -4
- vanna/{google → legacy/google}/bigquery_vector.py +75 -42
- vanna/{google → legacy/google}/gemini_chat.py +7 -3
- vanna/{hf → legacy/hf}/hf.py +0 -1
- vanna/{milvus → legacy/milvus}/milvus_vector.py +58 -35
- vanna/{mock → legacy/mock}/llm.py +0 -1
- vanna/legacy/mock/vectordb.py +67 -0
- vanna/legacy/ollama/ollama.py +110 -0
- vanna/{openai → legacy/openai}/openai_chat.py +2 -6
- vanna/legacy/opensearch/opensearch_vector.py +369 -0
- vanna/legacy/opensearch/opensearch_vector_semantic.py +200 -0
- vanna/legacy/oracle/oracle_vector.py +584 -0
- vanna/{pgvector → legacy/pgvector}/pgvector.py +42 -13
- vanna/{qdrant → legacy/qdrant}/qdrant.py +2 -6
- vanna/legacy/qianfan/Qianfan_Chat.py +170 -0
- vanna/legacy/qianfan/Qianfan_embeddings.py +36 -0
- vanna/legacy/qianwen/QianwenAI_chat.py +132 -0
- vanna/{remote.py → legacy/remote.py} +28 -26
- vanna/{utils.py → legacy/utils.py} +6 -11
- vanna/{vannadb → legacy/vannadb}/vannadb_vector.py +115 -46
- vanna/{vllm → legacy/vllm}/vllm.py +5 -6
- vanna/{weaviate → legacy/weaviate}/weaviate_vector.py +59 -40
- vanna/{xinference → legacy/xinference}/xinference.py +6 -6
- vanna/py.typed +0 -0
- vanna/servers/__init__.py +16 -0
- vanna/servers/__main__.py +8 -0
- vanna/servers/base/__init__.py +18 -0
- vanna/servers/base/chat_handler.py +65 -0
- vanna/servers/base/models.py +111 -0
- vanna/servers/base/rich_chat_handler.py +141 -0
- vanna/servers/base/templates.py +331 -0
- vanna/servers/cli/__init__.py +7 -0
- vanna/servers/cli/server_runner.py +204 -0
- vanna/servers/fastapi/__init__.py +7 -0
- vanna/servers/fastapi/app.py +163 -0
- vanna/servers/fastapi/routes.py +183 -0
- vanna/servers/flask/__init__.py +7 -0
- vanna/servers/flask/app.py +132 -0
- vanna/servers/flask/routes.py +137 -0
- vanna/tools/__init__.py +41 -0
- vanna/tools/agent_memory.py +322 -0
- vanna/tools/file_system.py +879 -0
- vanna/tools/python.py +222 -0
- vanna/tools/run_sql.py +165 -0
- vanna/tools/visualize_data.py +195 -0
- vanna/utils/__init__.py +0 -0
- vanna/web_components/__init__.py +44 -0
- vanna-2.0.0.dist-info/METADATA +485 -0
- vanna-2.0.0.dist-info/RECORD +289 -0
- vanna-2.0.0.dist-info/entry_points.txt +3 -0
- vanna/bedrock/__init__.py +0 -1
- vanna/cohere/__init__.py +0 -2
- vanna/faiss/__init__.py +0 -1
- vanna/mock/vectordb.py +0 -55
- vanna/ollama/ollama.py +0 -103
- vanna/opensearch/opensearch_vector.py +0 -392
- vanna/opensearch/opensearch_vector_semantic.py +0 -175
- vanna/oracle/oracle_vector.py +0 -585
- vanna/qianfan/Qianfan_Chat.py +0 -165
- vanna/qianfan/Qianfan_embeddings.py +0 -36
- vanna/qianwen/QianwenAI_chat.py +0 -133
- vanna-0.7.8.dist-info/METADATA +0 -408
- vanna-0.7.8.dist-info/RECORD +0 -79
- /vanna/{ZhipuAI → legacy/ZhipuAI}/ZhipuAI_Chat.py +0 -0
- /vanna/{ZhipuAI → legacy/ZhipuAI}/__init__.py +0 -0
- /vanna/{anthropic → legacy/anthropic}/__init__.py +0 -0
- /vanna/{azuresearch → legacy/azuresearch}/__init__.py +0 -0
- /vanna/{base → legacy/base}/__init__.py +0 -0
- /vanna/{chromadb → legacy/chromadb}/__init__.py +0 -0
- /vanna/{deepseek → legacy/deepseek}/__init__.py +0 -0
- /vanna/{exceptions → legacy/exceptions}/__init__.py +0 -0
- /vanna/{google → legacy/google}/__init__.py +0 -0
- /vanna/{hf → legacy/hf}/__init__.py +0 -0
- /vanna/{local.py → legacy/local.py} +0 -0
- /vanna/{marqo → legacy/marqo}/__init__.py +0 -0
- /vanna/{marqo → legacy/marqo}/marqo.py +0 -0
- /vanna/{milvus → legacy/milvus}/__init__.py +0 -0
- /vanna/{mistral → legacy/mistral}/__init__.py +0 -0
- /vanna/{mistral → legacy/mistral}/mistral.py +0 -0
- /vanna/{mock → legacy/mock}/__init__.py +0 -0
- /vanna/{mock → legacy/mock}/embedding.py +0 -0
- /vanna/{ollama → legacy/ollama}/__init__.py +0 -0
- /vanna/{openai → legacy/openai}/__init__.py +0 -0
- /vanna/{openai → legacy/openai}/openai_embeddings.py +0 -0
- /vanna/{opensearch → legacy/opensearch}/__init__.py +0 -0
- /vanna/{oracle → legacy/oracle}/__init__.py +0 -0
- /vanna/{pgvector → legacy/pgvector}/__init__.py +0 -0
- /vanna/{pinecone → legacy/pinecone}/__init__.py +0 -0
- /vanna/{pinecone → legacy/pinecone}/pinecone_vector.py +0 -0
- /vanna/{qdrant → legacy/qdrant}/__init__.py +0 -0
- /vanna/{qianfan → legacy/qianfan}/__init__.py +0 -0
- /vanna/{qianwen → legacy/qianwen}/QianwenAI_embeddings.py +0 -0
- /vanna/{qianwen → legacy/qianwen}/__init__.py +0 -0
- /vanna/{types → legacy/types}/__init__.py +0 -0
- /vanna/{vannadb → legacy/vannadb}/__init__.py +0 -0
- /vanna/{vllm → legacy/vllm}/__init__.py +0 -0
- /vanna/{weaviate → legacy/weaviate}/__init__.py +0 -0
- /vanna/{xinference → legacy/xinference}/__init__.py +0 -0
- {vanna-0.7.8.dist-info → vanna-2.0.0.dist-info}/WHEEL +0 -0
- {vanna-0.7.8.dist-info → vanna-2.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File system tools with dependency injection support.
|
|
3
|
+
|
|
4
|
+
This module provides file system operations through an abstract FileSystem interface,
|
|
5
|
+
allowing for different implementations (local, remote, sandboxed, etc.).
|
|
6
|
+
The tools accept a FileSystem instance via dependency injection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, List, Optional, Type
|
|
14
|
+
import difflib
|
|
15
|
+
import hashlib
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field, model_validator
|
|
18
|
+
|
|
19
|
+
from vanna.core.tool import Tool, ToolContext, ToolResult
|
|
20
|
+
from vanna.components import (
|
|
21
|
+
UiComponent,
|
|
22
|
+
CardComponent,
|
|
23
|
+
NotificationComponent,
|
|
24
|
+
ComponentType,
|
|
25
|
+
SimpleTextComponent,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
MAX_SEARCH_FILE_BYTES = 1_000_000
|
|
29
|
+
FILENAME_MATCH_SNIPPET = "[filename match]"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class FileSearchMatch:
|
|
34
|
+
"""Represents a single search result within a file system."""
|
|
35
|
+
|
|
36
|
+
path: str
|
|
37
|
+
snippet: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CommandResult:
|
|
42
|
+
"""Represents the result of executing a shell command."""
|
|
43
|
+
|
|
44
|
+
stdout: str
|
|
45
|
+
stderr: str
|
|
46
|
+
returncode: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _make_snippet(text: str, query: str, context_window: int = 60) -> Optional[str]:
|
|
50
|
+
"""Return a short snippet around the first occurrence of query in text."""
|
|
51
|
+
|
|
52
|
+
lowered = text.lower()
|
|
53
|
+
index = lowered.find(query.lower())
|
|
54
|
+
if index == -1:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
start = max(0, index - context_window)
|
|
58
|
+
end = min(len(text), index + len(query) + context_window)
|
|
59
|
+
snippet = text[start:end].replace("\n", " ").strip()
|
|
60
|
+
|
|
61
|
+
if start > 0:
|
|
62
|
+
snippet = f"…{snippet}"
|
|
63
|
+
if end < len(text):
|
|
64
|
+
snippet = f"{snippet}…"
|
|
65
|
+
|
|
66
|
+
return snippet
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FileSystem(ABC):
|
|
70
|
+
"""Abstract base class for file system operations."""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
async def list_files(self, directory: str, context: ToolContext) -> List[str]:
|
|
74
|
+
"""List files in a directory."""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def read_file(self, filename: str, context: ToolContext) -> str:
|
|
79
|
+
"""Read the contents of a file."""
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
async def write_file(
|
|
84
|
+
self, filename: str, content: str, context: ToolContext, overwrite: bool = False
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Write content to a file."""
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
async def exists(self, path: str, context: ToolContext) -> bool:
|
|
91
|
+
"""Check if a file or directory exists."""
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
async def is_directory(self, path: str, context: ToolContext) -> bool:
|
|
96
|
+
"""Check if a path is a directory."""
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
async def search_files(
|
|
101
|
+
self,
|
|
102
|
+
query: str,
|
|
103
|
+
context: ToolContext,
|
|
104
|
+
*,
|
|
105
|
+
max_results: int = 20,
|
|
106
|
+
include_content: bool = False,
|
|
107
|
+
) -> List[FileSearchMatch]:
|
|
108
|
+
"""Search for files matching a query within the accessible namespace."""
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
@abstractmethod
|
|
112
|
+
async def run_bash(
|
|
113
|
+
self,
|
|
114
|
+
command: str,
|
|
115
|
+
context: ToolContext,
|
|
116
|
+
*,
|
|
117
|
+
timeout: Optional[float] = None,
|
|
118
|
+
) -> CommandResult:
|
|
119
|
+
"""Execute a bash command within the accessible namespace."""
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class LocalFileSystem(FileSystem):
|
|
124
|
+
"""Local file system implementation with per-user isolation."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, working_directory: str = "."):
|
|
127
|
+
"""Initialize with a working directory.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
working_directory: Base directory where user-specific folders will be created
|
|
131
|
+
"""
|
|
132
|
+
self.working_directory = Path(working_directory)
|
|
133
|
+
|
|
134
|
+
def _get_user_directory(self, context: ToolContext) -> Path:
|
|
135
|
+
"""Get the user-specific directory by hashing the user ID.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
context: Tool context containing user information
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Path to the user-specific directory
|
|
142
|
+
"""
|
|
143
|
+
# Hash the user ID to create a directory name
|
|
144
|
+
user_hash = hashlib.sha256(context.user.id.encode()).hexdigest()[:16]
|
|
145
|
+
user_dir = self.working_directory / user_hash
|
|
146
|
+
|
|
147
|
+
# Create the directory if it doesn't exist
|
|
148
|
+
user_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
return user_dir
|
|
151
|
+
|
|
152
|
+
def _resolve_path(self, path: str, context: ToolContext) -> Path:
|
|
153
|
+
"""Resolve a path relative to the user's directory.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
path: Path relative to user directory
|
|
157
|
+
context: Tool context containing user information
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Absolute path within user's directory
|
|
161
|
+
"""
|
|
162
|
+
user_dir = self._get_user_directory(context)
|
|
163
|
+
resolved = user_dir / path
|
|
164
|
+
|
|
165
|
+
# Ensure the path is within the user's directory (prevent directory traversal)
|
|
166
|
+
try:
|
|
167
|
+
resolved.resolve().relative_to(user_dir.resolve())
|
|
168
|
+
except ValueError:
|
|
169
|
+
raise PermissionError(
|
|
170
|
+
f"Access denied: path '{path}' is outside user directory"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return resolved
|
|
174
|
+
|
|
175
|
+
async def list_files(self, directory: str, context: ToolContext) -> List[str]:
|
|
176
|
+
"""List files in a directory within the user's isolated space."""
|
|
177
|
+
directory_path = self._resolve_path(directory, context)
|
|
178
|
+
|
|
179
|
+
if not directory_path.exists():
|
|
180
|
+
raise FileNotFoundError(f"Directory '{directory}' does not exist")
|
|
181
|
+
|
|
182
|
+
if not directory_path.is_dir():
|
|
183
|
+
raise NotADirectoryError(f"'{directory}' is not a directory")
|
|
184
|
+
|
|
185
|
+
files = []
|
|
186
|
+
for item in directory_path.iterdir():
|
|
187
|
+
if item.is_file():
|
|
188
|
+
files.append(item.name)
|
|
189
|
+
|
|
190
|
+
return sorted(files)
|
|
191
|
+
|
|
192
|
+
async def read_file(self, filename: str, context: ToolContext) -> str:
|
|
193
|
+
"""Read the contents of a file within the user's isolated space."""
|
|
194
|
+
file_path = self._resolve_path(filename, context)
|
|
195
|
+
|
|
196
|
+
if not file_path.exists():
|
|
197
|
+
raise FileNotFoundError(f"File '{filename}' does not exist")
|
|
198
|
+
|
|
199
|
+
if not file_path.is_file():
|
|
200
|
+
raise IsADirectoryError(f"'{filename}' is a directory, not a file")
|
|
201
|
+
|
|
202
|
+
return file_path.read_text(encoding="utf-8")
|
|
203
|
+
|
|
204
|
+
async def write_file(
|
|
205
|
+
self, filename: str, content: str, context: ToolContext, overwrite: bool = False
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Write content to a file within the user's isolated space."""
|
|
208
|
+
file_path = self._resolve_path(filename, context)
|
|
209
|
+
|
|
210
|
+
# Create parent directories if they don't exist
|
|
211
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
|
|
213
|
+
if file_path.exists() and not overwrite:
|
|
214
|
+
raise FileExistsError(
|
|
215
|
+
f"File '{filename}' already exists. Use overwrite=True to replace it."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
file_path.write_text(content, encoding="utf-8")
|
|
219
|
+
|
|
220
|
+
async def exists(self, path: str, context: ToolContext) -> bool:
|
|
221
|
+
"""Check if a file or directory exists within the user's isolated space."""
|
|
222
|
+
try:
|
|
223
|
+
resolved_path = self._resolve_path(path, context)
|
|
224
|
+
return resolved_path.exists()
|
|
225
|
+
except PermissionError:
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
async def is_directory(self, path: str, context: ToolContext) -> bool:
|
|
229
|
+
"""Check if a path is a directory within the user's isolated space."""
|
|
230
|
+
try:
|
|
231
|
+
resolved_path = self._resolve_path(path, context)
|
|
232
|
+
return resolved_path.exists() and resolved_path.is_dir()
|
|
233
|
+
except PermissionError:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
async def search_files(
|
|
237
|
+
self,
|
|
238
|
+
query: str,
|
|
239
|
+
context: ToolContext,
|
|
240
|
+
*,
|
|
241
|
+
max_results: int = 20,
|
|
242
|
+
include_content: bool = False,
|
|
243
|
+
) -> List[FileSearchMatch]:
|
|
244
|
+
"""Search for files within the user's isolated space."""
|
|
245
|
+
|
|
246
|
+
trimmed_query = query.strip()
|
|
247
|
+
if not trimmed_query:
|
|
248
|
+
raise ValueError("Search query must not be empty")
|
|
249
|
+
|
|
250
|
+
user_dir = self._get_user_directory(context)
|
|
251
|
+
matches: List[FileSearchMatch] = []
|
|
252
|
+
query_lower = trimmed_query.lower()
|
|
253
|
+
|
|
254
|
+
for path in user_dir.rglob("*"):
|
|
255
|
+
if len(matches) >= max_results:
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
if not path.is_file():
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
relative_path = path.relative_to(user_dir).as_posix()
|
|
262
|
+
include_entry = False
|
|
263
|
+
snippet: Optional[str] = None
|
|
264
|
+
|
|
265
|
+
if query_lower in path.name.lower():
|
|
266
|
+
include_entry = True
|
|
267
|
+
snippet = FILENAME_MATCH_SNIPPET
|
|
268
|
+
|
|
269
|
+
content: Optional[str] = None
|
|
270
|
+
if include_content:
|
|
271
|
+
try:
|
|
272
|
+
size = path.stat().st_size
|
|
273
|
+
except OSError:
|
|
274
|
+
if include_entry:
|
|
275
|
+
matches.append(
|
|
276
|
+
FileSearchMatch(path=relative_path, snippet=snippet)
|
|
277
|
+
)
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
if size <= MAX_SEARCH_FILE_BYTES:
|
|
281
|
+
try:
|
|
282
|
+
content = path.read_text(encoding="utf-8")
|
|
283
|
+
except (UnicodeDecodeError, OSError):
|
|
284
|
+
content = None
|
|
285
|
+
elif not include_entry:
|
|
286
|
+
# Skip oversized files if they do not match by name
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
if include_content and content is not None:
|
|
290
|
+
if query_lower in content.lower():
|
|
291
|
+
snippet = _make_snippet(content, trimmed_query) or snippet
|
|
292
|
+
include_entry = True
|
|
293
|
+
elif not include_entry:
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
if include_entry:
|
|
297
|
+
matches.append(FileSearchMatch(path=relative_path, snippet=snippet))
|
|
298
|
+
|
|
299
|
+
return matches
|
|
300
|
+
|
|
301
|
+
async def run_bash(
|
|
302
|
+
self,
|
|
303
|
+
command: str,
|
|
304
|
+
context: ToolContext,
|
|
305
|
+
*,
|
|
306
|
+
timeout: Optional[float] = None,
|
|
307
|
+
) -> CommandResult:
|
|
308
|
+
"""Execute a bash command within the user's isolated space."""
|
|
309
|
+
|
|
310
|
+
if not command.strip():
|
|
311
|
+
raise ValueError("Command must not be empty")
|
|
312
|
+
|
|
313
|
+
user_dir = self._get_user_directory(context)
|
|
314
|
+
|
|
315
|
+
process = await asyncio.create_subprocess_shell(
|
|
316
|
+
command,
|
|
317
|
+
stdout=asyncio.subprocess.PIPE,
|
|
318
|
+
stderr=asyncio.subprocess.PIPE,
|
|
319
|
+
cwd=str(user_dir),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
324
|
+
process.communicate(), timeout=timeout
|
|
325
|
+
)
|
|
326
|
+
except asyncio.TimeoutError as exc:
|
|
327
|
+
process.kill()
|
|
328
|
+
await process.wait()
|
|
329
|
+
raise TimeoutError(f"Command timed out after {timeout} seconds") from exc
|
|
330
|
+
|
|
331
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
332
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
333
|
+
|
|
334
|
+
return CommandResult(
|
|
335
|
+
stdout=stdout, stderr=stderr, returncode=process.returncode or 0
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class SearchFilesArgs(BaseModel):
|
|
340
|
+
"""Arguments for searching files."""
|
|
341
|
+
|
|
342
|
+
query: str = Field(description="Text to search for in file names or contents")
|
|
343
|
+
include_content: bool = Field(
|
|
344
|
+
default=True,
|
|
345
|
+
description="Whether to search within file contents in addition to file names",
|
|
346
|
+
)
|
|
347
|
+
max_results: int = Field(
|
|
348
|
+
default=20,
|
|
349
|
+
ge=1,
|
|
350
|
+
le=100,
|
|
351
|
+
description="Maximum number of matches to return",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class SearchFilesTool(Tool[SearchFilesArgs]):
|
|
356
|
+
"""Tool to search for files using the injected file system implementation."""
|
|
357
|
+
|
|
358
|
+
def __init__(self, file_system: Optional[FileSystem] = None):
|
|
359
|
+
self.file_system = file_system or LocalFileSystem()
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def name(self) -> str:
|
|
363
|
+
return "search_files"
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def description(self) -> str:
|
|
367
|
+
return "Search for files by name or content"
|
|
368
|
+
|
|
369
|
+
def get_args_schema(self) -> Type[SearchFilesArgs]:
|
|
370
|
+
return SearchFilesArgs
|
|
371
|
+
|
|
372
|
+
async def execute(self, context: ToolContext, args: SearchFilesArgs) -> ToolResult:
|
|
373
|
+
try:
|
|
374
|
+
matches = await self.file_system.search_files(
|
|
375
|
+
args.query,
|
|
376
|
+
context,
|
|
377
|
+
max_results=args.max_results,
|
|
378
|
+
include_content=args.include_content,
|
|
379
|
+
)
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
error_msg = f"Error searching files: {exc}"
|
|
382
|
+
return ToolResult(
|
|
383
|
+
success=False,
|
|
384
|
+
result_for_llm=error_msg,
|
|
385
|
+
ui_component=UiComponent(
|
|
386
|
+
rich_component=NotificationComponent(
|
|
387
|
+
type=ComponentType.NOTIFICATION,
|
|
388
|
+
level="error",
|
|
389
|
+
message=error_msg,
|
|
390
|
+
),
|
|
391
|
+
simple_component=SimpleTextComponent(text=error_msg),
|
|
392
|
+
),
|
|
393
|
+
error=str(exc),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not matches:
|
|
397
|
+
message = f"No matches found for '{args.query}'."
|
|
398
|
+
return ToolResult(
|
|
399
|
+
success=True,
|
|
400
|
+
result_for_llm=message,
|
|
401
|
+
ui_component=UiComponent(
|
|
402
|
+
rich_component=NotificationComponent(
|
|
403
|
+
type=ComponentType.NOTIFICATION,
|
|
404
|
+
level="info",
|
|
405
|
+
message=message,
|
|
406
|
+
),
|
|
407
|
+
simple_component=SimpleTextComponent(text=message),
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
lines: List[str] = []
|
|
412
|
+
for match in matches:
|
|
413
|
+
snippet = match.snippet
|
|
414
|
+
if snippet == FILENAME_MATCH_SNIPPET:
|
|
415
|
+
snippet_text = "(matched filename)"
|
|
416
|
+
elif snippet:
|
|
417
|
+
snippet_text = snippet
|
|
418
|
+
else:
|
|
419
|
+
snippet_text = ""
|
|
420
|
+
|
|
421
|
+
if snippet_text and len(snippet_text) > 200:
|
|
422
|
+
snippet_text = f"{snippet_text[:197]}…"
|
|
423
|
+
|
|
424
|
+
if snippet_text:
|
|
425
|
+
lines.append(f"- {match.path}: {snippet_text}")
|
|
426
|
+
else:
|
|
427
|
+
lines.append(f"- {match.path}")
|
|
428
|
+
|
|
429
|
+
summary = f"Found {len(matches)} match(es) for '{args.query}' (max {args.max_results})."
|
|
430
|
+
content = "\n".join(lines)
|
|
431
|
+
|
|
432
|
+
return ToolResult(
|
|
433
|
+
success=True,
|
|
434
|
+
result_for_llm=f"{summary}\n{content}",
|
|
435
|
+
ui_component=UiComponent(
|
|
436
|
+
rich_component=CardComponent(
|
|
437
|
+
type=ComponentType.CARD,
|
|
438
|
+
title=f"Search results for '{args.query}'",
|
|
439
|
+
content=content,
|
|
440
|
+
),
|
|
441
|
+
simple_component=SimpleTextComponent(text=summary),
|
|
442
|
+
),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class ListFilesArgs(BaseModel):
|
|
447
|
+
"""Arguments for listing files."""
|
|
448
|
+
|
|
449
|
+
directory: str = Field(
|
|
450
|
+
default=".", description="Directory to list (defaults to current)"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class ListFilesTool(Tool[ListFilesArgs]):
|
|
455
|
+
"""Tool to list files in a directory using dependency injection for file system access."""
|
|
456
|
+
|
|
457
|
+
def __init__(self, file_system: Optional[FileSystem] = None):
|
|
458
|
+
"""Initialize with optional file system dependency."""
|
|
459
|
+
self.file_system = file_system or LocalFileSystem()
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def name(self) -> str:
|
|
463
|
+
return "list_files"
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def description(self) -> str:
|
|
467
|
+
return "List files in a directory"
|
|
468
|
+
|
|
469
|
+
def get_args_schema(self) -> Type[ListFilesArgs]:
|
|
470
|
+
return ListFilesArgs
|
|
471
|
+
|
|
472
|
+
async def execute(self, context: ToolContext, args: ListFilesArgs) -> ToolResult:
|
|
473
|
+
try:
|
|
474
|
+
files = await self.file_system.list_files(args.directory, context)
|
|
475
|
+
|
|
476
|
+
if not files:
|
|
477
|
+
result = f"No files found in directory '{args.directory}'"
|
|
478
|
+
files_list = "No files found"
|
|
479
|
+
else:
|
|
480
|
+
files_list = "\n".join(f"- {f}" for f in files)
|
|
481
|
+
result = f"Files in '{args.directory}':\n{files_list}"
|
|
482
|
+
|
|
483
|
+
return ToolResult(
|
|
484
|
+
success=True,
|
|
485
|
+
result_for_llm=result,
|
|
486
|
+
ui_component=UiComponent(
|
|
487
|
+
rich_component=CardComponent(
|
|
488
|
+
type=ComponentType.CARD,
|
|
489
|
+
title=f"Files in {args.directory}",
|
|
490
|
+
content=files_list,
|
|
491
|
+
),
|
|
492
|
+
simple_component=SimpleTextComponent(text=result),
|
|
493
|
+
),
|
|
494
|
+
)
|
|
495
|
+
except Exception as e:
|
|
496
|
+
error_msg = f"Error listing files: {str(e)}"
|
|
497
|
+
return ToolResult(
|
|
498
|
+
success=False,
|
|
499
|
+
result_for_llm=error_msg,
|
|
500
|
+
ui_component=UiComponent(
|
|
501
|
+
rich_component=NotificationComponent(
|
|
502
|
+
type=ComponentType.NOTIFICATION,
|
|
503
|
+
level="error",
|
|
504
|
+
message=error_msg,
|
|
505
|
+
),
|
|
506
|
+
simple_component=SimpleTextComponent(text=error_msg),
|
|
507
|
+
),
|
|
508
|
+
error=str(e),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class ReadFileArgs(BaseModel):
|
|
513
|
+
"""Arguments for reading a file."""
|
|
514
|
+
|
|
515
|
+
filename: str = Field(description="Name of the file to read")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class ReadFileTool(Tool[ReadFileArgs]):
|
|
519
|
+
"""Tool to read file contents using dependency injection for file system access."""
|
|
520
|
+
|
|
521
|
+
def __init__(self, file_system: Optional[FileSystem] = None):
|
|
522
|
+
"""Initialize with optional file system dependency."""
|
|
523
|
+
self.file_system = file_system or LocalFileSystem()
|
|
524
|
+
|
|
525
|
+
@property
|
|
526
|
+
def name(self) -> str:
|
|
527
|
+
return "read_file"
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def description(self) -> str:
|
|
531
|
+
return "Read the contents of a file"
|
|
532
|
+
|
|
533
|
+
def get_args_schema(self) -> Type[ReadFileArgs]:
|
|
534
|
+
return ReadFileArgs
|
|
535
|
+
|
|
536
|
+
async def execute(self, context: ToolContext, args: ReadFileArgs) -> ToolResult:
|
|
537
|
+
try:
|
|
538
|
+
content = await self.file_system.read_file(args.filename, context)
|
|
539
|
+
result = f"Content of '{args.filename}':\n\n{content}"
|
|
540
|
+
|
|
541
|
+
return ToolResult(
|
|
542
|
+
success=True,
|
|
543
|
+
result_for_llm=result,
|
|
544
|
+
ui_component=UiComponent(
|
|
545
|
+
rich_component=CardComponent(
|
|
546
|
+
type=ComponentType.CARD,
|
|
547
|
+
title=f"Contents of {args.filename}",
|
|
548
|
+
content=content,
|
|
549
|
+
),
|
|
550
|
+
simple_component=SimpleTextComponent(
|
|
551
|
+
text=f"File content:\n{content}"
|
|
552
|
+
),
|
|
553
|
+
),
|
|
554
|
+
)
|
|
555
|
+
except Exception as e:
|
|
556
|
+
error_msg = f"Error reading file: {str(e)}"
|
|
557
|
+
return ToolResult(
|
|
558
|
+
success=False,
|
|
559
|
+
result_for_llm=error_msg,
|
|
560
|
+
ui_component=UiComponent(
|
|
561
|
+
rich_component=NotificationComponent(
|
|
562
|
+
type=ComponentType.NOTIFICATION,
|
|
563
|
+
level="error",
|
|
564
|
+
message=error_msg,
|
|
565
|
+
),
|
|
566
|
+
simple_component=SimpleTextComponent(text=error_msg),
|
|
567
|
+
),
|
|
568
|
+
error=str(e),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class WriteFileArgs(BaseModel):
|
|
573
|
+
"""Arguments for writing a file."""
|
|
574
|
+
|
|
575
|
+
filename: str = Field(description="Name of the file to write")
|
|
576
|
+
content: str = Field(description="Content to write to the file")
|
|
577
|
+
overwrite: bool = Field(
|
|
578
|
+
default=False, description="Whether to overwrite existing files"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class WriteFileTool(Tool[WriteFileArgs]):
|
|
583
|
+
"""Tool to write content to a file using dependency injection for file system access."""
|
|
584
|
+
|
|
585
|
+
def __init__(self, file_system: Optional[FileSystem] = None):
|
|
586
|
+
"""Initialize with optional file system dependency."""
|
|
587
|
+
self.file_system = file_system or LocalFileSystem()
|
|
588
|
+
|
|
589
|
+
@property
|
|
590
|
+
def name(self) -> str:
|
|
591
|
+
return "write_file"
|
|
592
|
+
|
|
593
|
+
@property
|
|
594
|
+
def description(self) -> str:
|
|
595
|
+
return "Write content to a file"
|
|
596
|
+
|
|
597
|
+
def get_args_schema(self) -> Type[WriteFileArgs]:
|
|
598
|
+
return WriteFileArgs
|
|
599
|
+
|
|
600
|
+
async def execute(self, context: ToolContext, args: WriteFileArgs) -> ToolResult:
|
|
601
|
+
try:
|
|
602
|
+
await self.file_system.write_file(
|
|
603
|
+
args.filename, args.content, context, args.overwrite
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
success_msg = f"Successfully wrote {len(args.content)} characters to '{args.filename}'"
|
|
607
|
+
|
|
608
|
+
return ToolResult(
|
|
609
|
+
success=True,
|
|
610
|
+
result_for_llm=success_msg,
|
|
611
|
+
ui_component=UiComponent(
|
|
612
|
+
rich_component=NotificationComponent(
|
|
613
|
+
type=ComponentType.NOTIFICATION,
|
|
614
|
+
level="success",
|
|
615
|
+
message=f"File '{args.filename}' written successfully",
|
|
616
|
+
),
|
|
617
|
+
simple_component=SimpleTextComponent(
|
|
618
|
+
text=f"Wrote to {args.filename}"
|
|
619
|
+
),
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
except Exception as e:
|
|
623
|
+
error_msg = f"Error writing file: {str(e)}"
|
|
624
|
+
return ToolResult(
|
|
625
|
+
success=False,
|
|
626
|
+
result_for_llm=error_msg,
|
|
627
|
+
ui_component=UiComponent(
|
|
628
|
+
rich_component=NotificationComponent(
|
|
629
|
+
type=ComponentType.NOTIFICATION,
|
|
630
|
+
level="error",
|
|
631
|
+
message=error_msg,
|
|
632
|
+
),
|
|
633
|
+
simple_component=SimpleTextComponent(text=error_msg),
|
|
634
|
+
),
|
|
635
|
+
error=str(e),
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class LineEdit(BaseModel):
|
|
640
|
+
"""Definition of a single line-based edit operation."""
|
|
641
|
+
|
|
642
|
+
start_line: int = Field(
|
|
643
|
+
ge=1, description="First line (1-based) affected by this edit"
|
|
644
|
+
)
|
|
645
|
+
end_line: Optional[int] = Field(
|
|
646
|
+
default=None,
|
|
647
|
+
description=(
|
|
648
|
+
"Last line (1-based, inclusive) to replace. Set to start_line - 1 to insert before start_line. "
|
|
649
|
+
"Defaults to start_line, replacing a single line."
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
new_content: str = Field(
|
|
653
|
+
default="", description="Replacement text (preserves provided newlines)"
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
@model_validator(mode="after")
|
|
657
|
+
def validate_line_range(self) -> "LineEdit":
|
|
658
|
+
effective_end = self.start_line if self.end_line is None else self.end_line
|
|
659
|
+
|
|
660
|
+
if effective_end < self.start_line - 1:
|
|
661
|
+
raise ValueError("end_line must be >= start_line - 1")
|
|
662
|
+
|
|
663
|
+
return self
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
class EditFileArgs(BaseModel):
|
|
667
|
+
"""Arguments for editing one or more sections within a file."""
|
|
668
|
+
|
|
669
|
+
filename: str = Field(description="Path to the file to edit")
|
|
670
|
+
edits: List[LineEdit] = Field(
|
|
671
|
+
description="List of edits to apply. Later entries should reference higher line numbers.",
|
|
672
|
+
min_length=1,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
class EditFileTool(Tool[EditFileArgs]):
|
|
677
|
+
"""Tool to apply line-based edits to an existing file."""
|
|
678
|
+
|
|
679
|
+
def __init__(self, file_system: Optional[FileSystem] = None):
|
|
680
|
+
self.file_system = file_system or LocalFileSystem()
|
|
681
|
+
|
|
682
|
+
@property
|
|
683
|
+
def name(self) -> str:
|
|
684
|
+
return "edit_file"
|
|
685
|
+
|
|
686
|
+
@property
|
|
687
|
+
def description(self) -> str:
|
|
688
|
+
return "Modify specific lines within a file"
|
|
689
|
+
|
|
690
|
+
def get_args_schema(self) -> Type[EditFileArgs]:
|
|
691
|
+
return EditFileArgs
|
|
692
|
+
|
|
693
|
+
async def execute(self, context: ToolContext, args: EditFileArgs) -> ToolResult:
|
|
694
|
+
try:
|
|
695
|
+
original_content = await self.file_system.read_file(args.filename, context)
|
|
696
|
+
except Exception as exc:
|
|
697
|
+
error_msg = f"Error loading file '{args.filename}': {exc}"
|
|
698
|
+
return ToolResult(
|
|
699
|
+
success=False,
|
|
700
|
+
result_for_llm=error_msg,
|
|
701
|
+
ui_component=UiComponent(
|
|
702
|
+
rich_component=NotificationComponent(
|
|
703
|
+
type=ComponentType.NOTIFICATION,
|
|
704
|
+
level="error",
|
|
705
|
+
message=error_msg,
|
|
706
|
+
),
|
|
707
|
+
simple_component=SimpleTextComponent(text=error_msg),
|
|
708
|
+
),
|
|
709
|
+
error=str(exc),
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
lines = original_content.splitlines(keepends=True)
|
|
713
|
+
applied_edits: List[str] = []
|
|
714
|
+
|
|
715
|
+
# Apply edits starting from the bottom so line numbers remain valid for each operation
|
|
716
|
+
for edit in sorted(args.edits, key=lambda e: e.start_line, reverse=True):
|
|
717
|
+
start_line = edit.start_line
|
|
718
|
+
end_line = edit.end_line if edit.end_line is not None else edit.start_line
|
|
719
|
+
|
|
720
|
+
if start_line < 1:
|
|
721
|
+
return self._range_error(
|
|
722
|
+
args.filename, start_line, end_line, "start_line must be >= 1"
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
if end_line < start_line - 1:
|
|
726
|
+
return self._range_error(
|
|
727
|
+
args.filename,
|
|
728
|
+
start_line,
|
|
729
|
+
end_line,
|
|
730
|
+
"end_line must be >= start_line - 1",
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
is_insertion = end_line == start_line - 1
|
|
734
|
+
|
|
735
|
+
if not is_insertion and start_line > len(lines):
|
|
736
|
+
return self._range_error(
|
|
737
|
+
args.filename,
|
|
738
|
+
start_line,
|
|
739
|
+
end_line,
|
|
740
|
+
f"start_line {start_line} is beyond the end of the file (len={len(lines)})",
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
if is_insertion:
|
|
744
|
+
if start_line > len(lines) + 1:
|
|
745
|
+
return self._range_error(
|
|
746
|
+
args.filename,
|
|
747
|
+
start_line,
|
|
748
|
+
end_line,
|
|
749
|
+
"Cannot insert beyond one line past the end of the file",
|
|
750
|
+
)
|
|
751
|
+
start_index = min(start_line - 1, len(lines))
|
|
752
|
+
end_index = start_index
|
|
753
|
+
else:
|
|
754
|
+
if end_line > len(lines):
|
|
755
|
+
return self._range_error(
|
|
756
|
+
args.filename,
|
|
757
|
+
start_line,
|
|
758
|
+
end_line,
|
|
759
|
+
f"end_line {end_line} is beyond the end of the file (len={len(lines)})",
|
|
760
|
+
)
|
|
761
|
+
start_index = start_line - 1
|
|
762
|
+
end_index = end_line
|
|
763
|
+
|
|
764
|
+
replacement_lines = edit.new_content.splitlines(keepends=True)
|
|
765
|
+
lines[start_index:end_index] = replacement_lines
|
|
766
|
+
|
|
767
|
+
if is_insertion:
|
|
768
|
+
inserted_count = len(replacement_lines)
|
|
769
|
+
applied_edits.append(
|
|
770
|
+
f"Inserted {inserted_count} line(s) at line {start_line}"
|
|
771
|
+
)
|
|
772
|
+
else:
|
|
773
|
+
removed_count = end_line - start_line + 1
|
|
774
|
+
applied_edits.append(
|
|
775
|
+
f"Replaced lines {start_line}-{end_line} (removed {removed_count} line(s))"
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
new_content = "".join(lines)
|
|
779
|
+
|
|
780
|
+
if new_content == original_content:
|
|
781
|
+
message = (
|
|
782
|
+
f"No changes applied to '{args.filename}' (content already up to date)."
|
|
783
|
+
)
|
|
784
|
+
return ToolResult(
|
|
785
|
+
success=True,
|
|
786
|
+
result_for_llm=message,
|
|
787
|
+
ui_component=UiComponent(
|
|
788
|
+
rich_component=NotificationComponent(
|
|
789
|
+
type=ComponentType.NOTIFICATION,
|
|
790
|
+
level="info",
|
|
791
|
+
message=message,
|
|
792
|
+
),
|
|
793
|
+
simple_component=SimpleTextComponent(text=message),
|
|
794
|
+
),
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
try:
|
|
798
|
+
await self.file_system.write_file(
|
|
799
|
+
args.filename, new_content, context, overwrite=True
|
|
800
|
+
)
|
|
801
|
+
except Exception as exc:
|
|
802
|
+
error_msg = f"Error writing updated contents to '{args.filename}': {exc}"
|
|
803
|
+
return ToolResult(
|
|
804
|
+
success=False,
|
|
805
|
+
result_for_llm=error_msg,
|
|
806
|
+
ui_component=UiComponent(
|
|
807
|
+
rich_component=NotificationComponent(
|
|
808
|
+
type=ComponentType.NOTIFICATION,
|
|
809
|
+
level="error",
|
|
810
|
+
message=error_msg,
|
|
811
|
+
),
|
|
812
|
+
simple_component=SimpleTextComponent(text=error_msg),
|
|
813
|
+
),
|
|
814
|
+
error=str(exc),
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
diff_lines = list(
|
|
818
|
+
difflib.unified_diff(
|
|
819
|
+
original_content.splitlines(),
|
|
820
|
+
new_content.splitlines(),
|
|
821
|
+
fromfile=f"a/{args.filename}",
|
|
822
|
+
tofile=f"b/{args.filename}",
|
|
823
|
+
lineterm="",
|
|
824
|
+
)
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
diff_text = (
|
|
828
|
+
"\n".join(diff_lines) if diff_lines else "(No textual diff available)"
|
|
829
|
+
)
|
|
830
|
+
summary = (
|
|
831
|
+
f"Updated '{args.filename}' with {len(args.edits)} edit(s).\n"
|
|
832
|
+
+ "\n".join(reversed(applied_edits))
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
return ToolResult(
|
|
836
|
+
success=True,
|
|
837
|
+
result_for_llm=f"{summary}\n\n{diff_text}",
|
|
838
|
+
ui_component=UiComponent(
|
|
839
|
+
rich_component=CardComponent(
|
|
840
|
+
type=ComponentType.CARD,
|
|
841
|
+
title=f"Edited {args.filename}",
|
|
842
|
+
content=diff_text,
|
|
843
|
+
),
|
|
844
|
+
simple_component=SimpleTextComponent(text=summary),
|
|
845
|
+
),
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
def _range_error(
|
|
849
|
+
self, filename: str, start_line: int, end_line: int, message: str
|
|
850
|
+
) -> ToolResult:
|
|
851
|
+
error_msg = f"Invalid edit range for '{filename}': start_line={start_line}, end_line={end_line}. {message}"
|
|
852
|
+
return ToolResult(
|
|
853
|
+
success=False,
|
|
854
|
+
result_for_llm=error_msg,
|
|
855
|
+
ui_component=UiComponent(
|
|
856
|
+
rich_component=NotificationComponent(
|
|
857
|
+
type=ComponentType.NOTIFICATION,
|
|
858
|
+
level="error",
|
|
859
|
+
message=error_msg,
|
|
860
|
+
),
|
|
861
|
+
simple_component=SimpleTextComponent(text=error_msg),
|
|
862
|
+
),
|
|
863
|
+
error=message,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# Convenience function for creating tools with default local file system
|
|
868
|
+
def create_file_system_tools(
|
|
869
|
+
file_system: Optional[FileSystem] = None,
|
|
870
|
+
) -> List[Tool[Any]]:
|
|
871
|
+
"""Create a set of file system tools with optional dependency injection."""
|
|
872
|
+
fs = file_system or LocalFileSystem()
|
|
873
|
+
return [
|
|
874
|
+
ListFilesTool(fs),
|
|
875
|
+
SearchFilesTool(fs),
|
|
876
|
+
ReadFileTool(fs),
|
|
877
|
+
WriteFileTool(fs),
|
|
878
|
+
EditFileTool(fs),
|
|
879
|
+
]
|