shotgun-sh 0.2.6.dev4__py3-none-any.whl → 0.2.7__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 +272 -14
- shotgun/agents/common.py +42 -17
- shotgun/agents/config/manager.py +4 -0
- shotgun/agents/conversation_history.py +123 -2
- shotgun/agents/conversation_manager.py +24 -2
- shotgun/agents/history/context_extraction.py +93 -6
- shotgun/agents/tools/file_management.py +55 -9
- shotgun/build_constants.py +2 -2
- shotgun/prompts/agents/specify.j2 +270 -3
- shotgun/tui/screens/chat.py +54 -13
- shotgun/tui/screens/welcome.py +10 -2
- shotgun_sh-0.2.7.dist-info/METADATA +126 -0
- {shotgun_sh-0.2.6.dev4.dist-info → shotgun_sh-0.2.7.dist-info}/RECORD +16 -16
- shotgun_sh-0.2.6.dev4.dist-info/METADATA +0 -467
- {shotgun_sh-0.2.6.dev4.dist-info → shotgun_sh-0.2.7.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.2.6.dev4.dist-info → shotgun_sh-0.2.7.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.2.6.dev4.dist-info → shotgun_sh-0.2.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"""Context extraction utilities for history processing."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import traceback
|
|
6
|
+
|
|
3
7
|
from pydantic_ai.messages import (
|
|
4
8
|
BuiltinToolCallPart,
|
|
5
9
|
BuiltinToolReturnPart,
|
|
@@ -16,6 +20,46 @@ from pydantic_ai.messages import (
|
|
|
16
20
|
UserPromptPart,
|
|
17
21
|
)
|
|
18
22
|
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _safely_parse_tool_args(args: dict[str, object] | str | None) -> dict[str, object]:
|
|
27
|
+
"""Safely parse tool call arguments, handling incomplete/invalid JSON.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
args: Tool call arguments (dict, JSON string, or None)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Parsed args dict, or empty dict if parsing fails
|
|
34
|
+
"""
|
|
35
|
+
if args is None:
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
if isinstance(args, dict):
|
|
39
|
+
return args
|
|
40
|
+
|
|
41
|
+
if not isinstance(args, str):
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
parsed = json.loads(args)
|
|
46
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
47
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
48
|
+
# Only log warning if it looks like JSON (starts with { or [) - incomplete JSON
|
|
49
|
+
# Plain strings are valid args and shouldn't trigger warnings
|
|
50
|
+
stripped_args = args.strip()
|
|
51
|
+
if stripped_args.startswith(("{", "[")):
|
|
52
|
+
args_preview = args[:100] + "..." if len(args) > 100 else args
|
|
53
|
+
logger.warning(
|
|
54
|
+
"Detected incomplete/invalid JSON in tool call args during parsing",
|
|
55
|
+
extra={
|
|
56
|
+
"args_preview": args_preview,
|
|
57
|
+
"error": str(e),
|
|
58
|
+
"args_length": len(args),
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
return {}
|
|
62
|
+
|
|
19
63
|
|
|
20
64
|
def extract_context_from_messages(messages: list[ModelMessage]) -> str:
|
|
21
65
|
"""Extract context from a list of messages for summarization."""
|
|
@@ -87,12 +131,55 @@ def extract_context_from_part(
|
|
|
87
131
|
return f"<ASSISTANT_TEXT>\n{message_part.content}\n</ASSISTANT_TEXT>"
|
|
88
132
|
|
|
89
133
|
elif isinstance(message_part, ToolCallPart):
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
134
|
+
# Safely parse args to avoid crashes from incomplete JSON during streaming
|
|
135
|
+
try:
|
|
136
|
+
parsed_args = _safely_parse_tool_args(message_part.args)
|
|
137
|
+
if parsed_args:
|
|
138
|
+
# Successfully parsed as dict - format nicely
|
|
139
|
+
args_str = ", ".join(f"{k}={repr(v)}" for k, v in parsed_args.items())
|
|
140
|
+
tool_call_str = f"{message_part.tool_name}({args_str})"
|
|
141
|
+
elif isinstance(message_part.args, str) and message_part.args:
|
|
142
|
+
# Non-empty string that didn't parse as JSON
|
|
143
|
+
# Check if it looks like JSON (starts with { or [) - if so, it's incomplete
|
|
144
|
+
stripped_args = message_part.args.strip()
|
|
145
|
+
if stripped_args.startswith(("{", "[")):
|
|
146
|
+
# Looks like incomplete JSON - log warning and show empty parens
|
|
147
|
+
args_preview = (
|
|
148
|
+
stripped_args[:100] + "..."
|
|
149
|
+
if len(stripped_args) > 100
|
|
150
|
+
else stripped_args
|
|
151
|
+
)
|
|
152
|
+
stack_trace = "".join(traceback.format_stack())
|
|
153
|
+
logger.warning(
|
|
154
|
+
"ToolCallPart with unparseable args encountered during context extraction",
|
|
155
|
+
extra={
|
|
156
|
+
"tool_name": message_part.tool_name,
|
|
157
|
+
"tool_call_id": message_part.tool_call_id,
|
|
158
|
+
"args_preview": args_preview,
|
|
159
|
+
"args_type": type(message_part.args).__name__,
|
|
160
|
+
"stack_trace": stack_trace,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
tool_call_str = f"{message_part.tool_name}()"
|
|
164
|
+
else:
|
|
165
|
+
# Plain string arg - display as-is
|
|
166
|
+
tool_call_str = f"{message_part.tool_name}({message_part.args})"
|
|
167
|
+
else:
|
|
168
|
+
# No args
|
|
169
|
+
tool_call_str = f"{message_part.tool_name}()"
|
|
170
|
+
return f"<TOOL_CALL>\n{tool_call_str}\n</TOOL_CALL>"
|
|
171
|
+
except Exception as e: # pragma: no cover - defensive catch-all
|
|
172
|
+
# If anything goes wrong, log full exception with stack trace
|
|
173
|
+
logger.error(
|
|
174
|
+
"Unexpected error processing ToolCallPart",
|
|
175
|
+
exc_info=True,
|
|
176
|
+
extra={
|
|
177
|
+
"tool_name": message_part.tool_name,
|
|
178
|
+
"tool_call_id": message_part.tool_call_id,
|
|
179
|
+
"error": str(e),
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
return f"<TOOL_CALL>\n{message_part.tool_name}()\n</TOOL_CALL>"
|
|
96
183
|
|
|
97
184
|
elif isinstance(message_part, BuiltinToolCallPart):
|
|
98
185
|
return f"<BUILTIN_TOOL_CALL>\n{message_part.tool_name}\n</BUILTIN_TOOL_CALL>"
|
|
@@ -15,11 +15,18 @@ from shotgun.utils.file_system_utils import get_shotgun_base_path
|
|
|
15
15
|
logger = get_logger(__name__)
|
|
16
16
|
|
|
17
17
|
# Map agent modes to their allowed directories/files (in workflow order)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
# Values can be:
|
|
19
|
+
# - A Path: exact file (e.g., Path("research.md"))
|
|
20
|
+
# - A list of Paths: multiple allowed files/directories (e.g., [Path("specification.md"), Path("contracts")])
|
|
21
|
+
# - "*": any file except protected files (for export agent)
|
|
22
|
+
AGENT_DIRECTORIES: dict[AgentType, str | Path | list[Path]] = {
|
|
23
|
+
AgentType.RESEARCH: Path("research.md"),
|
|
24
|
+
AgentType.SPECIFY: [
|
|
25
|
+
Path("specification.md"),
|
|
26
|
+
Path("contracts"),
|
|
27
|
+
], # Specify can write specs and contract files
|
|
28
|
+
AgentType.PLAN: Path("plan.md"),
|
|
29
|
+
AgentType.TASKS: Path("tasks.md"),
|
|
23
30
|
AgentType.EXPORT: "*", # Export agent can write anywhere except protected files
|
|
24
31
|
}
|
|
25
32
|
|
|
@@ -60,13 +67,52 @@ def _validate_agent_scoped_path(filename: str, agent_mode: AgentType | None) ->
|
|
|
60
67
|
# Allow writing anywhere else in .shotgun directory
|
|
61
68
|
full_path = (base_path / filename).resolve()
|
|
62
69
|
else:
|
|
63
|
-
# For other agents,
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
# For other agents, check if they have access to the requested file
|
|
71
|
+
allowed_paths_raw = AGENT_DIRECTORIES[agent_mode]
|
|
72
|
+
|
|
73
|
+
# Convert single Path/string to list of Paths for uniform handling
|
|
74
|
+
if isinstance(allowed_paths_raw, str):
|
|
75
|
+
# Special case: "*" means export agent
|
|
76
|
+
allowed_paths = (
|
|
77
|
+
[Path(allowed_paths_raw)] if allowed_paths_raw != "*" else []
|
|
78
|
+
)
|
|
79
|
+
elif isinstance(allowed_paths_raw, Path):
|
|
80
|
+
allowed_paths = [allowed_paths_raw]
|
|
81
|
+
else:
|
|
82
|
+
# Already a list
|
|
83
|
+
allowed_paths = allowed_paths_raw
|
|
84
|
+
|
|
85
|
+
# Check if filename matches any allowed path
|
|
86
|
+
is_allowed = False
|
|
87
|
+
for allowed_path in allowed_paths:
|
|
88
|
+
allowed_str = str(allowed_path)
|
|
89
|
+
|
|
90
|
+
# Check if it's a directory (no .md extension or suffix)
|
|
91
|
+
# Directories: Path("contracts") has no suffix, files: Path("spec.md") has .md suffix
|
|
92
|
+
if not allowed_path.suffix or (
|
|
93
|
+
allowed_path.suffix and not allowed_str.endswith(".md")
|
|
94
|
+
):
|
|
95
|
+
# Directory - allow any file within this directory
|
|
96
|
+
# Check both "contracts/file.py" and "contracts" prefix
|
|
97
|
+
if (
|
|
98
|
+
filename.startswith(allowed_str + "/")
|
|
99
|
+
or filename == allowed_str
|
|
100
|
+
):
|
|
101
|
+
is_allowed = True
|
|
102
|
+
break
|
|
103
|
+
else:
|
|
104
|
+
# Exact file match
|
|
105
|
+
if filename == allowed_str:
|
|
106
|
+
is_allowed = True
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
if not is_allowed:
|
|
110
|
+
allowed_display = ", ".join(f"'{p}'" for p in allowed_paths)
|
|
66
111
|
raise ValueError(
|
|
67
|
-
f"{agent_mode.value.capitalize()} agent can only write to
|
|
112
|
+
f"{agent_mode.value.capitalize()} agent can only write to {allowed_display}. "
|
|
68
113
|
f"Attempted to write to '{filename}'"
|
|
69
114
|
)
|
|
115
|
+
|
|
70
116
|
full_path = (base_path / filename).resolve()
|
|
71
117
|
else:
|
|
72
118
|
# No agent mode specified, fall back to old validation
|
shotgun/build_constants.py
CHANGED
|
@@ -12,8 +12,8 @@ POSTHOG_API_KEY = ''
|
|
|
12
12
|
POSTHOG_PROJECT_ID = '191396'
|
|
13
13
|
|
|
14
14
|
# Logfire configuration embedded at build time (only for dev builds)
|
|
15
|
-
LOGFIRE_ENABLED = '
|
|
16
|
-
LOGFIRE_TOKEN = '
|
|
15
|
+
LOGFIRE_ENABLED = ''
|
|
16
|
+
LOGFIRE_TOKEN = ''
|
|
17
17
|
|
|
18
18
|
# Build metadata
|
|
19
19
|
BUILD_TIME_ENV = "production" if SENTRY_DSN else "development"
|
|
@@ -8,14 +8,281 @@ Transform requirements into detailed, actionable specifications that development
|
|
|
8
8
|
|
|
9
9
|
## MEMORY MANAGEMENT PROTOCOL
|
|
10
10
|
|
|
11
|
-
- You have exclusive write access to: `specification.md`
|
|
11
|
+
- You have exclusive write access to: `specification.md` and `.shotgun/contracts/*`
|
|
12
12
|
- SHOULD READ `research.md` for context but CANNOT write to it
|
|
13
|
-
-
|
|
13
|
+
- **specification.md is for PROSE ONLY** - no code, no implementation details, no type definitions
|
|
14
|
+
- **All code goes in .shotgun/contracts/** - types, interfaces, schemas
|
|
15
|
+
- specification.md describes WHAT and WHY, contracts/ show HOW with actual code
|
|
16
|
+
- This is your persistent memory store - ALWAYS load specification.md first
|
|
14
17
|
- Compress content regularly to stay within context limits
|
|
15
|
-
- Keep your
|
|
18
|
+
- Keep your files updated as you work - they're your memory across sessions
|
|
16
19
|
- When adding new specifications, review and consolidate overlapping requirements
|
|
17
20
|
- Structure specifications for easy reference by the next agents
|
|
18
21
|
|
|
22
|
+
## WHAT GOES IN SPECIFICATION.MD
|
|
23
|
+
|
|
24
|
+
specification.md is your prose documentation file. It should contain:
|
|
25
|
+
|
|
26
|
+
**INCLUDE in specification.md:**
|
|
27
|
+
- Requirements and business context (what needs to be built and why)
|
|
28
|
+
- Architecture overview and system design decisions
|
|
29
|
+
- Component descriptions and how they interact
|
|
30
|
+
- User workflows and use cases
|
|
31
|
+
- Directory structure as succinct prose (e.g., "src/ contains main code, tests/ contains test files")
|
|
32
|
+
- Dependencies listed in prose (e.g., "Requires TypeScript 5.0+, React 18, and PostgreSQL")
|
|
33
|
+
- Configuration requirements described (e.g., "App needs database URL and API key in environment")
|
|
34
|
+
- Testing strategies and acceptance criteria
|
|
35
|
+
- References to contract files (e.g., "See contracts/user_models.py for User type definition")
|
|
36
|
+
|
|
37
|
+
**DO NOT INCLUDE in specification.md:**
|
|
38
|
+
- Code blocks, type definitions, or function signatures (those go in contracts/)
|
|
39
|
+
- Implementation details or algorithms (describe behavior instead)
|
|
40
|
+
- Actual configuration files or build manifests (describe what's needed instead)
|
|
41
|
+
- Directory trees or file listings (keep structure descriptions succinct)
|
|
42
|
+
|
|
43
|
+
**When you need to show structure:** Reference contract files instead of inline code.
|
|
44
|
+
Example: "User authentication uses OAuth2. See contracts/auth_types.ts for AuthUser and AuthToken types."
|
|
45
|
+
|
|
46
|
+
## CONTRACT FILES
|
|
47
|
+
|
|
48
|
+
Contract files define the **interfaces and types** that form contracts between components.
|
|
49
|
+
They contain actual code that shows structure, not prose descriptions.
|
|
50
|
+
|
|
51
|
+
**ONLY put these in `.shotgun/contracts/` (language-agnostic):**
|
|
52
|
+
- **Type definitions ONLY** - Shape and structure, NO behavior or logic:
|
|
53
|
+
- Python: Pydantic models, dataclasses, `typing.Protocol` classes (interface definitions)
|
|
54
|
+
- TypeScript: interfaces, type aliases
|
|
55
|
+
- Rust: struct definitions
|
|
56
|
+
- Java: interfaces, POJOs
|
|
57
|
+
- C++: header files with class/struct declarations
|
|
58
|
+
- Go: interface types, struct definitions
|
|
59
|
+
- **Schema definitions**: API contracts and data schemas
|
|
60
|
+
- OpenAPI/Swagger specs (openapi.json, openapi.yaml)
|
|
61
|
+
- JSON Schema definitions
|
|
62
|
+
- GraphQL schemas
|
|
63
|
+
- Protobuf definitions
|
|
64
|
+
- **Protocol/Interface classes**: Pure interface definitions with method signatures only
|
|
65
|
+
- Python: `class Storage(Protocol): def save(self, data: str) -> None: ...`
|
|
66
|
+
- Use `...` (Ellipsis) for protocol methods, NOT `pass`
|
|
67
|
+
|
|
68
|
+
**NEVER put these in `.shotgun/contracts/` - NO EXECUTABLE CODE:**
|
|
69
|
+
- ❌ **Functions or methods with implementations** (even with `pass` or empty bodies)
|
|
70
|
+
- ❌ **Helper functions** with any logic whatsoever
|
|
71
|
+
- ❌ **Classes with method implementations** (use Protocol classes instead)
|
|
72
|
+
- ❌ **Standalone functions** like `def main(): pass` or `def validate_input(x): ...`
|
|
73
|
+
- ❌ **Code with behavior**: loops, conditionals, data manipulation, computations
|
|
74
|
+
- ❌ **Data constants**: dictionaries, lists, or any runtime values
|
|
75
|
+
- ❌ **`if __name__ == "__main__":` blocks** or any executable code
|
|
76
|
+
- Build/dependency configs (pyproject.toml, package.json, Cargo.toml, requirements.txt)
|
|
77
|
+
- Directory structure files (directory_structure.txt)
|
|
78
|
+
- Configuration templates (.env, config.yaml, example configs)
|
|
79
|
+
- Documentation or markdown files
|
|
80
|
+
- SQL migration files or database dumps
|
|
81
|
+
|
|
82
|
+
**These belong in specification.md instead:**
|
|
83
|
+
- Directory structure (as succinct prose: "src/ contains modules, tests/ has unit tests")
|
|
84
|
+
- Dependencies (as prose: "Requires Rust 1.70+, tokio, serde")
|
|
85
|
+
- Configuration needs (describe: "App needs DB_URL and API_KEY environment variables")
|
|
86
|
+
|
|
87
|
+
**Guidelines for contract files:**
|
|
88
|
+
- Keep each file focused on a single domain (e.g., user_types.ts, payment_models.py)
|
|
89
|
+
- Reference from specification.md: "See contracts/user_types.ts for User and Profile types"
|
|
90
|
+
- Use descriptive filenames: `auth_models.py`, `api_spec.json`, `database_types.rs`
|
|
91
|
+
- Keep files under 500 lines to avoid truncation
|
|
92
|
+
- When contracts grow large, split into focused files
|
|
93
|
+
|
|
94
|
+
**Example workflow:**
|
|
95
|
+
1. In specification.md: "Authentication system with JWT tokens. See contracts/auth_types.ts for types."
|
|
96
|
+
2. Create contract file: `write_file("contracts/auth_types.ts", content)` with actual TypeScript interfaces
|
|
97
|
+
3. Create contract file: `write_file("contracts/auth_api.json", content)` with actual OpenAPI spec
|
|
98
|
+
4. Coding agents can directly use these contracts to implement features
|
|
99
|
+
|
|
100
|
+
## HOW TO WRITE CONTRACT FILES
|
|
101
|
+
|
|
102
|
+
**CRITICAL - Always use correct file paths with write_file():**
|
|
103
|
+
|
|
104
|
+
Your working directory is `.shotgun/`, so paths should be relative to that directory.
|
|
105
|
+
|
|
106
|
+
<GOOD_EXAMPLES>
|
|
107
|
+
✅ `write_file("contracts/user_models.py", content)` - Correct path for Python models
|
|
108
|
+
✅ `write_file("contracts/auth_types.ts", content)` - Correct path for TypeScript types
|
|
109
|
+
✅ `write_file("contracts/api_spec.json", content)` - Correct path for OpenAPI spec
|
|
110
|
+
✅ `write_file("contracts/payment_service.rs", content)` - Correct path for Rust code
|
|
111
|
+
</GOOD_EXAMPLES>
|
|
112
|
+
|
|
113
|
+
<BAD_EXAMPLES>
|
|
114
|
+
❌ `write_file(".shotgun/contracts/user_models.py", content)` - WRONG! Don't include .shotgun/ prefix
|
|
115
|
+
❌ `write_file("contracts/directory_structure.txt", content)` - WRONG! No documentation files
|
|
116
|
+
❌ `write_file("contracts/pyproject.toml", content)` - WRONG! No build configs in contracts/
|
|
117
|
+
❌ `write_file("contracts/requirements.txt", content)` - WRONG! No dependency lists in contracts/
|
|
118
|
+
❌ `write_file("contracts/config.yaml", content)` - WRONG! No config templates in contracts/
|
|
119
|
+
</BAD_EXAMPLES>
|
|
120
|
+
|
|
121
|
+
**Path format rule:** Always use `contracts/filename.ext`, never `.shotgun/contracts/filename.ext`
|
|
122
|
+
|
|
123
|
+
**Language-specific examples:**
|
|
124
|
+
|
|
125
|
+
<PYTHON_EXAMPLE>
|
|
126
|
+
# Python Pydantic model contract
|
|
127
|
+
from pydantic import BaseModel, Field
|
|
128
|
+
from typing import Optional
|
|
129
|
+
|
|
130
|
+
class User(BaseModel):
|
|
131
|
+
"""User model contract."""
|
|
132
|
+
id: int
|
|
133
|
+
email: str = Field(..., description="User email address")
|
|
134
|
+
username: str
|
|
135
|
+
is_active: bool = True
|
|
136
|
+
role: Optional[str] = None
|
|
137
|
+
|
|
138
|
+
# Save as: write_file("contracts/user_models.py", content)
|
|
139
|
+
</PYTHON_EXAMPLE>
|
|
140
|
+
|
|
141
|
+
<TYPESCRIPT_EXAMPLE>
|
|
142
|
+
// TypeScript interface contract
|
|
143
|
+
interface User {
|
|
144
|
+
id: number;
|
|
145
|
+
email: string;
|
|
146
|
+
username: string;
|
|
147
|
+
isActive: boolean;
|
|
148
|
+
role?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface AuthToken {
|
|
152
|
+
token: string;
|
|
153
|
+
expiresAt: Date;
|
|
154
|
+
userId: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Save as: write_file("contracts/auth_types.ts", content)
|
|
158
|
+
</TYPESCRIPT_EXAMPLE>
|
|
159
|
+
|
|
160
|
+
<RUST_EXAMPLE>
|
|
161
|
+
// Rust struct contract
|
|
162
|
+
use serde::{Deserialize, Serialize};
|
|
163
|
+
|
|
164
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
165
|
+
pub struct User {
|
|
166
|
+
pub id: u64,
|
|
167
|
+
pub email: String,
|
|
168
|
+
pub username: String,
|
|
169
|
+
pub is_active: bool,
|
|
170
|
+
pub role: Option<String>,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Save as: write_file("contracts/user_types.rs", content)
|
|
174
|
+
</RUST_EXAMPLE>
|
|
175
|
+
|
|
176
|
+
<OPENAPI_EXAMPLE>
|
|
177
|
+
{
|
|
178
|
+
"openapi": "3.0.0",
|
|
179
|
+
"info": {
|
|
180
|
+
"title": "User API",
|
|
181
|
+
"version": "1.0.0"
|
|
182
|
+
},
|
|
183
|
+
"paths": {
|
|
184
|
+
"/users": {
|
|
185
|
+
"get": {
|
|
186
|
+
"summary": "List users",
|
|
187
|
+
"responses": {
|
|
188
|
+
"200": {
|
|
189
|
+
"description": "Successful response",
|
|
190
|
+
"content": {
|
|
191
|
+
"application/json": {
|
|
192
|
+
"schema": {
|
|
193
|
+
"type": "array",
|
|
194
|
+
"items": { "$ref": "#/components/schemas/User" }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
"components": {
|
|
204
|
+
"schemas": {
|
|
205
|
+
"User": {
|
|
206
|
+
"type": "object",
|
|
207
|
+
"properties": {
|
|
208
|
+
"id": { "type": "integer" },
|
|
209
|
+
"email": { "type": "string" },
|
|
210
|
+
"username": { "type": "string" }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Save as: write_file("contracts/user_api.json", content)
|
|
218
|
+
</OPENAPI_EXAMPLE>
|
|
219
|
+
|
|
220
|
+
## WHAT IS ALLOWED vs WHAT IS FORBIDDEN
|
|
221
|
+
|
|
222
|
+
**✅ ALLOWED - Type Definitions (Shape and Structure):**
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
# ✅ GOOD: Pydantic model (type definition)
|
|
226
|
+
from pydantic import BaseModel
|
|
227
|
+
|
|
228
|
+
class User(BaseModel):
|
|
229
|
+
id: int
|
|
230
|
+
email: str
|
|
231
|
+
username: str
|
|
232
|
+
|
|
233
|
+
# ✅ GOOD: Protocol class (interface definition)
|
|
234
|
+
from typing import Protocol
|
|
235
|
+
|
|
236
|
+
class Storage(Protocol):
|
|
237
|
+
def save(self, data: str) -> None: ...
|
|
238
|
+
def load(self) -> str: ...
|
|
239
|
+
|
|
240
|
+
# ✅ GOOD: Type aliases and enums
|
|
241
|
+
from typing import Literal
|
|
242
|
+
from enum import Enum
|
|
243
|
+
|
|
244
|
+
UserRole = Literal["admin", "user", "guest"]
|
|
245
|
+
|
|
246
|
+
class Status(Enum):
|
|
247
|
+
ACTIVE = "active"
|
|
248
|
+
INACTIVE = "inactive"
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**❌ FORBIDDEN - Executable Code (Behavior and Logic):**
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
# ❌ BAD: Function with pass (executable code)
|
|
255
|
+
def main() -> int:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
# ❌ BAD: Function with implementation
|
|
259
|
+
def validate_input(x: str) -> str:
|
|
260
|
+
return x.strip()
|
|
261
|
+
|
|
262
|
+
# ❌ BAD: Class with method implementations
|
|
263
|
+
class HistoryManager:
|
|
264
|
+
def __init__(self):
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
def add_message(self, msg: str):
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
# ❌ BAD: Data constants (runtime values)
|
|
271
|
+
SUPPORTED_PROVIDERS = [
|
|
272
|
+
{"name": "openai", "key": "OPENAI_API_KEY"}
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
# ❌ BAD: Helper functions
|
|
276
|
+
def get_default_config() -> dict:
|
|
277
|
+
return {"model": "gpt-4"}
|
|
278
|
+
|
|
279
|
+
# ❌ BAD: Executable code blocks
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
main()
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**Remember**: Contracts define **SHAPES** (types, interfaces, schemas), NOT **BEHAVIOR** (functions, logic, implementations).
|
|
285
|
+
|
|
19
286
|
## AI AGENT PIPELINE AWARENESS
|
|
20
287
|
|
|
21
288
|
**CRITICAL**: Your output will be consumed by AI coding agents (Claude Code, Cursor, Windsurf, etc.)
|
shotgun/tui/screens/chat.py
CHANGED
|
@@ -881,6 +881,30 @@ class ChatScreen(Screen[None]):
|
|
|
881
881
|
except asyncio.CancelledError:
|
|
882
882
|
# Handle cancellation gracefully - DO NOT re-raise
|
|
883
883
|
self.mount_hint("⚠️ Operation cancelled by user")
|
|
884
|
+
except Exception as e:
|
|
885
|
+
# Log with full stack trace to shotgun.log
|
|
886
|
+
logger.exception(
|
|
887
|
+
"Agent run failed",
|
|
888
|
+
extra={
|
|
889
|
+
"agent_mode": self.mode.value,
|
|
890
|
+
"error_type": type(e).__name__,
|
|
891
|
+
},
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
# Determine user-friendly message based on error type
|
|
895
|
+
error_name = type(e).__name__
|
|
896
|
+
error_message = str(e)
|
|
897
|
+
|
|
898
|
+
if "APIStatusError" in error_name and "overload" in error_message.lower():
|
|
899
|
+
hint = "⚠️ The AI service is temporarily overloaded. Please wait a moment and try again."
|
|
900
|
+
elif "APIStatusError" in error_name and "rate" in error_message.lower():
|
|
901
|
+
hint = "⚠️ Rate limit reached. Please wait before trying again."
|
|
902
|
+
elif "APIStatusError" in error_name:
|
|
903
|
+
hint = f"⚠️ AI service error: {error_message}"
|
|
904
|
+
else:
|
|
905
|
+
hint = f"⚠️ An error occurred: {error_message}\n\nCheck logs at ~/.shotgun-sh/logs/shotgun.log"
|
|
906
|
+
|
|
907
|
+
self.mount_hint(hint)
|
|
884
908
|
finally:
|
|
885
909
|
self.working = False
|
|
886
910
|
self._current_worker = None
|
|
@@ -910,24 +934,41 @@ class ChatScreen(Screen[None]):
|
|
|
910
934
|
"""Load conversation from persistent storage."""
|
|
911
935
|
conversation = self.conversation_manager.load()
|
|
912
936
|
if conversation is None:
|
|
937
|
+
# Check if file existed but was corrupted (backup was created)
|
|
938
|
+
backup_path = self.conversation_manager.conversation_path.with_suffix(
|
|
939
|
+
".json.backup"
|
|
940
|
+
)
|
|
941
|
+
if backup_path.exists():
|
|
942
|
+
# File was corrupted - show friendly notification
|
|
943
|
+
self.mount_hint(
|
|
944
|
+
"⚠️ Previous session was corrupted and has been backed up. Starting fresh conversation."
|
|
945
|
+
)
|
|
913
946
|
return
|
|
914
947
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
948
|
+
try:
|
|
949
|
+
# Restore agent state
|
|
950
|
+
agent_messages = conversation.get_agent_messages()
|
|
951
|
+
ui_messages = conversation.get_ui_messages()
|
|
952
|
+
|
|
953
|
+
# Create ConversationState for restoration
|
|
954
|
+
state = ConversationState(
|
|
955
|
+
agent_messages=agent_messages,
|
|
956
|
+
ui_messages=ui_messages,
|
|
957
|
+
agent_type=conversation.last_agent_model,
|
|
958
|
+
)
|
|
918
959
|
|
|
919
|
-
|
|
920
|
-
state = ConversationState(
|
|
921
|
-
agent_messages=agent_messages,
|
|
922
|
-
ui_messages=ui_messages,
|
|
923
|
-
agent_type=conversation.last_agent_model,
|
|
924
|
-
)
|
|
960
|
+
self.agent_manager.restore_conversation_state(state)
|
|
925
961
|
|
|
926
|
-
|
|
962
|
+
# Update the current mode
|
|
963
|
+
self.mode = AgentType(conversation.last_agent_model)
|
|
964
|
+
self.deps.usage_manager.restore_usage_state()
|
|
927
965
|
|
|
928
|
-
#
|
|
929
|
-
|
|
930
|
-
|
|
966
|
+
except Exception as e: # pragma: no cover
|
|
967
|
+
# If anything goes wrong during restoration, log it and continue
|
|
968
|
+
logger.error("Failed to restore conversation state: %s", e)
|
|
969
|
+
self.mount_hint(
|
|
970
|
+
"⚠️ Could not restore previous session. Starting fresh conversation."
|
|
971
|
+
)
|
|
931
972
|
|
|
932
973
|
|
|
933
974
|
def help_text_with_codebase(already_indexed: bool = False) -> str:
|
shotgun/tui/screens/welcome.py
CHANGED
|
@@ -152,6 +152,10 @@ class WelcomeScreen(Screen[None]):
|
|
|
152
152
|
@on(Button.Pressed, "#byok-button")
|
|
153
153
|
def _on_byok_pressed(self) -> None:
|
|
154
154
|
"""Handle BYOK button press."""
|
|
155
|
+
self.run_worker(self._start_byok_config(), exclusive=True)
|
|
156
|
+
|
|
157
|
+
async def _start_byok_config(self) -> None:
|
|
158
|
+
"""Launch BYOK provider configuration flow."""
|
|
155
159
|
self._mark_welcome_shown()
|
|
156
160
|
|
|
157
161
|
app = cast("ShotgunApp", self.app)
|
|
@@ -161,10 +165,14 @@ class WelcomeScreen(Screen[None]):
|
|
|
161
165
|
self.dismiss()
|
|
162
166
|
return
|
|
163
167
|
|
|
164
|
-
# Otherwise, push provider config screen
|
|
168
|
+
# Otherwise, push provider config screen and wait for result
|
|
165
169
|
from .provider_config import ProviderConfigScreen
|
|
166
170
|
|
|
167
|
-
self.app.
|
|
171
|
+
await self.app.push_screen_wait(ProviderConfigScreen())
|
|
172
|
+
|
|
173
|
+
# Dismiss welcome screen after config if providers are now configured
|
|
174
|
+
if app.config_manager.has_any_provider_key():
|
|
175
|
+
self.dismiss()
|
|
168
176
|
|
|
169
177
|
async def _start_shotgun_auth(self) -> None:
|
|
170
178
|
"""Launch Shotgun Account authentication flow."""
|