shotgun-sh 0.1.9__py3-none-any.whl → 0.2.11__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.

Files changed (150) hide show
  1. shotgun/agents/agent_manager.py +761 -52
  2. shotgun/agents/common.py +80 -75
  3. shotgun/agents/config/constants.py +21 -10
  4. shotgun/agents/config/manager.py +322 -97
  5. shotgun/agents/config/models.py +114 -84
  6. shotgun/agents/config/provider.py +232 -88
  7. shotgun/agents/context_analyzer/__init__.py +28 -0
  8. shotgun/agents/context_analyzer/analyzer.py +471 -0
  9. shotgun/agents/context_analyzer/constants.py +9 -0
  10. shotgun/agents/context_analyzer/formatter.py +115 -0
  11. shotgun/agents/context_analyzer/models.py +212 -0
  12. shotgun/agents/conversation_history.py +125 -2
  13. shotgun/agents/conversation_manager.py +57 -19
  14. shotgun/agents/export.py +6 -7
  15. shotgun/agents/history/compaction.py +23 -3
  16. shotgun/agents/history/context_extraction.py +93 -6
  17. shotgun/agents/history/history_processors.py +179 -11
  18. shotgun/agents/history/token_counting/__init__.py +31 -0
  19. shotgun/agents/history/token_counting/anthropic.py +127 -0
  20. shotgun/agents/history/token_counting/base.py +78 -0
  21. shotgun/agents/history/token_counting/openai.py +90 -0
  22. shotgun/agents/history/token_counting/sentencepiece_counter.py +127 -0
  23. shotgun/agents/history/token_counting/tokenizer_cache.py +92 -0
  24. shotgun/agents/history/token_counting/utils.py +144 -0
  25. shotgun/agents/history/token_estimation.py +12 -12
  26. shotgun/agents/llm.py +62 -0
  27. shotgun/agents/models.py +59 -4
  28. shotgun/agents/plan.py +6 -7
  29. shotgun/agents/research.py +7 -8
  30. shotgun/agents/specify.py +6 -7
  31. shotgun/agents/tasks.py +6 -7
  32. shotgun/agents/tools/__init__.py +0 -2
  33. shotgun/agents/tools/codebase/codebase_shell.py +6 -0
  34. shotgun/agents/tools/codebase/directory_lister.py +6 -0
  35. shotgun/agents/tools/codebase/file_read.py +11 -2
  36. shotgun/agents/tools/codebase/query_graph.py +6 -0
  37. shotgun/agents/tools/codebase/retrieve_code.py +6 -0
  38. shotgun/agents/tools/file_management.py +82 -16
  39. shotgun/agents/tools/registry.py +217 -0
  40. shotgun/agents/tools/web_search/__init__.py +55 -16
  41. shotgun/agents/tools/web_search/anthropic.py +76 -51
  42. shotgun/agents/tools/web_search/gemini.py +50 -27
  43. shotgun/agents/tools/web_search/openai.py +26 -17
  44. shotgun/agents/tools/web_search/utils.py +2 -2
  45. shotgun/agents/usage_manager.py +164 -0
  46. shotgun/api_endpoints.py +15 -0
  47. shotgun/cli/clear.py +53 -0
  48. shotgun/cli/codebase/commands.py +71 -2
  49. shotgun/cli/compact.py +186 -0
  50. shotgun/cli/config.py +41 -67
  51. shotgun/cli/context.py +111 -0
  52. shotgun/cli/export.py +1 -1
  53. shotgun/cli/feedback.py +50 -0
  54. shotgun/cli/models.py +3 -2
  55. shotgun/cli/plan.py +1 -1
  56. shotgun/cli/research.py +1 -1
  57. shotgun/cli/specify.py +1 -1
  58. shotgun/cli/tasks.py +1 -1
  59. shotgun/cli/update.py +18 -5
  60. shotgun/codebase/core/change_detector.py +5 -3
  61. shotgun/codebase/core/code_retrieval.py +4 -2
  62. shotgun/codebase/core/ingestor.py +169 -19
  63. shotgun/codebase/core/manager.py +177 -13
  64. shotgun/codebase/core/nl_query.py +1 -1
  65. shotgun/codebase/models.py +28 -3
  66. shotgun/codebase/service.py +14 -2
  67. shotgun/exceptions.py +32 -0
  68. shotgun/llm_proxy/__init__.py +19 -0
  69. shotgun/llm_proxy/clients.py +44 -0
  70. shotgun/llm_proxy/constants.py +15 -0
  71. shotgun/logging_config.py +18 -27
  72. shotgun/main.py +91 -4
  73. shotgun/posthog_telemetry.py +87 -40
  74. shotgun/prompts/agents/export.j2 +18 -1
  75. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +5 -1
  76. shotgun/prompts/agents/partials/interactive_mode.j2 +24 -7
  77. shotgun/prompts/agents/plan.j2 +1 -1
  78. shotgun/prompts/agents/research.j2 +1 -1
  79. shotgun/prompts/agents/specify.j2 +270 -3
  80. shotgun/prompts/agents/state/system_state.j2 +4 -0
  81. shotgun/prompts/agents/tasks.j2 +1 -1
  82. shotgun/prompts/codebase/partials/cypher_rules.j2 +13 -0
  83. shotgun/prompts/loader.py +2 -2
  84. shotgun/prompts/tools/web_search.j2 +14 -0
  85. shotgun/sdk/codebase.py +60 -2
  86. shotgun/sentry_telemetry.py +28 -21
  87. shotgun/settings.py +238 -0
  88. shotgun/shotgun_web/__init__.py +19 -0
  89. shotgun/shotgun_web/client.py +138 -0
  90. shotgun/shotgun_web/constants.py +21 -0
  91. shotgun/shotgun_web/models.py +47 -0
  92. shotgun/telemetry.py +24 -36
  93. shotgun/tui/app.py +275 -23
  94. shotgun/tui/commands/__init__.py +1 -1
  95. shotgun/tui/components/context_indicator.py +179 -0
  96. shotgun/tui/components/mode_indicator.py +70 -0
  97. shotgun/tui/components/status_bar.py +48 -0
  98. shotgun/tui/components/vertical_tail.py +6 -0
  99. shotgun/tui/containers.py +91 -0
  100. shotgun/tui/dependencies.py +39 -0
  101. shotgun/tui/filtered_codebase_service.py +46 -0
  102. shotgun/tui/protocols.py +45 -0
  103. shotgun/tui/screens/chat/__init__.py +5 -0
  104. shotgun/tui/screens/chat/chat.tcss +54 -0
  105. shotgun/tui/screens/chat/chat_screen.py +1234 -0
  106. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +64 -0
  107. shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
  108. shotgun/tui/screens/chat/help_text.py +40 -0
  109. shotgun/tui/screens/chat/prompt_history.py +48 -0
  110. shotgun/tui/screens/chat.tcss +11 -0
  111. shotgun/tui/screens/chat_screen/command_providers.py +226 -11
  112. shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
  113. shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
  114. shotgun/tui/screens/chat_screen/history/chat_history.py +116 -0
  115. shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
  116. shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
  117. shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
  118. shotgun/tui/screens/confirmation_dialog.py +151 -0
  119. shotgun/tui/screens/feedback.py +193 -0
  120. shotgun/tui/screens/github_issue.py +102 -0
  121. shotgun/tui/screens/model_picker.py +352 -0
  122. shotgun/tui/screens/onboarding.py +431 -0
  123. shotgun/tui/screens/pipx_migration.py +153 -0
  124. shotgun/tui/screens/provider_config.py +156 -39
  125. shotgun/tui/screens/shotgun_auth.py +295 -0
  126. shotgun/tui/screens/welcome.py +198 -0
  127. shotgun/tui/services/__init__.py +5 -0
  128. shotgun/tui/services/conversation_service.py +184 -0
  129. shotgun/tui/state/__init__.py +7 -0
  130. shotgun/tui/state/processing_state.py +185 -0
  131. shotgun/tui/utils/mode_progress.py +14 -7
  132. shotgun/tui/widgets/__init__.py +5 -0
  133. shotgun/tui/widgets/widget_coordinator.py +262 -0
  134. shotgun/utils/datetime_utils.py +77 -0
  135. shotgun/utils/env_utils.py +13 -0
  136. shotgun/utils/file_system_utils.py +22 -2
  137. shotgun/utils/marketing.py +110 -0
  138. shotgun/utils/source_detection.py +16 -0
  139. shotgun/utils/update_checker.py +73 -21
  140. shotgun_sh-0.2.11.dist-info/METADATA +130 -0
  141. shotgun_sh-0.2.11.dist-info/RECORD +194 -0
  142. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/entry_points.txt +1 -0
  143. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/licenses/LICENSE +1 -1
  144. shotgun/agents/history/token_counting.py +0 -429
  145. shotgun/agents/tools/user_interaction.py +0 -37
  146. shotgun/tui/screens/chat.py +0 -818
  147. shotgun/tui/screens/chat_screen/history.py +0 -222
  148. shotgun_sh-0.1.9.dist-info/METADATA +0 -466
  149. shotgun_sh-0.1.9.dist-info/RECORD +0 -131
  150. {shotgun_sh-0.1.9.dist-info → shotgun_sh-0.2.11.dist-info}/WHEEL +0 -0
