shotgun-sh 0.2.29.dev2__py3-none-any.whl → 0.6.1.dev1__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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/agent_manager.py +497 -30
- shotgun/agents/cancellation.py +103 -0
- shotgun/agents/common.py +90 -77
- shotgun/agents/config/README.md +0 -1
- shotgun/agents/config/manager.py +52 -8
- shotgun/agents/config/models.py +48 -45
- shotgun/agents/config/provider.py +44 -29
- shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
- shotgun/agents/conversation/history/token_counting/base.py +51 -9
- shotgun/agents/export.py +12 -13
- shotgun/agents/file_read.py +176 -0
- shotgun/agents/messages.py +15 -3
- shotgun/agents/models.py +90 -2
- shotgun/agents/plan.py +12 -13
- shotgun/agents/research.py +13 -10
- shotgun/agents/router/__init__.py +47 -0
- shotgun/agents/router/models.py +384 -0
- shotgun/agents/router/router.py +185 -0
- shotgun/agents/router/tools/__init__.py +18 -0
- shotgun/agents/router/tools/delegation_tools.py +557 -0
- shotgun/agents/router/tools/plan_tools.py +403 -0
- shotgun/agents/runner.py +17 -2
- shotgun/agents/specify.py +12 -13
- shotgun/agents/tasks.py +12 -13
- shotgun/agents/tools/__init__.py +8 -0
- shotgun/agents/tools/codebase/directory_lister.py +27 -39
- shotgun/agents/tools/codebase/file_read.py +26 -35
- shotgun/agents/tools/codebase/query_graph.py +9 -0
- shotgun/agents/tools/codebase/retrieve_code.py +9 -0
- shotgun/agents/tools/file_management.py +81 -3
- shotgun/agents/tools/file_read_tools/__init__.py +7 -0
- shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
- shotgun/agents/tools/markdown_tools/__init__.py +62 -0
- shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
- shotgun/agents/tools/markdown_tools/models.py +86 -0
- shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
- shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
- shotgun/agents/tools/markdown_tools/utils.py +453 -0
- shotgun/agents/tools/registry.py +41 -0
- shotgun/agents/tools/web_search/__init__.py +1 -2
- shotgun/agents/tools/web_search/gemini.py +1 -3
- shotgun/agents/tools/web_search/openai.py +42 -23
- shotgun/attachments/__init__.py +41 -0
- shotgun/attachments/errors.py +60 -0
- shotgun/attachments/models.py +107 -0
- shotgun/attachments/parser.py +257 -0
- shotgun/attachments/processor.py +193 -0
- shotgun/cli/clear.py +2 -2
- shotgun/cli/codebase/commands.py +181 -65
- shotgun/cli/compact.py +2 -2
- shotgun/cli/context.py +2 -2
- shotgun/cli/run.py +90 -0
- shotgun/cli/spec/backup.py +2 -1
- shotgun/cli/spec/commands.py +2 -0
- shotgun/cli/spec/models.py +18 -0
- shotgun/cli/spec/pull_service.py +122 -68
- shotgun/codebase/__init__.py +2 -0
- shotgun/codebase/benchmarks/__init__.py +35 -0
- shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
- shotgun/codebase/benchmarks/exporters.py +119 -0
- shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
- shotgun/codebase/benchmarks/formatters/base.py +34 -0
- shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
- shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
- shotgun/codebase/benchmarks/models.py +129 -0
- shotgun/codebase/core/__init__.py +4 -0
- shotgun/codebase/core/call_resolution.py +91 -0
- shotgun/codebase/core/change_detector.py +11 -6
- shotgun/codebase/core/errors.py +159 -0
- shotgun/codebase/core/extractors/__init__.py +23 -0
- shotgun/codebase/core/extractors/base.py +138 -0
- shotgun/codebase/core/extractors/factory.py +63 -0
- shotgun/codebase/core/extractors/go/__init__.py +7 -0
- shotgun/codebase/core/extractors/go/extractor.py +122 -0
- shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
- shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
- shotgun/codebase/core/extractors/protocol.py +109 -0
- shotgun/codebase/core/extractors/python/__init__.py +7 -0
- shotgun/codebase/core/extractors/python/extractor.py +141 -0
- shotgun/codebase/core/extractors/rust/__init__.py +7 -0
- shotgun/codebase/core/extractors/rust/extractor.py +139 -0
- shotgun/codebase/core/extractors/types.py +15 -0
- shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
- shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
- shotgun/codebase/core/gitignore.py +252 -0
- shotgun/codebase/core/ingestor.py +644 -354
- shotgun/codebase/core/kuzu_compat.py +119 -0
- shotgun/codebase/core/language_config.py +239 -0
- shotgun/codebase/core/manager.py +256 -46
- shotgun/codebase/core/metrics_collector.py +310 -0
- shotgun/codebase/core/metrics_types.py +347 -0
- shotgun/codebase/core/parallel_executor.py +424 -0
- shotgun/codebase/core/work_distributor.py +254 -0
- shotgun/codebase/core/worker.py +768 -0
- shotgun/codebase/indexing_state.py +86 -0
- shotgun/codebase/models.py +94 -0
- shotgun/codebase/service.py +13 -0
- shotgun/exceptions.py +1 -1
- shotgun/main.py +2 -10
- shotgun/prompts/agents/export.j2 +2 -0
- shotgun/prompts/agents/file_read.j2 +48 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +20 -28
- shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
- shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
- shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
- shotgun/prompts/agents/plan.j2 +43 -1
- shotgun/prompts/agents/research.j2 +75 -20
- shotgun/prompts/agents/router.j2 +713 -0
- shotgun/prompts/agents/specify.j2 +94 -4
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
- shotgun/prompts/agents/state/system_state.j2 +24 -15
- shotgun/prompts/agents/tasks.j2 +77 -23
- shotgun/settings.py +44 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
- shotgun/tui/app.py +90 -23
- shotgun/tui/commands/__init__.py +9 -1
- shotgun/tui/components/attachment_bar.py +87 -0
- shotgun/tui/components/mode_indicator.py +120 -25
- shotgun/tui/components/prompt_input.py +23 -28
- shotgun/tui/components/status_bar.py +5 -4
- shotgun/tui/dependencies.py +58 -8
- shotgun/tui/protocols.py +37 -0
- shotgun/tui/screens/chat/chat.tcss +24 -1
- shotgun/tui/screens/chat/chat_screen.py +1374 -211
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
- shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
- shotgun/tui/screens/chat_screen/command_providers.py +0 -97
- shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
- shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
- shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
- shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
- shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
- shotgun/tui/screens/chat_screen/messages.py +219 -0
- shotgun/tui/screens/database_locked_dialog.py +219 -0
- shotgun/tui/screens/database_timeout_dialog.py +158 -0
- shotgun/tui/screens/kuzu_error_dialog.py +135 -0
- shotgun/tui/screens/model_picker.py +14 -9
- shotgun/tui/screens/models.py +11 -0
- shotgun/tui/screens/shotgun_auth.py +50 -0
- shotgun/tui/screens/spec_pull.py +2 -0
- shotgun/tui/state/processing_state.py +19 -0
- shotgun/tui/utils/mode_progress.py +20 -86
- shotgun/tui/widgets/__init__.py +2 -1
- shotgun/tui/widgets/approval_widget.py +152 -0
- shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
- shotgun/tui/widgets/plan_panel.py +129 -0
- shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
- shotgun/tui/widgets/widget_coordinator.py +18 -0
- shotgun/utils/file_system_utils.py +4 -1
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
- shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
- shotgun/cli/export.py +0 -81
- shotgun/cli/plan.py +0 -73
- shotgun/cli/research.py +0 -93
- shotgun/cli/specify.py +0 -70
- shotgun/cli/tasks.py +0 -78
- shotgun/tui/screens/onboarding.py +0 -580
- shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/licenses/LICENSE +0 -0
|
@@ -19,60 +19,48 @@ logger = get_logger(__name__)
|
|
|
19
19
|
key_arg="directory",
|
|
20
20
|
)
|
|
21
21
|
async def directory_lister(
|
|
22
|
-
ctx: RunContext[AgentDeps],
|
|
22
|
+
ctx: RunContext[AgentDeps], directory: str = ".", graph_id: str = ""
|
|
23
23
|
) -> DirectoryListResult:
|
|
24
|
-
"""List directory contents in codebase.
|
|
24
|
+
"""List directory contents in codebase or current working directory.
|
|
25
25
|
|
|
26
26
|
Args:
|
|
27
27
|
ctx: RunContext containing AgentDeps with codebase service
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
directory: Path to directory relative to repository root or CWD (default: ".")
|
|
29
|
+
graph_id: Graph ID to identify the repository (optional - uses CWD if not provided)
|
|
30
30
|
|
|
31
31
|
Returns:
|
|
32
32
|
DirectoryListResult with formatted output via __str__
|
|
33
33
|
"""
|
|
34
|
-
logger.debug("🔧 Listing directory: %s in graph %s", directory, graph_id)
|
|
34
|
+
logger.debug("🔧 Listing directory: %s in graph %s", directory, graph_id or "(CWD)")
|
|
35
35
|
|
|
36
36
|
try:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
if not graph:
|
|
59
|
-
return DirectoryListResult(
|
|
60
|
-
success=False,
|
|
61
|
-
directory=directory,
|
|
62
|
-
full_path="",
|
|
63
|
-
error=f"Graph '{graph_id}' not found",
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
# Validate the directory path is within the repository
|
|
67
|
-
repo_path = Path(graph.repo_path).resolve()
|
|
37
|
+
# Determine the root path - either from indexed codebase or CWD
|
|
38
|
+
repo_path: Path | None = None
|
|
39
|
+
|
|
40
|
+
if graph_id and ctx.deps.codebase_service:
|
|
41
|
+
# Try to get the graph to find the repository path
|
|
42
|
+
try:
|
|
43
|
+
graphs = await ctx.deps.codebase_service.list_graphs()
|
|
44
|
+
graph = next((g for g in graphs if g.graph_id == graph_id), None)
|
|
45
|
+
if graph:
|
|
46
|
+
repo_path = Path(graph.repo_path).resolve()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.debug("Could not find graph '%s': %s", graph_id, e)
|
|
49
|
+
|
|
50
|
+
# Fall back to CWD if no graph found or no graph_id provided
|
|
51
|
+
if repo_path is None:
|
|
52
|
+
repo_path = Path.cwd().resolve()
|
|
53
|
+
logger.debug("📂 Using CWD as root: %s", repo_path)
|
|
54
|
+
|
|
55
|
+
# Validate the directory path is within the root
|
|
68
56
|
full_dir_path = (repo_path / directory).resolve()
|
|
69
57
|
|
|
70
|
-
# Security check: ensure the resolved path is within the
|
|
58
|
+
# Security check: ensure the resolved path is within the root directory
|
|
71
59
|
try:
|
|
72
60
|
full_dir_path.relative_to(repo_path)
|
|
73
61
|
except ValueError:
|
|
74
62
|
error_msg = (
|
|
75
|
-
f"Access denied: Path '{directory}' is outside
|
|
63
|
+
f"Access denied: Path '{directory}' is outside allowed directory bounds"
|
|
76
64
|
)
|
|
77
65
|
logger.warning("🚨 Security violation attempt: %s", error_msg)
|
|
78
66
|
return DirectoryListResult(
|
|
@@ -88,7 +76,7 @@ async def directory_lister(
|
|
|
88
76
|
success=False,
|
|
89
77
|
directory=directory,
|
|
90
78
|
full_path=str(full_dir_path),
|
|
91
|
-
error=f"Directory
|
|
79
|
+
error=f"Directory not found: {directory}",
|
|
92
80
|
)
|
|
93
81
|
|
|
94
82
|
if not full_dir_path.is_dir():
|
|
@@ -21,57 +21,48 @@ logger = get_logger(__name__)
|
|
|
21
21
|
key_arg="file_path",
|
|
22
22
|
)
|
|
23
23
|
async def file_read(
|
|
24
|
-
ctx: RunContext[AgentDeps],
|
|
24
|
+
ctx: RunContext[AgentDeps], file_path: str, graph_id: str = ""
|
|
25
25
|
) -> FileReadResult:
|
|
26
|
-
"""Read file contents from codebase.
|
|
26
|
+
"""Read file contents from codebase or current working directory.
|
|
27
27
|
|
|
28
28
|
Args:
|
|
29
29
|
ctx: RunContext containing AgentDeps with codebase service
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
file_path: Path to file relative to repository root or CWD
|
|
31
|
+
graph_id: Graph ID to identify the repository (optional - uses CWD if not provided)
|
|
32
32
|
|
|
33
33
|
Returns:
|
|
34
34
|
FileReadResult with formatted output via __str__
|
|
35
35
|
"""
|
|
36
|
-
logger.debug("🔧 Reading file: %s in graph %s", file_path, graph_id)
|
|
36
|
+
logger.debug("🔧 Reading file: %s in graph %s", file_path, graph_id or "(CWD)")
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
success=False,
|
|
42
|
-
file_path=file_path,
|
|
43
|
-
error="No codebase indexed",
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
# Get the graph to find the repository path
|
|
47
|
-
try:
|
|
48
|
-
graphs = await ctx.deps.codebase_service.list_graphs()
|
|
49
|
-
graph = next((g for g in graphs if g.graph_id == graph_id), None)
|
|
50
|
-
except Exception as e:
|
|
51
|
-
logger.error("Error getting graph: %s", e)
|
|
52
|
-
return FileReadResult(
|
|
53
|
-
success=False,
|
|
54
|
-
file_path=file_path,
|
|
55
|
-
error=f"Could not find graph with ID '{graph_id}'",
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
if not graph:
|
|
59
|
-
return FileReadResult(
|
|
60
|
-
success=False,
|
|
61
|
-
file_path=file_path,
|
|
62
|
-
error=f"Graph '{graph_id}' not found",
|
|
63
|
-
)
|
|
39
|
+
# Determine the root path - either from indexed codebase or CWD
|
|
40
|
+
repo_path: Path | None = None
|
|
64
41
|
|
|
65
|
-
|
|
66
|
-
|
|
42
|
+
if graph_id and ctx.deps.codebase_service:
|
|
43
|
+
# Try to get the graph to find the repository path
|
|
44
|
+
try:
|
|
45
|
+
graphs = await ctx.deps.codebase_service.list_graphs()
|
|
46
|
+
graph = next((g for g in graphs if g.graph_id == graph_id), None)
|
|
47
|
+
if graph:
|
|
48
|
+
repo_path = Path(graph.repo_path).resolve()
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.debug("Could not find graph '%s': %s", graph_id, e)
|
|
51
|
+
|
|
52
|
+
# Fall back to CWD if no graph found or no graph_id provided
|
|
53
|
+
if repo_path is None:
|
|
54
|
+
repo_path = Path.cwd().resolve()
|
|
55
|
+
logger.debug("📂 Using CWD as root: %s", repo_path)
|
|
56
|
+
|
|
57
|
+
# Validate the file path is within the root
|
|
67
58
|
full_file_path = (repo_path / file_path).resolve()
|
|
68
59
|
|
|
69
|
-
# Security check: ensure the resolved path is within the
|
|
60
|
+
# Security check: ensure the resolved path is within the root directory
|
|
70
61
|
try:
|
|
71
62
|
full_file_path.relative_to(repo_path)
|
|
72
63
|
except ValueError:
|
|
73
64
|
error_msg = (
|
|
74
|
-
f"Access denied: Path '{file_path}' is outside
|
|
65
|
+
f"Access denied: Path '{file_path}' is outside allowed directory bounds"
|
|
75
66
|
)
|
|
76
67
|
logger.warning("🚨 Security violation attempt: %s", error_msg)
|
|
77
68
|
return FileReadResult(success=False, file_path=file_path, error=error_msg)
|
|
@@ -81,7 +72,7 @@ async def file_read(
|
|
|
81
72
|
return FileReadResult(
|
|
82
73
|
success=False,
|
|
83
74
|
file_path=file_path,
|
|
84
|
-
error=f"File
|
|
75
|
+
error=f"File not found: {file_path}",
|
|
85
76
|
)
|
|
86
77
|
|
|
87
78
|
if full_file_path.is_dir():
|
|
@@ -4,6 +4,7 @@ from pydantic_ai import RunContext
|
|
|
4
4
|
|
|
5
5
|
from shotgun.agents.models import AgentDeps
|
|
6
6
|
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
7
|
+
from shotgun.codebase.indexing_state import IndexingState
|
|
7
8
|
from shotgun.codebase.models import QueryType
|
|
8
9
|
from shotgun.logging_config import get_logger
|
|
9
10
|
|
|
@@ -40,6 +41,14 @@ async def query_graph(
|
|
|
40
41
|
error="No codebase indexed",
|
|
41
42
|
)
|
|
42
43
|
|
|
44
|
+
# Check if graph is currently being indexed
|
|
45
|
+
if ctx.deps.codebase_service.indexing.is_active(graph_id):
|
|
46
|
+
return QueryGraphResult(
|
|
47
|
+
success=False,
|
|
48
|
+
query=query,
|
|
49
|
+
error=IndexingState.INDEXING_IN_PROGRESS_ERROR,
|
|
50
|
+
)
|
|
51
|
+
|
|
43
52
|
# Execute natural language query
|
|
44
53
|
result = await ctx.deps.codebase_service.execute_query(
|
|
45
54
|
graph_id=graph_id,
|
|
@@ -8,6 +8,7 @@ from shotgun.agents.models import AgentDeps
|
|
|
8
8
|
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
9
9
|
from shotgun.codebase.core.code_retrieval import retrieve_code_by_qualified_name
|
|
10
10
|
from shotgun.codebase.core.language_config import get_language_config
|
|
11
|
+
from shotgun.codebase.indexing_state import IndexingState
|
|
11
12
|
from shotgun.logging_config import get_logger
|
|
12
13
|
|
|
13
14
|
from .models import CodeSnippetResult
|
|
@@ -43,6 +44,14 @@ async def retrieve_code(
|
|
|
43
44
|
error="No codebase indexed",
|
|
44
45
|
)
|
|
45
46
|
|
|
47
|
+
# Check if graph is currently being indexed
|
|
48
|
+
if ctx.deps.codebase_service.indexing.is_active(graph_id):
|
|
49
|
+
return CodeSnippetResult(
|
|
50
|
+
found=False,
|
|
51
|
+
qualified_name=qualified_name,
|
|
52
|
+
error=IndexingState.INDEXING_IN_PROGRESS_ERROR,
|
|
53
|
+
)
|
|
54
|
+
|
|
46
55
|
# Use the existing code retrieval functionality
|
|
47
56
|
code_snippet = await retrieve_code_by_qualified_name(
|
|
48
57
|
manager=ctx.deps.codebase_service.manager,
|
|
@@ -23,7 +23,10 @@ logger = get_logger(__name__)
|
|
|
23
23
|
# - A list of Paths: multiple allowed files/directories (e.g., [Path("specification.md"), Path("contracts")])
|
|
24
24
|
# - "*": any file except protected files (for export agent)
|
|
25
25
|
AGENT_DIRECTORIES: dict[AgentType, str | Path | list[Path]] = {
|
|
26
|
-
AgentType.RESEARCH:
|
|
26
|
+
AgentType.RESEARCH: [
|
|
27
|
+
Path("research.md"),
|
|
28
|
+
Path("research"),
|
|
29
|
+
], # Research can write main file and research folder
|
|
27
30
|
AgentType.SPECIFY: [
|
|
28
31
|
Path("specification.md"),
|
|
29
32
|
Path("contracts"),
|
|
@@ -160,19 +163,33 @@ def _validate_shotgun_path(filename: str) -> Path:
|
|
|
160
163
|
return full_path
|
|
161
164
|
|
|
162
165
|
|
|
166
|
+
# Binary file extensions that should be loaded via file_requests
|
|
167
|
+
BINARY_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
|
168
|
+
|
|
169
|
+
|
|
163
170
|
@register_tool(
|
|
164
171
|
category=ToolCategory.ARTIFACT_MANAGEMENT,
|
|
165
172
|
display_text="Reading file",
|
|
166
173
|
key_arg="filename",
|
|
167
174
|
)
|
|
168
175
|
async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
|
|
169
|
-
"""Read a file from the .shotgun directory.
|
|
176
|
+
"""Read a TEXT file from the .shotgun directory.
|
|
177
|
+
|
|
178
|
+
IMPORTANT: This tool is for TEXT files only (.md, .txt, .json, etc.).
|
|
179
|
+
|
|
180
|
+
For BINARY files (PDFs, images), DO NOT use this tool. Instead:
|
|
181
|
+
- Use file_requests in your response to load binary files
|
|
182
|
+
- Example: {"response": "Let me check that.", "file_requests": ["/path/to/file.pdf"]}
|
|
183
|
+
|
|
184
|
+
Binary file extensions that require file_requests instead:
|
|
185
|
+
- .pdf, .png, .jpg, .jpeg, .gif, .webp
|
|
170
186
|
|
|
171
187
|
Args:
|
|
172
188
|
filename: Relative path to file within .shotgun directory
|
|
173
189
|
|
|
174
190
|
Returns:
|
|
175
|
-
File contents as string
|
|
191
|
+
File contents as string. For binary files, returns instructions
|
|
192
|
+
with the absolute path to use in file_requests.
|
|
176
193
|
|
|
177
194
|
Raises:
|
|
178
195
|
ValueError: If path is outside .shotgun directory
|
|
@@ -186,6 +203,22 @@ async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
|
|
|
186
203
|
if not await aiofiles.os.path.exists(file_path):
|
|
187
204
|
raise FileNotFoundError(f"File not found: {filename}")
|
|
188
205
|
|
|
206
|
+
# Check if it's a binary file type (PDF, image)
|
|
207
|
+
suffix = file_path.suffix.lower()
|
|
208
|
+
if suffix in BINARY_EXTENSIONS:
|
|
209
|
+
# Return info for the agent to use file_requests
|
|
210
|
+
logger.debug(
|
|
211
|
+
"📎 Binary file detected (%s), returning path for file_requests: %s",
|
|
212
|
+
suffix,
|
|
213
|
+
file_path,
|
|
214
|
+
)
|
|
215
|
+
return (
|
|
216
|
+
f"This is a binary file ({suffix}) that cannot be read as text. "
|
|
217
|
+
f"To view its contents, include the absolute path in your "
|
|
218
|
+
f"`file_requests` response field:\n\n"
|
|
219
|
+
f"Absolute path: {file_path}"
|
|
220
|
+
)
|
|
221
|
+
|
|
189
222
|
async with aiofiles.open(file_path, encoding="utf-8") as f:
|
|
190
223
|
content = await f.read()
|
|
191
224
|
logger.debug("📄 Read %d characters from %s", len(content), filename)
|
|
@@ -282,3 +315,48 @@ async def append_file(ctx: RunContext[AgentDeps], filename: str, content: str) -
|
|
|
282
315
|
Success message or error message
|
|
283
316
|
"""
|
|
284
317
|
return await write_file(ctx, filename, content, mode="a")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@register_tool(
|
|
321
|
+
category=ToolCategory.ARTIFACT_MANAGEMENT,
|
|
322
|
+
display_text="Deleting file",
|
|
323
|
+
key_arg="filename",
|
|
324
|
+
)
|
|
325
|
+
async def delete_file(ctx: RunContext[AgentDeps], filename: str) -> str:
|
|
326
|
+
"""Delete a file from the .shotgun directory.
|
|
327
|
+
|
|
328
|
+
Uses the same permission model as write_file - agents can only delete
|
|
329
|
+
files they have permission to write to.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
filename: Relative path to file within .shotgun directory
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Success message or error message
|
|
336
|
+
|
|
337
|
+
Raises:
|
|
338
|
+
ValueError: If path is outside .shotgun directory or agent lacks permission
|
|
339
|
+
FileNotFoundError: If file does not exist
|
|
340
|
+
"""
|
|
341
|
+
logger.debug("🔧 Deleting file: %s", filename)
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
# Use agent-scoped validation (same as write_file)
|
|
345
|
+
file_path = _validate_agent_scoped_path(filename, ctx.deps.agent_mode)
|
|
346
|
+
|
|
347
|
+
if not await aiofiles.os.path.exists(file_path):
|
|
348
|
+
raise FileNotFoundError(f"File not found: {filename}")
|
|
349
|
+
|
|
350
|
+
# Delete the file
|
|
351
|
+
await aiofiles.os.remove(file_path)
|
|
352
|
+
logger.debug("🗑️ Deleted file: %s", filename)
|
|
353
|
+
|
|
354
|
+
# Track the file operation
|
|
355
|
+
ctx.deps.file_tracker.add_operation(file_path, FileOperationType.DELETED)
|
|
356
|
+
|
|
357
|
+
return f"Successfully deleted {filename}"
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
error_msg = f"Error deleting file '{filename}': {str(e)}"
|
|
361
|
+
logger.error("❌ File delete failed: %s", error_msg)
|
|
362
|
+
return error_msg
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Multimodal file reading tool that verifies files exist and returns paths.
|
|
2
|
+
|
|
3
|
+
This tool verifies PDFs/images exist and returns their paths for the agent
|
|
4
|
+
to include in `files_found`. The Router then loads these via `file_requests`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
from pydantic_ai import RunContext, ToolReturn
|
|
11
|
+
|
|
12
|
+
from shotgun.agents.models import AgentDeps
|
|
13
|
+
from shotgun.agents.tools.registry import ToolCategory, register_tool
|
|
14
|
+
from shotgun.logging_config import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# MIME type mapping for supported file types
|
|
19
|
+
MIME_TYPES: dict[str, str] = {
|
|
20
|
+
".pdf": "application/pdf",
|
|
21
|
+
".png": "image/png",
|
|
22
|
+
".jpg": "image/jpeg",
|
|
23
|
+
".jpeg": "image/jpeg",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Maximum file size for multimodal reading (32MB - Anthropic's limit)
|
|
29
|
+
MAX_FILE_SIZE_BYTES = 32 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MultimodalFileReadResult(BaseModel):
|
|
33
|
+
"""Result from multimodal file read."""
|
|
34
|
+
|
|
35
|
+
success: bool = Field(description="Whether the file was successfully found")
|
|
36
|
+
file_path: str = Field(description="The absolute path to the file")
|
|
37
|
+
file_name: str = Field(default="", description="The file name")
|
|
38
|
+
file_size_bytes: int = Field(default=0, description="File size in bytes")
|
|
39
|
+
mime_type: str = Field(default="", description="MIME type of the file")
|
|
40
|
+
error: str | None = Field(default=None, description="Error message if failed")
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
if not self.success:
|
|
44
|
+
return f"Error: {self.error}"
|
|
45
|
+
return (
|
|
46
|
+
f"Found: {self.file_name} ({self.file_size_bytes} bytes, {self.mime_type})"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_mime_type(file_path: Path) -> str | None:
|
|
51
|
+
"""Get MIME type for a file based on extension."""
|
|
52
|
+
suffix = file_path.suffix.lower()
|
|
53
|
+
return MIME_TYPES.get(suffix)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _format_file_size(size_bytes: int) -> str:
|
|
57
|
+
"""Format file size in human-readable format."""
|
|
58
|
+
if size_bytes < 1024:
|
|
59
|
+
return f"{size_bytes} B"
|
|
60
|
+
elif size_bytes < 1024 * 1024:
|
|
61
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
62
|
+
else:
|
|
63
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@register_tool(
|
|
67
|
+
category=ToolCategory.CODEBASE_UNDERSTANDING,
|
|
68
|
+
display_text="Reading file (multimodal)",
|
|
69
|
+
key_arg="file_path",
|
|
70
|
+
)
|
|
71
|
+
async def multimodal_file_read(
|
|
72
|
+
ctx: RunContext[AgentDeps],
|
|
73
|
+
file_path: str,
|
|
74
|
+
) -> ToolReturn:
|
|
75
|
+
"""Verify a PDF or image file exists and return its path.
|
|
76
|
+
|
|
77
|
+
This tool checks that the file exists and is a supported type (PDF, image),
|
|
78
|
+
then returns the absolute path. Include this path in your `files_found`
|
|
79
|
+
response so the Router can load it for visual analysis.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
ctx: RunContext containing AgentDeps
|
|
83
|
+
file_path: Path to the file (absolute or relative to CWD)
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
ToolReturn with file info and absolute path
|
|
87
|
+
"""
|
|
88
|
+
logger.debug("Checking multimodal file: %s", file_path)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Resolve the path
|
|
92
|
+
path = Path(file_path).expanduser().resolve()
|
|
93
|
+
|
|
94
|
+
# Check if file exists
|
|
95
|
+
if not path.exists():
|
|
96
|
+
error_result = MultimodalFileReadResult(
|
|
97
|
+
success=False,
|
|
98
|
+
file_path=str(path),
|
|
99
|
+
error=f"File not found: {file_path}",
|
|
100
|
+
)
|
|
101
|
+
return ToolReturn(return_value=str(error_result))
|
|
102
|
+
|
|
103
|
+
if path.is_dir():
|
|
104
|
+
error_result = MultimodalFileReadResult(
|
|
105
|
+
success=False,
|
|
106
|
+
file_path=str(path),
|
|
107
|
+
error=f"'{file_path}' is a directory, not a file",
|
|
108
|
+
)
|
|
109
|
+
return ToolReturn(return_value=str(error_result))
|
|
110
|
+
|
|
111
|
+
# Check MIME type
|
|
112
|
+
mime_type = _get_mime_type(path)
|
|
113
|
+
if mime_type is None:
|
|
114
|
+
supported = ", ".join(MIME_TYPES.keys())
|
|
115
|
+
error_result = MultimodalFileReadResult(
|
|
116
|
+
success=False,
|
|
117
|
+
file_path=str(path),
|
|
118
|
+
error=f"Unsupported file type: {path.suffix}. Supported: {supported}",
|
|
119
|
+
)
|
|
120
|
+
return ToolReturn(return_value=str(error_result))
|
|
121
|
+
|
|
122
|
+
# Check file size
|
|
123
|
+
file_size = path.stat().st_size
|
|
124
|
+
if file_size > MAX_FILE_SIZE_BYTES:
|
|
125
|
+
error_result = MultimodalFileReadResult(
|
|
126
|
+
success=False,
|
|
127
|
+
file_path=str(path),
|
|
128
|
+
file_size_bytes=file_size,
|
|
129
|
+
error=f"File too large: {_format_file_size(file_size)} (max: {_format_file_size(MAX_FILE_SIZE_BYTES)})",
|
|
130
|
+
)
|
|
131
|
+
return ToolReturn(return_value=str(error_result))
|
|
132
|
+
|
|
133
|
+
logger.debug(
|
|
134
|
+
"Found multimodal file: %s (%s, %s)",
|
|
135
|
+
path.name,
|
|
136
|
+
_format_file_size(file_size),
|
|
137
|
+
mime_type,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Return file info with absolute path
|
|
141
|
+
file_type = "PDF" if mime_type == "application/pdf" else "Image"
|
|
142
|
+
summary = f"""{file_type} found: {path.name}
|
|
143
|
+
Size: {_format_file_size(file_size)}
|
|
144
|
+
Type: {mime_type}
|
|
145
|
+
Absolute path: {path}
|
|
146
|
+
|
|
147
|
+
IMPORTANT: Include the absolute path above in your `files_found` response field.
|
|
148
|
+
The Router will then be able to load and analyze this file's visual content."""
|
|
149
|
+
|
|
150
|
+
return ToolReturn(return_value=summary)
|
|
151
|
+
|
|
152
|
+
except PermissionError:
|
|
153
|
+
error_result = MultimodalFileReadResult(
|
|
154
|
+
success=False,
|
|
155
|
+
file_path=file_path,
|
|
156
|
+
error=f"Permission denied: {file_path}",
|
|
157
|
+
)
|
|
158
|
+
return ToolReturn(return_value=str(error_result))
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.error("Error checking multimodal file: %s", str(e))
|
|
162
|
+
error_result = MultimodalFileReadResult(
|
|
163
|
+
success=False,
|
|
164
|
+
file_path=file_path,
|
|
165
|
+
error=f"Error: {str(e)}",
|
|
166
|
+
)
|
|
167
|
+
return ToolReturn(return_value=str(error_result))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Markdown manipulation tools for Pydantic AI agents."""
|
|
2
|
+
|
|
3
|
+
from .insert_section import insert_markdown_section
|
|
4
|
+
from .models import (
|
|
5
|
+
CloseMatch,
|
|
6
|
+
HeadingList,
|
|
7
|
+
HeadingMatch,
|
|
8
|
+
MarkdownFileContext,
|
|
9
|
+
MarkdownHeading,
|
|
10
|
+
SectionMatchResult,
|
|
11
|
+
SectionNumber,
|
|
12
|
+
)
|
|
13
|
+
from .remove_section import remove_markdown_section
|
|
14
|
+
from .replace_section import replace_markdown_section
|
|
15
|
+
from .utils import (
|
|
16
|
+
decrement_section_number,
|
|
17
|
+
detect_line_ending,
|
|
18
|
+
extract_headings,
|
|
19
|
+
find_and_validate_section,
|
|
20
|
+
find_close_matches,
|
|
21
|
+
find_matching_heading,
|
|
22
|
+
find_section_bounds,
|
|
23
|
+
get_heading_level,
|
|
24
|
+
increment_section_number,
|
|
25
|
+
load_markdown_file,
|
|
26
|
+
normalize_section_content,
|
|
27
|
+
parse_section_number,
|
|
28
|
+
renumber_headings_after,
|
|
29
|
+
split_normalized_content,
|
|
30
|
+
write_markdown_file,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
# Tools
|
|
35
|
+
"replace_markdown_section",
|
|
36
|
+
"insert_markdown_section",
|
|
37
|
+
"remove_markdown_section",
|
|
38
|
+
# Models
|
|
39
|
+
"MarkdownHeading",
|
|
40
|
+
"HeadingList",
|
|
41
|
+
"HeadingMatch",
|
|
42
|
+
"CloseMatch",
|
|
43
|
+
"SectionNumber",
|
|
44
|
+
"MarkdownFileContext",
|
|
45
|
+
"SectionMatchResult",
|
|
46
|
+
# Utilities
|
|
47
|
+
"get_heading_level",
|
|
48
|
+
"extract_headings",
|
|
49
|
+
"find_matching_heading",
|
|
50
|
+
"find_close_matches",
|
|
51
|
+
"find_section_bounds",
|
|
52
|
+
"detect_line_ending",
|
|
53
|
+
"normalize_section_content",
|
|
54
|
+
"split_normalized_content",
|
|
55
|
+
"parse_section_number",
|
|
56
|
+
"increment_section_number",
|
|
57
|
+
"decrement_section_number",
|
|
58
|
+
"renumber_headings_after",
|
|
59
|
+
"load_markdown_file",
|
|
60
|
+
"find_and_validate_section",
|
|
61
|
+
"write_markdown_file",
|
|
62
|
+
]
|