shotgun-sh 0.2.8.dev2__py3-none-any.whl → 0.3.3.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.
- shotgun/agents/agent_manager.py +382 -60
- shotgun/agents/common.py +15 -9
- shotgun/agents/config/README.md +89 -0
- shotgun/agents/config/__init__.py +10 -1
- shotgun/agents/config/constants.py +0 -6
- shotgun/agents/config/manager.py +383 -82
- shotgun/agents/config/models.py +122 -18
- shotgun/agents/config/provider.py +81 -15
- shotgun/agents/config/streaming_test.py +119 -0
- shotgun/agents/context_analyzer/__init__.py +28 -0
- shotgun/agents/context_analyzer/analyzer.py +475 -0
- shotgun/agents/context_analyzer/constants.py +9 -0
- shotgun/agents/context_analyzer/formatter.py +115 -0
- shotgun/agents/context_analyzer/models.py +212 -0
- shotgun/agents/conversation/__init__.py +18 -0
- shotgun/agents/conversation/filters.py +164 -0
- shotgun/agents/conversation/history/chunking.py +278 -0
- shotgun/agents/{history → conversation/history}/compaction.py +36 -5
- shotgun/agents/{history → conversation/history}/constants.py +5 -0
- shotgun/agents/conversation/history/file_content_deduplication.py +216 -0
- shotgun/agents/{history → conversation/history}/history_processors.py +380 -8
- shotgun/agents/{history → conversation/history}/token_counting/anthropic.py +25 -1
- shotgun/agents/{history → conversation/history}/token_counting/base.py +14 -3
- shotgun/agents/{history → conversation/history}/token_counting/openai.py +11 -1
- shotgun/agents/{history → conversation/history}/token_counting/sentencepiece_counter.py +8 -0
- shotgun/agents/{history → conversation/history}/token_counting/tokenizer_cache.py +3 -1
- shotgun/agents/{history → conversation/history}/token_counting/utils.py +0 -3
- shotgun/agents/{conversation_manager.py → conversation/manager.py} +36 -20
- shotgun/agents/{conversation_history.py → conversation/models.py} +8 -92
- shotgun/agents/error/__init__.py +11 -0
- shotgun/agents/error/models.py +19 -0
- shotgun/agents/export.py +2 -2
- shotgun/agents/plan.py +2 -2
- shotgun/agents/research.py +3 -3
- shotgun/agents/runner.py +230 -0
- shotgun/agents/specify.py +2 -2
- shotgun/agents/tasks.py +2 -2
- shotgun/agents/tools/codebase/codebase_shell.py +6 -0
- shotgun/agents/tools/codebase/directory_lister.py +6 -0
- shotgun/agents/tools/codebase/file_read.py +11 -2
- shotgun/agents/tools/codebase/query_graph.py +6 -0
- shotgun/agents/tools/codebase/retrieve_code.py +6 -0
- shotgun/agents/tools/file_management.py +27 -7
- shotgun/agents/tools/registry.py +217 -0
- shotgun/agents/tools/web_search/__init__.py +8 -8
- shotgun/agents/tools/web_search/anthropic.py +8 -2
- shotgun/agents/tools/web_search/gemini.py +7 -1
- shotgun/agents/tools/web_search/openai.py +8 -2
- shotgun/agents/tools/web_search/utils.py +2 -2
- shotgun/agents/usage_manager.py +16 -11
- shotgun/api_endpoints.py +7 -3
- shotgun/build_constants.py +2 -2
- shotgun/cli/clear.py +53 -0
- shotgun/cli/compact.py +188 -0
- shotgun/cli/config.py +8 -5
- shotgun/cli/context.py +154 -0
- shotgun/cli/error_handler.py +24 -0
- shotgun/cli/export.py +34 -34
- shotgun/cli/feedback.py +4 -2
- shotgun/cli/models.py +1 -0
- shotgun/cli/plan.py +34 -34
- shotgun/cli/research.py +18 -10
- shotgun/cli/spec/__init__.py +5 -0
- shotgun/cli/spec/backup.py +81 -0
- shotgun/cli/spec/commands.py +132 -0
- shotgun/cli/spec/models.py +48 -0
- shotgun/cli/spec/pull_service.py +219 -0
- shotgun/cli/specify.py +20 -19
- shotgun/cli/tasks.py +34 -34
- shotgun/cli/update.py +16 -2
- shotgun/codebase/core/change_detector.py +5 -3
- shotgun/codebase/core/code_retrieval.py +4 -2
- shotgun/codebase/core/ingestor.py +163 -15
- shotgun/codebase/core/manager.py +13 -4
- shotgun/codebase/core/nl_query.py +1 -1
- shotgun/codebase/models.py +2 -0
- shotgun/exceptions.py +357 -0
- shotgun/llm_proxy/__init__.py +17 -0
- shotgun/llm_proxy/client.py +215 -0
- shotgun/llm_proxy/models.py +137 -0
- shotgun/logging_config.py +60 -27
- shotgun/main.py +77 -11
- shotgun/posthog_telemetry.py +38 -29
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
- shotgun/prompts/agents/partials/interactive_mode.j2 +3 -3
- shotgun/prompts/agents/plan.j2 +16 -0
- shotgun/prompts/agents/research.j2 +16 -3
- shotgun/prompts/agents/specify.j2 +54 -1
- shotgun/prompts/agents/state/system_state.j2 +0 -2
- shotgun/prompts/agents/tasks.j2 +16 -0
- shotgun/prompts/history/chunk_summarization.j2 +34 -0
- shotgun/prompts/history/combine_summaries.j2 +53 -0
- shotgun/sdk/codebase.py +14 -3
- shotgun/sentry_telemetry.py +163 -16
- shotgun/settings.py +243 -0
- shotgun/shotgun_web/__init__.py +67 -1
- shotgun/shotgun_web/client.py +42 -1
- shotgun/shotgun_web/constants.py +46 -0
- shotgun/shotgun_web/exceptions.py +29 -0
- shotgun/shotgun_web/models.py +390 -0
- shotgun/shotgun_web/shared_specs/__init__.py +32 -0
- shotgun/shotgun_web/shared_specs/file_scanner.py +175 -0
- shotgun/shotgun_web/shared_specs/hasher.py +83 -0
- shotgun/shotgun_web/shared_specs/models.py +71 -0
- shotgun/shotgun_web/shared_specs/upload_pipeline.py +329 -0
- shotgun/shotgun_web/shared_specs/utils.py +34 -0
- shotgun/shotgun_web/specs_client.py +703 -0
- shotgun/shotgun_web/supabase_client.py +31 -0
- shotgun/telemetry.py +10 -33
- shotgun/tui/app.py +310 -46
- shotgun/tui/commands/__init__.py +1 -1
- shotgun/tui/components/context_indicator.py +179 -0
- shotgun/tui/components/mode_indicator.py +70 -0
- shotgun/tui/components/status_bar.py +48 -0
- shotgun/tui/containers.py +91 -0
- shotgun/tui/dependencies.py +39 -0
- shotgun/tui/layout.py +5 -0
- shotgun/tui/protocols.py +45 -0
- shotgun/tui/screens/chat/__init__.py +5 -0
- shotgun/tui/screens/chat/chat.tcss +54 -0
- shotgun/tui/screens/chat/chat_screen.py +1531 -0
- shotgun/tui/screens/chat/codebase_index_prompt_screen.py +243 -0
- shotgun/tui/screens/chat/codebase_index_selection.py +12 -0
- shotgun/tui/screens/chat/help_text.py +40 -0
- shotgun/tui/screens/chat/prompt_history.py +48 -0
- shotgun/tui/screens/chat.tcss +11 -0
- shotgun/tui/screens/chat_screen/command_providers.py +91 -4
- shotgun/tui/screens/chat_screen/hint_message.py +76 -1
- shotgun/tui/screens/chat_screen/history/__init__.py +22 -0
- shotgun/tui/screens/chat_screen/history/agent_response.py +66 -0
- shotgun/tui/screens/chat_screen/history/chat_history.py +115 -0
- shotgun/tui/screens/chat_screen/history/formatters.py +115 -0
- shotgun/tui/screens/chat_screen/history/partial_response.py +43 -0
- shotgun/tui/screens/chat_screen/history/user_question.py +42 -0
- shotgun/tui/screens/confirmation_dialog.py +191 -0
- shotgun/tui/screens/directory_setup.py +45 -41
- shotgun/tui/screens/feedback.py +14 -7
- shotgun/tui/screens/github_issue.py +111 -0
- shotgun/tui/screens/model_picker.py +77 -32
- shotgun/tui/screens/onboarding.py +580 -0
- shotgun/tui/screens/pipx_migration.py +205 -0
- shotgun/tui/screens/provider_config.py +116 -35
- shotgun/tui/screens/shared_specs/__init__.py +21 -0
- shotgun/tui/screens/shared_specs/create_spec_dialog.py +273 -0
- shotgun/tui/screens/shared_specs/models.py +56 -0
- shotgun/tui/screens/shared_specs/share_specs_dialog.py +390 -0
- shotgun/tui/screens/shared_specs/upload_progress_screen.py +452 -0
- shotgun/tui/screens/shotgun_auth.py +112 -18
- shotgun/tui/screens/spec_pull.py +288 -0
- shotgun/tui/screens/welcome.py +137 -11
- shotgun/tui/services/__init__.py +5 -0
- shotgun/tui/services/conversation_service.py +187 -0
- shotgun/tui/state/__init__.py +7 -0
- shotgun/tui/state/processing_state.py +185 -0
- shotgun/tui/utils/mode_progress.py +14 -7
- shotgun/tui/widgets/__init__.py +5 -0
- shotgun/tui/widgets/widget_coordinator.py +263 -0
- shotgun/utils/file_system_utils.py +22 -2
- shotgun/utils/marketing.py +110 -0
- shotgun/utils/update_checker.py +69 -14
- shotgun_sh-0.3.3.dev1.dist-info/METADATA +472 -0
- shotgun_sh-0.3.3.dev1.dist-info/RECORD +229 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/WHEEL +1 -1
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/entry_points.txt +1 -0
- {shotgun_sh-0.2.8.dev2.dist-info → shotgun_sh-0.3.3.dev1.dist-info}/licenses/LICENSE +1 -1
- shotgun/tui/screens/chat.py +0 -996
- shotgun/tui/screens/chat_screen/history.py +0 -335
- shotgun_sh-0.2.8.dev2.dist-info/METADATA +0 -126
- shotgun_sh-0.2.8.dev2.dist-info/RECORD +0 -155
- /shotgun/agents/{history → conversation/history}/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/context_extraction.py +0 -0
- /shotgun/agents/{history → conversation/history}/history_building.py +0 -0
- /shotgun/agents/{history → conversation/history}/message_utils.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_counting/__init__.py +0 -0
- /shotgun/agents/{history → conversation/history}/token_estimation.py +0 -0
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
"""Manager for handling conversation persistence operations."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import json
|
|
4
|
-
import shutil
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
import aiofiles
|
|
8
|
+
import aiofiles.os
|
|
9
|
+
|
|
7
10
|
from shotgun.logging_config import get_logger
|
|
8
11
|
from shotgun.utils import get_shotgun_home
|
|
12
|
+
from shotgun.utils.file_system_utils import async_copy_file
|
|
9
13
|
|
|
10
|
-
from .
|
|
14
|
+
from .models import ConversationHistory
|
|
11
15
|
|
|
12
16
|
logger = get_logger(__name__)
|
|
13
17
|
|
|
@@ -27,14 +31,14 @@ class ConversationManager:
|
|
|
27
31
|
else:
|
|
28
32
|
self.conversation_path = conversation_path
|
|
29
33
|
|
|
30
|
-
def save(self, conversation: ConversationHistory) -> None:
|
|
34
|
+
async def save(self, conversation: ConversationHistory) -> None:
|
|
31
35
|
"""Save conversation history to file.
|
|
32
36
|
|
|
33
37
|
Args:
|
|
34
38
|
conversation: ConversationHistory to save
|
|
35
39
|
"""
|
|
36
40
|
# Ensure directory exists
|
|
37
|
-
self.conversation_path.parent
|
|
41
|
+
await aiofiles.os.makedirs(self.conversation_path.parent, exist_ok=True)
|
|
38
42
|
|
|
39
43
|
try:
|
|
40
44
|
# Update timestamp
|
|
@@ -42,11 +46,17 @@ class ConversationManager:
|
|
|
42
46
|
|
|
43
47
|
conversation.updated_at = datetime.now()
|
|
44
48
|
|
|
45
|
-
# Serialize to JSON
|
|
46
|
-
|
|
49
|
+
# Serialize to JSON in background thread to avoid blocking event loop
|
|
50
|
+
# This is crucial for large conversations (5k+ tokens)
|
|
51
|
+
data = await asyncio.to_thread(conversation.model_dump, mode="json")
|
|
52
|
+
json_content = await asyncio.to_thread(
|
|
53
|
+
json.dumps, data, indent=2, ensure_ascii=False
|
|
54
|
+
)
|
|
47
55
|
|
|
48
|
-
with open(
|
|
49
|
-
|
|
56
|
+
async with aiofiles.open(
|
|
57
|
+
self.conversation_path, "w", encoding="utf-8"
|
|
58
|
+
) as f:
|
|
59
|
+
await f.write(json_content)
|
|
50
60
|
|
|
51
61
|
logger.debug("Conversation saved to %s", self.conversation_path)
|
|
52
62
|
|
|
@@ -56,21 +66,26 @@ class ConversationManager:
|
|
|
56
66
|
)
|
|
57
67
|
# Don't raise - we don't want to interrupt the user's session
|
|
58
68
|
|
|
59
|
-
def load(self) -> ConversationHistory | None:
|
|
69
|
+
async def load(self) -> ConversationHistory | None:
|
|
60
70
|
"""Load conversation history from file.
|
|
61
71
|
|
|
62
72
|
Returns:
|
|
63
73
|
ConversationHistory if file exists and is valid, None otherwise
|
|
64
74
|
"""
|
|
65
|
-
if not
|
|
75
|
+
if not await aiofiles.os.path.exists(self.conversation_path):
|
|
66
76
|
logger.debug("No conversation history found at %s", self.conversation_path)
|
|
67
77
|
return None
|
|
68
78
|
|
|
69
79
|
try:
|
|
70
|
-
with open(self.conversation_path, encoding="utf-8") as f:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
async with aiofiles.open(self.conversation_path, encoding="utf-8") as f:
|
|
81
|
+
content = await f.read()
|
|
82
|
+
# Deserialize JSON in background thread to avoid blocking
|
|
83
|
+
data = await asyncio.to_thread(json.loads, content)
|
|
84
|
+
|
|
85
|
+
# Validate model in background thread for large conversations
|
|
86
|
+
conversation = await asyncio.to_thread(
|
|
87
|
+
ConversationHistory.model_validate, data
|
|
88
|
+
)
|
|
74
89
|
logger.debug(
|
|
75
90
|
"Conversation loaded from %s with %d agent messages",
|
|
76
91
|
self.conversation_path,
|
|
@@ -89,7 +104,7 @@ class ConversationManager:
|
|
|
89
104
|
# Create a backup of the corrupted file for debugging
|
|
90
105
|
backup_path = self.conversation_path.with_suffix(".json.backup")
|
|
91
106
|
try:
|
|
92
|
-
|
|
107
|
+
await async_copy_file(self.conversation_path, backup_path)
|
|
93
108
|
logger.info("Backed up corrupted conversation to %s", backup_path)
|
|
94
109
|
except Exception as backup_error: # pragma: no cover
|
|
95
110
|
logger.warning("Failed to backup corrupted file: %s", backup_error)
|
|
@@ -105,11 +120,12 @@ class ConversationManager:
|
|
|
105
120
|
)
|
|
106
121
|
return None
|
|
107
122
|
|
|
108
|
-
def clear(self) -> None:
|
|
123
|
+
async def clear(self) -> None:
|
|
109
124
|
"""Delete the conversation history file."""
|
|
110
|
-
if
|
|
125
|
+
if await aiofiles.os.path.exists(self.conversation_path):
|
|
111
126
|
try:
|
|
112
|
-
|
|
127
|
+
# Use asyncio.to_thread for unlink operation
|
|
128
|
+
await asyncio.to_thread(self.conversation_path.unlink)
|
|
113
129
|
logger.debug(
|
|
114
130
|
"Conversation history cleared at %s", self.conversation_path
|
|
115
131
|
)
|
|
@@ -118,10 +134,10 @@ class ConversationManager:
|
|
|
118
134
|
"Failed to clear conversation at %s: %s", self.conversation_path, e
|
|
119
135
|
)
|
|
120
136
|
|
|
121
|
-
def exists(self) -> bool:
|
|
137
|
+
async def exists(self) -> bool:
|
|
122
138
|
"""Check if a conversation history file exists.
|
|
123
139
|
|
|
124
140
|
Returns:
|
|
125
141
|
True if conversation file exists, False otherwise
|
|
126
142
|
"""
|
|
127
|
-
return
|
|
143
|
+
return await aiofiles.os.path.exists(str(self.conversation_path))
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
"""Models
|
|
1
|
+
"""Models for persisting TUI conversation history."""
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
|
-
import logging
|
|
5
3
|
from datetime import datetime
|
|
6
4
|
from typing import Any, cast
|
|
7
5
|
|
|
@@ -16,99 +14,15 @@ from pydantic_core import to_jsonable_python
|
|
|
16
14
|
|
|
17
15
|
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
from .filters import (
|
|
18
|
+
filter_incomplete_messages,
|
|
19
|
+
filter_orphaned_tool_responses,
|
|
20
|
+
is_tool_call_complete,
|
|
21
|
+
)
|
|
20
22
|
|
|
21
23
|
SerializedMessage = dict[str, Any]
|
|
22
24
|
|
|
23
25
|
|
|
24
|
-
def is_tool_call_complete(tool_call: ToolCallPart) -> bool:
|
|
25
|
-
"""Check if a tool call has valid, complete JSON arguments.
|
|
26
|
-
|
|
27
|
-
Args:
|
|
28
|
-
tool_call: The tool call part to validate
|
|
29
|
-
|
|
30
|
-
Returns:
|
|
31
|
-
True if the tool call args are valid JSON, False otherwise
|
|
32
|
-
"""
|
|
33
|
-
if tool_call.args is None:
|
|
34
|
-
return True # No args is valid
|
|
35
|
-
|
|
36
|
-
if isinstance(tool_call.args, dict):
|
|
37
|
-
return True # Already parsed dict is valid
|
|
38
|
-
|
|
39
|
-
if not isinstance(tool_call.args, str):
|
|
40
|
-
return False
|
|
41
|
-
|
|
42
|
-
# Try to parse the JSON string
|
|
43
|
-
try:
|
|
44
|
-
json.loads(tool_call.args)
|
|
45
|
-
return True
|
|
46
|
-
except (json.JSONDecodeError, ValueError) as e:
|
|
47
|
-
# Log incomplete tool call detection
|
|
48
|
-
args_preview = (
|
|
49
|
-
tool_call.args[:100] + "..."
|
|
50
|
-
if len(tool_call.args) > 100
|
|
51
|
-
else tool_call.args
|
|
52
|
-
)
|
|
53
|
-
logger.info(
|
|
54
|
-
"Detected incomplete tool call in validation",
|
|
55
|
-
extra={
|
|
56
|
-
"tool_name": tool_call.tool_name,
|
|
57
|
-
"tool_call_id": tool_call.tool_call_id,
|
|
58
|
-
"args_preview": args_preview,
|
|
59
|
-
"error": str(e),
|
|
60
|
-
},
|
|
61
|
-
)
|
|
62
|
-
return False
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def filter_incomplete_messages(messages: list[ModelMessage]) -> list[ModelMessage]:
|
|
66
|
-
"""Filter out messages with incomplete tool calls.
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
messages: List of messages to filter
|
|
70
|
-
|
|
71
|
-
Returns:
|
|
72
|
-
List of messages with only complete tool calls
|
|
73
|
-
"""
|
|
74
|
-
filtered: list[ModelMessage] = []
|
|
75
|
-
filtered_count = 0
|
|
76
|
-
filtered_tool_names: list[str] = []
|
|
77
|
-
|
|
78
|
-
for message in messages:
|
|
79
|
-
# Only check ModelResponse messages for tool calls
|
|
80
|
-
if not isinstance(message, ModelResponse):
|
|
81
|
-
filtered.append(message)
|
|
82
|
-
continue
|
|
83
|
-
|
|
84
|
-
# Check if any tool calls are incomplete
|
|
85
|
-
has_incomplete_tool_call = False
|
|
86
|
-
for part in message.parts:
|
|
87
|
-
if isinstance(part, ToolCallPart) and not is_tool_call_complete(part):
|
|
88
|
-
has_incomplete_tool_call = True
|
|
89
|
-
filtered_tool_names.append(part.tool_name)
|
|
90
|
-
break
|
|
91
|
-
|
|
92
|
-
# Only include messages without incomplete tool calls
|
|
93
|
-
if not has_incomplete_tool_call:
|
|
94
|
-
filtered.append(message)
|
|
95
|
-
else:
|
|
96
|
-
filtered_count += 1
|
|
97
|
-
|
|
98
|
-
# Log if any messages were filtered
|
|
99
|
-
if filtered_count > 0:
|
|
100
|
-
logger.info(
|
|
101
|
-
"Filtered incomplete messages before saving",
|
|
102
|
-
extra={
|
|
103
|
-
"filtered_count": filtered_count,
|
|
104
|
-
"total_messages": len(messages),
|
|
105
|
-
"filtered_tool_names": filtered_tool_names,
|
|
106
|
-
},
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
return filtered
|
|
110
|
-
|
|
111
|
-
|
|
112
26
|
class ConversationState(BaseModel):
|
|
113
27
|
"""Represents the complete state of a conversation in memory."""
|
|
114
28
|
|
|
@@ -142,6 +56,8 @@ class ConversationHistory(BaseModel):
|
|
|
142
56
|
"""
|
|
143
57
|
# Filter out messages with incomplete tool calls to prevent corruption
|
|
144
58
|
filtered_messages = filter_incomplete_messages(messages)
|
|
59
|
+
# Filter out orphaned tool responses (tool responses without tool calls)
|
|
60
|
+
filtered_messages = filter_orphaned_tool_responses(filtered_messages)
|
|
145
61
|
|
|
146
62
|
# Serialize ModelMessage list to JSON-serializable format
|
|
147
63
|
self.agent_history = to_jsonable_python(
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Pydantic models for agent error handling."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AgentErrorContext(BaseModel):
|
|
9
|
+
"""Context information needed to classify and handle agent errors.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
exception: The exception that was raised
|
|
13
|
+
is_shotgun_account: Whether the user is using a Shotgun Account
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
17
|
+
|
|
18
|
+
exception: Any = Field(...)
|
|
19
|
+
is_shotgun_account: bool
|
shotgun/agents/export.py
CHANGED
|
@@ -23,7 +23,7 @@ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
|
|
|
23
23
|
logger = get_logger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def create_export_agent(
|
|
26
|
+
async def create_export_agent(
|
|
27
27
|
agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
|
|
28
28
|
) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
|
|
29
29
|
"""Create an export agent with file management capabilities.
|
|
@@ -39,7 +39,7 @@ def create_export_agent(
|
|
|
39
39
|
# Use partial to create system prompt function for export agent
|
|
40
40
|
system_prompt_fn = partial(build_agent_system_prompt, "export")
|
|
41
41
|
|
|
42
|
-
agent, deps = create_base_agent(
|
|
42
|
+
agent, deps = await create_base_agent(
|
|
43
43
|
system_prompt_fn,
|
|
44
44
|
agent_runtime_options,
|
|
45
45
|
provider=provider,
|
shotgun/agents/plan.py
CHANGED
|
@@ -23,7 +23,7 @@ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
|
|
|
23
23
|
logger = get_logger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def create_plan_agent(
|
|
26
|
+
async def create_plan_agent(
|
|
27
27
|
agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
|
|
28
28
|
) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
|
|
29
29
|
"""Create a plan agent with artifact management capabilities.
|
|
@@ -39,7 +39,7 @@ def create_plan_agent(
|
|
|
39
39
|
# Use partial to create system prompt function for plan agent
|
|
40
40
|
system_prompt_fn = partial(build_agent_system_prompt, "plan")
|
|
41
41
|
|
|
42
|
-
agent, deps = create_base_agent(
|
|
42
|
+
agent, deps = await create_base_agent(
|
|
43
43
|
system_prompt_fn,
|
|
44
44
|
agent_runtime_options,
|
|
45
45
|
load_codebase_understanding_tools=True,
|
shotgun/agents/research.py
CHANGED
|
@@ -26,7 +26,7 @@ from .tools import get_available_web_search_tools
|
|
|
26
26
|
logger = get_logger(__name__)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def create_research_agent(
|
|
29
|
+
async def create_research_agent(
|
|
30
30
|
agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
|
|
31
31
|
) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
|
|
32
32
|
"""Create a research agent with web search and artifact management capabilities.
|
|
@@ -41,7 +41,7 @@ def create_research_agent(
|
|
|
41
41
|
logger.debug("Initializing research agent")
|
|
42
42
|
|
|
43
43
|
# Get available web search tools based on configured API keys
|
|
44
|
-
web_search_tools = get_available_web_search_tools()
|
|
44
|
+
web_search_tools = await get_available_web_search_tools()
|
|
45
45
|
if web_search_tools:
|
|
46
46
|
logger.info(
|
|
47
47
|
"Research agent configured with %d web search tool(s)",
|
|
@@ -53,7 +53,7 @@ def create_research_agent(
|
|
|
53
53
|
# Use partial to create system prompt function for research agent
|
|
54
54
|
system_prompt_fn = partial(build_agent_system_prompt, "research")
|
|
55
55
|
|
|
56
|
-
agent, deps = create_base_agent(
|
|
56
|
+
agent, deps = await create_base_agent(
|
|
57
57
|
system_prompt_fn,
|
|
58
58
|
agent_runtime_options,
|
|
59
59
|
load_codebase_understanding_tools=True,
|
shotgun/agents/runner.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Unified agent execution with consistent error handling.
|
|
2
|
+
|
|
3
|
+
This module provides a reusable agent runner that wraps agent execution exceptions
|
|
4
|
+
in user-friendly custom exceptions that can be caught and displayed by TUI or CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
10
|
+
|
|
11
|
+
from anthropic import APIStatusError as AnthropicAPIStatusError
|
|
12
|
+
from openai import APIStatusError as OpenAIAPIStatusError
|
|
13
|
+
from pydantic_ai.exceptions import ModelHTTPError, UnexpectedModelBehavior
|
|
14
|
+
|
|
15
|
+
from shotgun.agents.error.models import AgentErrorContext
|
|
16
|
+
from shotgun.exceptions import (
|
|
17
|
+
AgentCancelledException,
|
|
18
|
+
BudgetExceededException,
|
|
19
|
+
BYOKAuthenticationException,
|
|
20
|
+
BYOKGenericAPIException,
|
|
21
|
+
BYOKQuotaBillingException,
|
|
22
|
+
BYOKRateLimitException,
|
|
23
|
+
BYOKServiceOverloadException,
|
|
24
|
+
ContextSizeLimitExceeded,
|
|
25
|
+
GenericAPIStatusException,
|
|
26
|
+
ShotgunRateLimitException,
|
|
27
|
+
ShotgunServiceOverloadException,
|
|
28
|
+
UnknownAgentException,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from shotgun.agents.agent_manager import AgentManager
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AgentRunner:
|
|
38
|
+
"""Unified agent execution wrapper with consistent error handling.
|
|
39
|
+
|
|
40
|
+
This class wraps agent execution and converts any exceptions into
|
|
41
|
+
user-friendly custom exceptions that can be caught and displayed by the
|
|
42
|
+
calling interface (TUI or CLI).
|
|
43
|
+
|
|
44
|
+
The runner:
|
|
45
|
+
- Executes the agent
|
|
46
|
+
- Logs errors for debugging
|
|
47
|
+
- Wraps exceptions in custom exception types (AgentCancelledException,
|
|
48
|
+
BYOKRateLimitException, etc.)
|
|
49
|
+
- Lets exceptions propagate to caller for display
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
>>> runner = AgentRunner(agent_manager)
|
|
53
|
+
>>> try:
|
|
54
|
+
>>> await runner.run("Write a hello world function")
|
|
55
|
+
>>> except ContextSizeLimitExceeded as e:
|
|
56
|
+
>>> print(e.to_markdown())
|
|
57
|
+
>>> except BYOKRateLimitException as e:
|
|
58
|
+
>>> print(e.to_plain_text())
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, agent_manager: "AgentManager"):
|
|
62
|
+
"""Initialize the agent runner.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
agent_manager: The agent manager to execute
|
|
66
|
+
"""
|
|
67
|
+
self.agent_manager = agent_manager
|
|
68
|
+
|
|
69
|
+
async def run(self, prompt: str) -> None:
|
|
70
|
+
"""Run the agent with the given prompt.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
prompt: The user's prompt/query
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
Custom exceptions for different error types:
|
|
77
|
+
- AgentCancelledException: User cancelled the operation
|
|
78
|
+
- ContextSizeLimitExceeded: Context too large for model
|
|
79
|
+
- BudgetExceededException: Shotgun Account budget exceeded
|
|
80
|
+
- BYOKRateLimitException: BYOK rate limit hit
|
|
81
|
+
- BYOKQuotaBillingException: BYOK quota/billing issue
|
|
82
|
+
- BYOKAuthenticationException: BYOK authentication failed
|
|
83
|
+
- BYOKServiceOverloadException: BYOK service overloaded
|
|
84
|
+
- BYOKGenericAPIException: Generic BYOK API error
|
|
85
|
+
- ShotgunServiceOverloadException: Shotgun service overloaded
|
|
86
|
+
- ShotgunRateLimitException: Shotgun rate limit hit
|
|
87
|
+
- GenericAPIStatusException: Generic API error
|
|
88
|
+
- UnknownAgentException: Unknown/unclassified error
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
await self.agent_manager.run(prompt=prompt)
|
|
92
|
+
|
|
93
|
+
except asyncio.CancelledError as e:
|
|
94
|
+
# User cancelled - wrap and re-raise as our custom exception
|
|
95
|
+
context = self._create_error_context(e)
|
|
96
|
+
self._classify_and_raise(context)
|
|
97
|
+
|
|
98
|
+
except ContextSizeLimitExceeded as e:
|
|
99
|
+
# Already a custom exception - log and re-raise
|
|
100
|
+
logger.info(
|
|
101
|
+
"Context size limit exceeded",
|
|
102
|
+
extra={
|
|
103
|
+
"max_tokens": e.max_tokens,
|
|
104
|
+
"model_name": e.model_name,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
# Log with full stack trace to shotgun.log
|
|
111
|
+
logger.exception(
|
|
112
|
+
"Agent run failed",
|
|
113
|
+
extra={
|
|
114
|
+
"agent_mode": self.agent_manager._current_agent_type.value,
|
|
115
|
+
"error_type": type(e).__name__,
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Create error context and wrap/raise custom exception
|
|
120
|
+
context = self._create_error_context(e)
|
|
121
|
+
self._classify_and_raise(context)
|
|
122
|
+
|
|
123
|
+
def _create_error_context(self, exception: BaseException) -> AgentErrorContext:
|
|
124
|
+
"""Create error context from exception and agent state.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
exception: The exception that was raised
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
AgentErrorContext with all necessary information for classification
|
|
131
|
+
"""
|
|
132
|
+
return AgentErrorContext(
|
|
133
|
+
exception=exception,
|
|
134
|
+
is_shotgun_account=self.agent_manager.deps.llm_model.is_shotgun_account,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _classify_and_raise(self, context: AgentErrorContext) -> NoReturn:
|
|
138
|
+
"""Classify an exception and raise the appropriate custom exception.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
context: Context information about the error
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
Custom exception based on the error type
|
|
145
|
+
"""
|
|
146
|
+
exception = context.exception
|
|
147
|
+
error_name = type(exception).__name__
|
|
148
|
+
error_message = str(exception)
|
|
149
|
+
|
|
150
|
+
# Check for cancellation
|
|
151
|
+
if isinstance(exception, asyncio.CancelledError):
|
|
152
|
+
raise AgentCancelledException() from exception
|
|
153
|
+
|
|
154
|
+
# Check for context size limit exceeded
|
|
155
|
+
if isinstance(exception, ContextSizeLimitExceeded):
|
|
156
|
+
# Already the right exception type, re-raise it
|
|
157
|
+
raise exception
|
|
158
|
+
|
|
159
|
+
# Check for budget exceeded (Shotgun Account only)
|
|
160
|
+
if (
|
|
161
|
+
context.is_shotgun_account
|
|
162
|
+
and "apistatuserror" in error_name.lower()
|
|
163
|
+
and "budget" in error_message.lower()
|
|
164
|
+
and "exceeded" in error_message.lower()
|
|
165
|
+
):
|
|
166
|
+
raise BudgetExceededException(message=error_message) from exception
|
|
167
|
+
|
|
168
|
+
# Check for empty model response (e.g., model unavailable or misconfigured)
|
|
169
|
+
if isinstance(exception, UnexpectedModelBehavior):
|
|
170
|
+
raise GenericAPIStatusException(
|
|
171
|
+
"The model returned an empty response. This may indicate:\n"
|
|
172
|
+
"- The model is unavailable or misconfigured\n"
|
|
173
|
+
"- A temporary service issue\n\n"
|
|
174
|
+
"Try switching to a different model or try again later."
|
|
175
|
+
) from exception
|
|
176
|
+
|
|
177
|
+
# Detect API errors
|
|
178
|
+
is_api_error = False
|
|
179
|
+
if isinstance(exception, OpenAIAPIStatusError):
|
|
180
|
+
is_api_error = True
|
|
181
|
+
elif isinstance(exception, AnthropicAPIStatusError):
|
|
182
|
+
is_api_error = True
|
|
183
|
+
elif isinstance(exception, ModelHTTPError):
|
|
184
|
+
# pydantic_ai wraps API errors in ModelHTTPError
|
|
185
|
+
# Check for HTTP error status codes (4xx client errors)
|
|
186
|
+
if 400 <= exception.status_code < 500:
|
|
187
|
+
is_api_error = True
|
|
188
|
+
|
|
189
|
+
# BYOK user API errors
|
|
190
|
+
if not context.is_shotgun_account and is_api_error:
|
|
191
|
+
self._raise_byok_api_error(error_message, exception)
|
|
192
|
+
|
|
193
|
+
# Shotgun Account specific errors
|
|
194
|
+
if "APIStatusError" in error_name:
|
|
195
|
+
if "overload" in error_message.lower():
|
|
196
|
+
raise ShotgunServiceOverloadException(error_message) from exception
|
|
197
|
+
elif "rate" in error_message.lower():
|
|
198
|
+
raise ShotgunRateLimitException(error_message) from exception
|
|
199
|
+
else:
|
|
200
|
+
raise GenericAPIStatusException(error_message) from exception
|
|
201
|
+
|
|
202
|
+
# Unknown error - wrap in our custom exception
|
|
203
|
+
raise UnknownAgentException(exception) from exception
|
|
204
|
+
|
|
205
|
+
def _raise_byok_api_error(
|
|
206
|
+
self, error_message: str, original_exception: Exception
|
|
207
|
+
) -> NoReturn:
|
|
208
|
+
"""Classify and raise API errors for BYOK users into specific types.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
error_message: The error message from the API
|
|
212
|
+
original_exception: The original exception
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
Specific BYOK exception type
|
|
216
|
+
"""
|
|
217
|
+
error_lower = error_message.lower()
|
|
218
|
+
|
|
219
|
+
if "rate" in error_lower:
|
|
220
|
+
raise BYOKRateLimitException(error_message) from original_exception
|
|
221
|
+
elif "quota" in error_lower or "billing" in error_lower:
|
|
222
|
+
raise BYOKQuotaBillingException(error_message) from original_exception
|
|
223
|
+
elif "authentication" in error_lower or (
|
|
224
|
+
"invalid" in error_lower and "key" in error_lower
|
|
225
|
+
):
|
|
226
|
+
raise BYOKAuthenticationException(error_message) from original_exception
|
|
227
|
+
elif "overload" in error_lower:
|
|
228
|
+
raise BYOKServiceOverloadException(error_message) from original_exception
|
|
229
|
+
else:
|
|
230
|
+
raise BYOKGenericAPIException(error_message) from original_exception
|
shotgun/agents/specify.py
CHANGED
|
@@ -23,7 +23,7 @@ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
|
|
|
23
23
|
logger = get_logger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def create_specify_agent(
|
|
26
|
+
async def create_specify_agent(
|
|
27
27
|
agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
|
|
28
28
|
) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
|
|
29
29
|
"""Create a specify agent with artifact management capabilities.
|
|
@@ -39,7 +39,7 @@ def create_specify_agent(
|
|
|
39
39
|
# Use partial to create system prompt function for specify agent
|
|
40
40
|
system_prompt_fn = partial(build_agent_system_prompt, "specify")
|
|
41
41
|
|
|
42
|
-
agent, deps = create_base_agent(
|
|
42
|
+
agent, deps = await create_base_agent(
|
|
43
43
|
system_prompt_fn,
|
|
44
44
|
agent_runtime_options,
|
|
45
45
|
load_codebase_understanding_tools=True,
|
shotgun/agents/tasks.py
CHANGED
|
@@ -23,7 +23,7 @@ from .models import AgentDeps, AgentResponse, AgentRuntimeOptions, AgentType
|
|
|
23
23
|
logger = get_logger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def create_tasks_agent(
|
|
26
|
+
async def create_tasks_agent(
|
|
27
27
|
agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
|
|
28
28
|
) -> tuple[Agent[AgentDeps, AgentResponse], AgentDeps]:
|
|
29
29
|
"""Create a tasks agent with file management capabilities.
|
|
@@ -39,7 +39,7 @@ def create_tasks_agent(
|
|
|
39
39
|
# Use partial to create system prompt function for tasks agent
|
|
40
40
|
system_prompt_fn = partial(build_agent_system_prompt, "tasks")
|
|
41
41
|
|
|
42
|
-
agent, deps = create_base_agent(
|
|
42
|
+
agent, deps = await create_base_agent(
|
|
43
43
|
system_prompt_fn,
|
|
44
44
|
agent_runtime_options,
|
|
45
45
|
provider=provider,
|
|
@@ -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:
|