shotgun/agents/plan.py CHANGED
@@ -4,7 +4,6 @@ from functools import partial
4
4
 
5
5
  from pydantic_ai import (
6
6
  Agent,
7
- DeferredToolRequests,
8
7
  )
9
8
  from pydantic_ai.agent import AgentRunResult
10
9
  from pydantic_ai.messages import ModelMessage
@@ -19,14 +18,14 @@ from .common import (
19
18
  create_usage_limits,
20
19
  run_agent,
21
20
  )
22
- from .models import AgentDeps, AgentRuntimeOptions, AgentType
21
+ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
23
22
 
24
23
  logger = get_logger(__name__)
25
24
 
26
25
 
27
- def create_plan_agent(
26
+ async def create_plan_agent(
28
27
  agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
29
- ) -> tuple[Agent[AgentDeps, str | DeferredToolRequests], AgentDeps]:
28
+ ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
30
29
  """Create a plan agent with artifact management capabilities.
31
30
 
32
31
  Args:
@@ -40,7 +39,7 @@ def create_plan_agent(
40
39
  # Use partial to create system prompt function for plan agent
41
40
  system_prompt_fn = partial(build_agent_system_prompt, "plan")
42
41
 
43
- agent, deps = create_base_agent(
42
+ agent, deps = await create_base_agent(
44
43
  system_prompt_fn,
45
44
  agent_runtime_options,
46
45
  load_codebase_understanding_tools=True,
@@ -52,11 +51,11 @@ def create_plan_agent(
52
51
 
53
52
 
54
53
  async def run_plan_agent(
55
- agent: Agent[AgentDeps, str | DeferredToolRequests],
54
+ agent: Agent[AgentDeps, AgentResponse],
56
55
  goal: str,
57
56
  deps: AgentDeps,
58
57
  message_history: list[ModelMessage] | None = None,
59
- ) -> AgentRunResult[str | DeferredToolRequests]:
58
+ ) -> AgentRunResult[AgentResponse]:
60
59
  """Create or update a plan based on the given goal using artifacts.
61
60
 
62
61
  Args:
@@ -4,7 +4,6 @@ from functools import partial
4
4
 
5
5
  from pydantic_ai import (
6
6
  Agent,
7
- DeferredToolRequests,
8
7
  )
9
8
  from pydantic_ai.agent import AgentRunResult
10
9
  from pydantic_ai.messages import (
@@ -21,15 +20,15 @@ from .common import (
21
20
  create_usage_limits,
22
21
  run_agent,
23
22
  )
24
- from .models import AgentDeps, AgentRuntimeOptions, AgentType
23
+ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
25
24
  from .tools import get_available_web_search_tools
26
25
 
27
26
  logger = get_logger(__name__)
28
27
 
29
28
 
30
- def create_research_agent(
29
+ async def create_research_agent(
31
30
  agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
32
- ) -> tuple[Agent[AgentDeps, str | DeferredToolRequests], AgentDeps]:
31
+ ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
33
32
  """Create a research agent with web search and artifact management capabilities.
34
33
 
35
34
  Args:
@@ -42,7 +41,7 @@ def create_research_agent(
42
41
  logger.debug("Initializing research agent")
43
42
 
44
43
  # Get available web search tools based on configured API keys
45
- web_search_tools = get_available_web_search_tools()
44
+ web_search_tools = await get_available_web_search_tools()
46
45
  if web_search_tools:
47
46
  logger.info(
48
47
  "Research agent configured with %d web search tool(s)",
@@ -54,7 +53,7 @@ def create_research_agent(
54
53
  # Use partial to create system prompt function for research agent
55
54
  system_prompt_fn = partial(build_agent_system_prompt, "research")
56
55
 
57
- agent, deps = create_base_agent(
56
+ agent, deps = await create_base_agent(
58
57
  system_prompt_fn,
59
58
  agent_runtime_options,
60
59
  load_codebase_understanding_tools=True,
@@ -66,11 +65,11 @@ def create_research_agent(
66
65
 
67
66
 
68
67
  async def run_research_agent(
69
- agent: Agent[AgentDeps, str | DeferredToolRequests],
68
+ agent: Agent[AgentDeps, AgentResponse],
70
69
  query: str,
71
70
  deps: AgentDeps,
72
71
  message_history: list[ModelMessage] | None = None,
73
- ) -> AgentRunResult[str | DeferredToolRequests]:
72
+ ) -> AgentRunResult[AgentResponse]:
74
73
  """Perform research on the given query and update research artifacts.
75
74
 
76
75
  Args:
shotgun/agents/specify.py CHANGED
@@ -4,7 +4,6 @@ from functools import partial
4
4
 
5
5
  from pydantic_ai import (
6
6
  Agent,
7
- DeferredToolRequests,
8
7
  )
9
8
  from pydantic_ai.agent import AgentRunResult
10
9
  from pydantic_ai.messages import ModelMessage
@@ -19,14 +18,14 @@ from .common import (
19
18
  create_usage_limits,
20
19
  run_agent,
21
20
  )
22
- from .models import AgentDeps, AgentRuntimeOptions, AgentType
21
+ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
23
22
 
24
23
  logger = get_logger(__name__)
25
24
 
26
25
 
27
- def create_specify_agent(
26
+ async def create_specify_agent(
28
27
  agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
29
- ) -> tuple[Agent[AgentDeps, str | DeferredToolRequests], AgentDeps]:
28
+ ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
30
29
  """Create a specify agent with artifact management capabilities.
31
30
 
32
31
  Args:
@@ -40,7 +39,7 @@ def create_specify_agent(
40
39
  # Use partial to create system prompt function for specify agent
41
40
  system_prompt_fn = partial(build_agent_system_prompt, "specify")
42
41
 
43
- agent, deps = create_base_agent(
42
+ agent, deps = await create_base_agent(
44
43
  system_prompt_fn,
45
44
  agent_runtime_options,
46
45
  load_codebase_understanding_tools=True,
@@ -52,11 +51,11 @@ def create_specify_agent(
52
51
 
53
52
 
54
53
  async def run_specify_agent(
55
- agent: Agent[AgentDeps, str | DeferredToolRequests],
54
+ agent: Agent[AgentDeps, AgentResponse],
56
55
  requirement: str,
57
56
  deps: AgentDeps,
58
57
  message_history: list[ModelMessage] | None = None,
59
- ) -> AgentRunResult[str | DeferredToolRequests]:
58
+ ) -> AgentRunResult[AgentResponse]:
60
59
  """Create or update specifications based on the given requirement.
61
60
 
62
61
  Args:
shotgun/agents/tasks.py CHANGED
@@ -4,7 +4,6 @@ from functools import partial
4
4
 
5
5
  from pydantic_ai import (
6
6
  Agent,
7
- DeferredToolRequests,
8
7
  )
9
8
  from pydantic_ai.agent import AgentRunResult
10
9
  from pydantic_ai.messages import ModelMessage
@@ -19,14 +18,14 @@ from .common import (
19
18
  create_usage_limits,
20
19
  run_agent,
21
20
  )
22
- from .models import AgentDeps, AgentRuntimeOptions, AgentType
21
+ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
23
22
 
24
23
  logger = get_logger(__name__)
25
24
 
26
25
 
27
- def create_tasks_agent(
26
+ async def create_tasks_agent(
28
27
  agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
29
- ) -> tuple[Agent[AgentDeps, str | DeferredToolRequests], AgentDeps]:
28
+ ) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
30
29
  """Create a tasks agent with file management capabilities.
31
30
 
32
31
  Args:
@@ -40,7 +39,7 @@ def create_tasks_agent(
40
39
  # Use partial to create system prompt function for tasks agent
41
40
  system_prompt_fn = partial(build_agent_system_prompt, "tasks")
42
41
 
43
- agent, deps = create_base_agent(
42
+ agent, deps = await create_base_agent(
44
43
  system_prompt_fn,
45
44
  agent_runtime_options,
46
45
  provider=provider,
@@ -50,11 +49,11 @@ def create_tasks_agent(
50
49
 
51
50
 
52
51
  async def run_tasks_agent(
53
- agent: Agent[AgentDeps, str | DeferredToolRequests],
52
+ agent: Agent[AgentDeps, AgentResponse],
54
53
  instruction: str,
55
54
  deps: AgentDeps,
56
55
  message_history: list[ModelMessage] | None = None,
57
- ) -> AgentRunResult[str | DeferredToolRequests]:
56
+ ) -> AgentRunResult[AgentResponse]:
58
57
  """Create or update tasks based on the given instruction.
59
58
 
60
59
  Args:
@@ -8,7 +8,6 @@ from .codebase import (
8
8
  retrieve_code,
9
9
  )
10
10
  from .file_management import append_file, read_file, write_file
11
- from .user_interaction import ask_user
12
11
  from .web_search import (
13
12
  anthropic_web_search_tool,
14
13
  gemini_web_search_tool,
@@ -21,7 +20,6 @@ __all__ = [
21
20
  "anthropic_web_search_tool",
22
21
  "gemini_web_search_tool",
23
22
  "get_available_web_search_tools",
24
- "ask_user",
25
23
  "read_file",
26
24
  "write_file",
27
25
  "append_file",
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from pydantic_ai import RunContext
9
9
 
10
10
  from shotgun.agents.models import AgentDeps
11
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
11
12
  from shotgun.logging_config import get_logger
12
13
 
13
14
  from .models import ShellCommandResult
@@ -48,6 +49,11 @@ DANGEROUS_PATTERNS = [
48
49
  ]
49
50
 
50
51
 
52
+ @register_tool(
53
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
54
+ display_text="Running shell",
55
+ key_arg="command",
56
+ )
51
57
  async def codebase_shell(
52
58
  ctx: RunContext[AgentDeps],
53
59
  command: str,
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from pydantic_ai import RunContext
6
6
 
7
7
  from shotgun.agents.models import AgentDeps
8
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
9
  from shotgun.logging_config import get_logger
9
10
 
10
11
  from .models import DirectoryListResult
@@ -12,6 +13,11 @@ from .models import DirectoryListResult
12
13
  logger = get_logger(__name__)
13
14
 
14
15
 
16
+ @register_tool(
17
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
18
+ display_text="Listing directory",
19
+ key_arg="directory",
20
+ )
15
21
  async def directory_lister(
16
22
  ctx: RunContext[AgentDeps], graph_id: str, directory: str = "."
17
23
  ) -> DirectoryListResult:
@@ -2,9 +2,11 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
+ import aiofiles
5
6
  from pydantic_ai import RunContext
6
7
 
7
8
  from shotgun.agents.models import AgentDeps
9
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
10
  from shotgun.codebase.core.language_config import get_language_config
9
11
  from shotgun.logging_config import get_logger
10
12
 
@@ -13,6 +15,11 @@ from .models import FileReadResult
13
15
  logger = get_logger(__name__)
14
16
 
15
17
 
18
+ @register_tool(
19
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
20
+ display_text="Reading file",
21
+ key_arg="file_path",
22
+ )
16
23
  async def file_read(
17
24
  ctx: RunContext[AgentDeps], graph_id: str, file_path: str
18
25
  ) -> FileReadResult:
@@ -87,7 +94,8 @@ async def file_read(
87
94
  # Read file contents
88
95
  encoding_used = "utf-8"
89
96
  try:
90
- content = full_file_path.read_text(encoding="utf-8")
97
+ async with aiofiles.open(full_file_path, encoding="utf-8") as f:
98
+ content = await f.read()
91
99
  size_bytes = full_file_path.stat().st_size
92
100
 
93
101
  logger.debug(
@@ -113,7 +121,8 @@ async def file_read(
113
121
  try:
114
122
  # Try with different encoding
115
123
  encoding_used = "latin-1"
116
- content = full_file_path.read_text(encoding="latin-1")
124
+ async with aiofiles.open(full_file_path, encoding="latin-1") as f:
125
+ content = await f.read()
117
126
  size_bytes = full_file_path.stat().st_size
118
127
 
119
128
  # Detect language from file extension
@@ -3,6 +3,7 @@
3
3
  from pydantic_ai import RunContext
4
4
 
5
5
  from shotgun.agents.models import AgentDeps
6
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
6
7
  from shotgun.codebase.models import QueryType
7
8
  from shotgun.logging_config import get_logger
8
9
 
@@ -11,6 +12,11 @@ from .models import QueryGraphResult
11
12
  logger = get_logger(__name__)
12
13
 
13
14
 
15
+ @register_tool(
16
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
17
+ display_text="Querying code",
18
+ key_arg="query",
19
+ )
14
20
  async def query_graph(
15
21
  ctx: RunContext[AgentDeps], graph_id: str, query: str
16
22
  ) -> QueryGraphResult:
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from pydantic_ai import RunContext
6
6
 
7
7
  from shotgun.agents.models import AgentDeps
8
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
9
  from shotgun.codebase.core.code_retrieval import retrieve_code_by_qualified_name
9
10
  from shotgun.codebase.core.language_config import get_language_config
10
11
  from shotgun.logging_config import get_logger
@@ -14,6 +15,11 @@ from .models import CodeSnippetResult
14
15
  logger = get_logger(__name__)
15
16
 
16
17
 
18
+ @register_tool(
19
+ category=ToolCategory.CODEBASE_UNDERSTANDING,
20
+ display_text="Retrieving code",
21
+ key_arg="qualified_name",
22
+ )
17
23
  async def retrieve_code(
18
24
  ctx: RunContext[AgentDeps], graph_id: str, qualified_name: str
19
25
  ) -> CodeSnippetResult:
@@ -6,20 +6,30 @@ These tools are restricted to the .shotgun directory for security.
6
6
  from pathlib import Path
7
7
  from typing import Literal
8
8
 
9
+ import aiofiles
10
+ import aiofiles.os
9
11
  from pydantic_ai import RunContext
10
12
 
11
13
  from shotgun.agents.models import AgentDeps, AgentType, FileOperationType
14
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
12
15
  from shotgun.logging_config import get_logger
13
16
  from shotgun.utils.file_system_utils import get_shotgun_base_path
14
17
 
15
18
  logger = get_logger(__name__)
16
19
 
17
20
  # Map agent modes to their allowed directories/files (in workflow order)
18
- AGENT_DIRECTORIES = {
19
- AgentType.RESEARCH: "research.md",
20
- AgentType.SPECIFY: "specification.md",
21
- AgentType.PLAN: "plan.md",
22
- AgentType.TASKS: "tasks.md",
21
+ # Values can be:
22
+ # - A Path: exact file (e.g., Path("research.md"))
23
+ # - A list of Paths: multiple allowed files/directories (e.g., [Path("specification.md"), Path("contracts")])
24
+ # - "*": any file except protected files (for export agent)
25
+ AGENT_DIRECTORIES: dict[AgentType, str | Path | list[Path]] = {
26
+ AgentType.RESEARCH: Path("research.md"),
27
+ AgentType.SPECIFY: [
28
+ Path("specification.md"),
29
+ Path("contracts"),
30
+ ], # Specify can write specs and contract files
31
+ AgentType.PLAN: Path("plan.md"),
32
+ AgentType.TASKS: Path("tasks.md"),
23
33
  AgentType.EXPORT: "*", # Export agent can write anywhere except protected files
24
34
  }
25
35
 
@@ -60,13 +70,52 @@ def _validate_agent_scoped_path(filename: str, agent_mode: AgentType | None) ->
60
70
  # Allow writing anywhere else in .shotgun directory
61
71
  full_path = (base_path / filename).resolve()
62
72
  else:
63
- # For other agents, only allow writing to their specific file
64
- allowed_file = AGENT_DIRECTORIES[agent_mode]
65
- if filename != allowed_file:
73
+ # For other agents, check if they have access to the requested file
74
+ allowed_paths_raw = AGENT_DIRECTORIES[agent_mode]
75
+
76
+ # Convert single Path/string to list of Paths for uniform handling
77
+ if isinstance(allowed_paths_raw, str):
78
+ # Special case: "*" means export agent
79
+ allowed_paths = (
80
+ [Path(allowed_paths_raw)] if allowed_paths_raw != "*" else []
81
+ )
82
+ elif isinstance(allowed_paths_raw, Path):
83
+ allowed_paths = [allowed_paths_raw]
84
+ else:
85
+ # Already a list
86
+ allowed_paths = allowed_paths_raw
87
+
88
+ # Check if filename matches any allowed path
89
+ is_allowed = False
90
+ for allowed_path in allowed_paths:
91
+ allowed_str = str(allowed_path)
92
+
93
+ # Check if it's a directory (no .md extension or suffix)
94
+ # Directories: Path("contracts") has no suffix, files: Path("spec.md") has .md suffix
95
+ if not allowed_path.suffix or (
96
+ allowed_path.suffix and not allowed_str.endswith(".md")
97
+ ):
98
+ # Directory - allow any file within this directory
99
+ # Check both "contracts/file.py" and "contracts" prefix
100
+ if (
101
+ filename.startswith(allowed_str + "/")
102
+ or filename == allowed_str
103
+ ):
104
+ is_allowed = True
105
+ break
106
+ else:
107
+ # Exact file match
108
+ if filename == allowed_str:
109
+ is_allowed = True
110
+ break
111
+
112
+ if not is_allowed:
113
+ allowed_display = ", ".join(f"'{p}'" for p in allowed_paths)
66
114
  raise ValueError(
67
- f"{agent_mode.value.capitalize()} agent can only write to '{allowed_file}'. "
115
+ f"{agent_mode.value.capitalize()} agent can only write to {allowed_display}. "
68
116
  f"Attempted to write to '{filename}'"
69
117
  )
118
+
70
119
  full_path = (base_path / filename).resolve()
71
120
  else:
72
121
  # No agent mode specified, fall back to old validation
@@ -111,6 +160,11 @@ def _validate_shotgun_path(filename: str) -> Path:
111
160
  return full_path
112
161
 
113
162
 
163
+ @register_tool(
164
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
165
+ display_text="Reading file",
166
+ key_arg="filename",
167
+ )
114
168
  async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
115
169
  """Read a file from the .shotgun directory.
116
170
 
@@ -129,10 +183,11 @@ async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
129
183
  try:
130
184
  file_path = _validate_shotgun_path(filename)
131
185
 
132
- if not file_path.exists():
186
+ if not await aiofiles.os.path.exists(file_path):
133
187
  raise FileNotFoundError(f"File not found: {filename}")
134
188
 
135
- content = file_path.read_text(encoding="utf-8")
189
+ async with aiofiles.open(file_path, encoding="utf-8") as f:
190
+ content = await f.read()
136
191
  logger.debug("📄 Read %d characters from %s", len(content), filename)
137
192
  return content
138
193
 
@@ -142,6 +197,11 @@ async def read_file(ctx: RunContext[AgentDeps], filename: str) -> str:
142
197
  return error_msg
143
198
 
144
199
 
200
+ @register_tool(
201
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
202
+ display_text="Writing file",
203
+ key_arg="filename",
204
+ )
145
205
  async def write_file(
146
206
  ctx: RunContext[AgentDeps],
147
207
  filename: str,
@@ -176,21 +236,22 @@ async def write_file(
176
236
  else:
177
237
  operation = (
178
238
  FileOperationType.CREATED
179
- if not file_path.exists()
239
+ if not await aiofiles.os.path.exists(file_path)
180
240
  else FileOperationType.UPDATED
181
241
  )
182
242
 
183
243
  # Ensure parent directory exists
184
- file_path.parent.mkdir(parents=True, exist_ok=True)
244
+ await aiofiles.os.makedirs(file_path.parent, exist_ok=True)
185
245
 
186
246
  # Write content
187
247
  if mode == "a":
188
- with open(file_path, "a", encoding="utf-8") as f:
189
- f.write(content)
248
+ async with aiofiles.open(file_path, "a", encoding="utf-8") as f:
249
+ await f.write(content)
190
250
  logger.debug("📄 Appended %d characters to %s", len(content), filename)
191
251
  result = f"Successfully appended {len(content)} characters to {filename}"
192
252
  else:
193
- file_path.write_text(content, encoding="utf-8")
253
+ async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
254
+ await f.write(content)
194
255
  logger.debug("📄 Wrote %d characters to %s", len(content), filename)
195
256
  result = f"Successfully wrote {len(content)} characters to {filename}"
196
257
 
@@ -205,6 +266,11 @@ async def write_file(
205
266
  return error_msg
206
267
 
207
268
 
269
+ @register_tool(
270
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
271
+ display_text="Appending to file",
272
+ key_arg="filename",
273
+ )
208
274
  async def append_file(ctx: RunContext[AgentDeps], filename: str, content: str) -> str:
209
275
  """Append content to a file in the .shotgun directory.
210
276