├── src └── shotgun │ ├── py.typed │ ├── tui │ ├── __init__.py │ ├── screens │ │ ├── chat_screen │ │ │ ├── __init__.py │ │ │ └── history │ │ │ │ ├── __init__.py │ │ │ │ ├── user_question.py │ │ │ │ ├── partial_response.py │ │ │ │ └── agent_response.py │ │ ├── chat │ │ │ ├── __init__.py │ │ │ ├── codebase_index_selection.py │ │ │ ├── chat.tcss │ │ │ ├── prompt_history.py │ │ │ └── help_text.py │ │ ├── shared_specs │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── chat.tcss │ │ └── splash.py │ ├── styles.tcss │ ├── state │ │ └── __init__.py │ ├── utils │ │ └── __init__.py │ ├── services │ │ └── __init__.py │ ├── layout.py │ ├── widgets │ │ └── __init__.py │ ├── components │ │ ├── vertical_tail.py │ │ ├── splash.py │ │ ├── status_bar.py │ │ ├── prompt_input.py │ │ └── spinner.py │ ├── filtered_codebase_service.py │ ├── protocols.py │ └── commands │ │ └── __init__.py │ ├── agents │ ├── __init__.py │ ├── conversation │ │ ├── history │ │ │ ├── __init__.py │ │ │ ├── constants.py │ │ │ ├── token_counting │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ └── openai.py │ │ │ └── message_utils.py │ │ └── __init__.py │ ├── error │ │ ├── __init__.py │ │ └── models.py │ ├── context_analyzer │ │ ├── constants.py │ │ └── __init__.py │ ├── router │ │ ├── tools │ │ │ └── __init__.py │ │ └── __init__.py │ ├── config │ │ ├── __init__.py │ │ └── constants.py │ ├── tools │ │ ├── web_search │ │ │ └── utils.py │ │ ├── codebase │ │ │ ├── __init__.py │ │ │ └── query_graph.py │ │ └── __init__.py │ ├── messages.py │ └── llm.py │ ├── cli │ ├── __init__.py │ ├── spec │ │ ├── __init__.py │ │ ├── models.py │ │ └── backup.py │ ├── codebase │ │ ├── __init__.py │ │ └── models.py │ ├── models.py │ ├── error_handler.py │ ├── utils.py │ ├── feedback.py │ ├── clear.py │ ├── specify.py │ ├── plan.py │ ├── tasks.py │ └── export.py │ ├── prompts │ ├── agents │ │ ├── __init__.py │ │ ├── state │ │ │ ├── codebase │ │ │ │ └── codebase_graphs_available.j2 │ │ │ └── system_state.j2 │ │ └── partials │ │ │ ├── interactive_mode.j2 │ │ │ ├── router_delegation_mode.j2 │ │ │ ├── content_formatting.j2 │ │ │ └── common_agent_system_prompt.j2 │ ├── codebase │ │ ├── __init__.py │ │ ├── enhanced_query_context.j2 │ │ ├── partials │ │ │ ├── temporal_context.j2 │ │ │ └── graph_schema.j2 │ │ └── cypher_system.j2 │ ├── history │ │ ├── __init__.py │ │ ├── chunk_summarization.j2 │ │ ├── combine_summaries.j2 │ │ ├── summarization.j2 │ │ └── incremental_summarization.j2 │ ├── __init__.py │ └── tools │ │ └── web_search.j2 │ ├── __init__.py │ ├── utils │ ├── __init__.py │ ├── source_detection.py │ ├── env_utils.py │ ├── file_system_utils.py │ └── datetime_utils.py │ ├── codebase │ ├── __init__.py │ └── core │ │ ├── __init__.py │ │ └── cypher_models.py │ ├── sdk │ ├── __init__.py │ ├── exceptions.py │ └── services.py │ ├── llm_proxy │ ├── constants.py │ ├── __init__.py │ └── clients.py │ ├── api_endpoints.py │ ├── shotgun_web │ ├── exceptions.py │ ├── shared_specs │ │ ├── utils.py │ │ ├── __init__.py │ │ ├── models.py │ │ └── hasher.py │ ├── supabase_client.py │ ├── __init__.py │ └── constants.py │ └── telemetry.py ├── test ├── unit │ ├── codebase │ │ ├── tools │ │ │ ├── __init__.py │ │ │ ├── test_query_graph.py │ │ │ ├── test_directory_lister.py │ │ │ ├── conftest.py │ │ │ ├── test_file_read.py │ │ │ └── test_retrieve_code.py │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_parser_loader.py │ ├── cli │ │ ├── __init__.py │ │ └── spec │ │ │ ├── __init__.py │ │ │ └── test_models.py │ ├── __init__.py │ ├── agents │ │ ├── history │ │ │ └── __init__.py │ │ ├── router │ │ │ └── __init__.py │ │ ├── config │ │ │ ├── __init__.py │ │ │ └── test_models.py │ │ └── test_research_system_prompt.py │ ├── shotgun_web │ │ ├── __init__.py │ │ ├── shared_specs │ │ │ ├── __init__.py │ │ │ └── conftest.py │ │ └── test_supabase_client.py │ ├── tui │ │ ├── components │ │ │ └── __init__.py │ │ ├── state │ │ │ └── __init__.py │ │ ├── widgets │ │ │ └── __init__.py │ │ ├── screens │ │ │ └── shared_specs │ │ │ │ └── __init__.py │ │ ├── test_provider_config.py │ │ └── test_chat_screen_byok_hints.py │ ├── exceptions │ │ ├── __init__.py │ │ └── test_context_size_exception.py │ └── llm_proxy │ │ └── __init__.py └── integration │ ├── __init__.py │ ├── sdk │ ├── __init__.py │ └── test_sdk_services.py │ ├── codebase │ ├── __init__.py │ └── tools │ │ ├── __init__.py │ │ └── test_query_graph.py │ ├── tools │ └── web_search │ │ └── __init__.py │ └── pytest.ini ├── .github ├── trufflehog-include-lockfile.txt ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ ├── bug_report.md │ └── documentation_request.md ├── CODEOWNERS └── dependabot.yml ├── docs ├── shotgun_logo.png ├── index_codebase_privacy.png └── README_DOCKER.md ├── SECURITY ├── evals ├── reporters │ └── __init__.py ├── suites │ └── __init__.py ├── datasets │ ├── __init__.py │ └── router_agent │ │ ├── planning_cases.py │ │ ├── __init__.py │ │ └── clarifying_questions_cases.py ├── judges │ └── __init__.py ├── aggregators │ └── __init__.py └── evaluators │ ├── deterministic │ └── __init__.py │ └── __init__.py ├── .vscode └── settings.json ├── lefthook.yml ├── LICENSE ├── .dockerignore ├── .env.example └── Dockerfile /src/shotgun/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shotgun/tui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit/codebase/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat_screen/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/unit/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """CLI unit tests.""" 2 | -------------------------------------------------------------------------------- /.github/trufflehog-include-lockfile.txt: -------------------------------------------------------------------------------- 1 | ^uv\.lock$ 2 | -------------------------------------------------------------------------------- /src/shotgun/agents/__init__.py: -------------------------------------------------------------------------------- 1 | """Shotgun AI Agents.""" 2 | -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for Shotgun components.""" 2 | -------------------------------------------------------------------------------- /src/shotgun/cli/__init__.py: -------------------------------------------------------------------------------- 1 | """Commands package for shotgun CLI.""" 2 | -------------------------------------------------------------------------------- /test/unit/agents/history/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for history module.""" 2 | -------------------------------------------------------------------------------- /test/unit/agents/router/__init__.py: -------------------------------------------------------------------------------- 1 | """Router agent unit tests.""" 2 | -------------------------------------------------------------------------------- /test/unit/cli/spec/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for CLI spec commands.""" 2 | -------------------------------------------------------------------------------- /test/unit/shotgun_web/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for shotgun_web module.""" 2 | -------------------------------------------------------------------------------- /test/unit/tui/components/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for TUI components.""" 2 | -------------------------------------------------------------------------------- /test/unit/tui/state/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for TUI state management.""" 2 | -------------------------------------------------------------------------------- /test/unit/tui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for TUI widgets.""" 2 | -------------------------------------------------------------------------------- /test/unit/agents/config/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for agents config module.""" 2 | -------------------------------------------------------------------------------- /test/unit/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for exceptions module.""" 2 | -------------------------------------------------------------------------------- /test/unit/llm_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for LiteLLM proxy client.""" 2 | -------------------------------------------------------------------------------- /src/shotgun/prompts/agents/__init__.py: -------------------------------------------------------------------------------- 1 | """Agent-specific prompt templates.""" 2 | -------------------------------------------------------------------------------- /test/integration/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests for Shotgun components.""" 2 | -------------------------------------------------------------------------------- /src/shotgun/prompts/codebase/__init__.py: -------------------------------------------------------------------------------- 1 | """Codebase analysis prompt templates.""" 2 | -------------------------------------------------------------------------------- /src/shotgun/prompts/history/__init__.py: -------------------------------------------------------------------------------- 1 | """History processing prompt templates.""" 2 | -------------------------------------------------------------------------------- /test/integration/sdk/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests for shotgun SDK module.""" 2 | -------------------------------------------------------------------------------- /test/unit/codebase/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for codebase understanding functionality.""" 2 | -------------------------------------------------------------------------------- /test/unit/shotgun_web/shared_specs/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for shared_specs module.""" 2 | -------------------------------------------------------------------------------- /test/unit/tui/screens/shared_specs/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for shared specs TUI screens.""" 2 | -------------------------------------------------------------------------------- /docs/shotgun_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shotgun-sh/shotgun/HEAD/docs/shotgun_logo.png -------------------------------------------------------------------------------- /test/integration/codebase/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests for shotgun codebase module.""" 2 | -------------------------------------------------------------------------------- /test/integration/tools/web_search/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests for web search tools.""" 2 | -------------------------------------------------------------------------------- /test/integration/codebase/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Integration tests for codebase understanding tools.""" 2 | -------------------------------------------------------------------------------- /docs/index_codebase_privacy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shotgun-sh/shotgun/HEAD/docs/index_codebase_privacy.png -------------------------------------------------------------------------------- /src/shotgun/cli/spec/__init__.py: -------------------------------------------------------------------------------- 1 | """Spec CLI module.""" 2 | 3 | from .commands import app 4 | 5 | __all__ = ["app"] 6 | -------------------------------------------------------------------------------- /src/shotgun/cli/codebase/__init__.py: -------------------------------------------------------------------------------- 1 | """Codebase CLI module.""" 2 | 3 | from .commands import app 4 | 5 | __all__ = ["app"] 6 | -------------------------------------------------------------------------------- /src/shotgun/__init__.py: -------------------------------------------------------------------------------- 1 | """Shotgun CLI package.""" 2 | 3 | from importlib.metadata import version 4 | 5 | __version__ = version("shotgun-sh") 6 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat/__init__.py: -------------------------------------------------------------------------------- 1 | """Chat screen module.""" 2 | 3 | from shotgun.tui.screens.chat.chat_screen import ChatScreen 4 | 5 | __all__ = ["ChatScreen"] 6 | -------------------------------------------------------------------------------- /src/shotgun/prompts/__init__.py: -------------------------------------------------------------------------------- 1 | """Jinja2 template-based prompt management for Shotgun LLM agents.""" 2 | 3 | from .loader import PromptLoader 4 | 5 | __all__ = ["PromptLoader"] 6 | -------------------------------------------------------------------------------- /src/shotgun/tui/styles.tcss: -------------------------------------------------------------------------------- 1 | Screen { 2 | background: $surface; 3 | } 4 | 5 | Input { 6 | border: round $border-blurred; 7 | &:focus { 8 | border: round $border; 9 | } 10 | } -------------------------------------------------------------------------------- /src/shotgun/tui/state/__init__.py: -------------------------------------------------------------------------------- 1 | """State management utilities for TUI.""" 2 | 3 | from .processing_state import ProcessingStateManager 4 | 5 | __all__ = [ 6 | "ProcessingStateManager", 7 | ] 8 | -------------------------------------------------------------------------------- /src/shotgun/tui/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """TUI utilities package.""" 2 | 3 | from .mode_progress import ModeProgressChecker, PlaceholderHints 4 | 5 | __all__ = ["ModeProgressChecker", "PlaceholderHints"] 6 | -------------------------------------------------------------------------------- /src/shotgun/tui/services/__init__.py: -------------------------------------------------------------------------------- 1 | """Services for TUI business logic.""" 2 | 3 | from shotgun.tui.services.conversation_service import ConversationService 4 | 5 | __all__ = ["ConversationService"] 6 | -------------------------------------------------------------------------------- /src/shotgun/agents/conversation/history/__init__.py: -------------------------------------------------------------------------------- 1 | """History management utilities for Shotgun agents.""" 2 | 3 | from .history_processors import token_limit_compactor 4 | 5 | __all__ = ["token_limit_compactor"] 6 | -------------------------------------------------------------------------------- /src/shotgun/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utility functions for the Shotgun package.""" 2 | 3 | from .file_system_utils import ensure_shotgun_directory_exists, get_shotgun_home 4 | 5 | __all__ = ["ensure_shotgun_directory_exists", "get_shotgun_home"] 6 | -------------------------------------------------------------------------------- /src/shotgun/cli/models.py: -------------------------------------------------------------------------------- 1 | """Common models for CLI commands.""" 2 | 3 | from enum import StrEnum 4 | 5 | 6 | class OutputFormat(StrEnum): 7 | """Output format options for CLI commands.""" 8 | 9 | TEXT = "text" 10 | JSON = "json" 11 | MARKDOWN = "markdown" 12 | -------------------------------------------------------------------------------- /src/shotgun/tui/layout.py: -------------------------------------------------------------------------------- 1 | """Layout utilities for responsive terminal UI.""" 2 | 3 | # Height thresholds for responsive layouts 4 | TINY_HEIGHT_THRESHOLD = 25 # Below this: minimal UI, hide most content 5 | COMPACT_HEIGHT_THRESHOLD = 35 # Below this: reduced padding, hide verbose text 6 | -------------------------------------------------------------------------------- /src/shotgun/tui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | """Widget utilities and coordinators for TUI.""" 2 | 3 | from shotgun.tui.widgets.plan_panel import PlanPanelWidget 4 | from shotgun.tui.widgets.widget_coordinator import WidgetCoordinator 5 | 6 | __all__ = ["PlanPanelWidget", "WidgetCoordinator"] 7 | -------------------------------------------------------------------------------- /SECURITY: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | **Do not open public issues for security vulnerabilities.** 6 | 7 | Email contact@shotgun.sh with: 8 | - Description of the vulnerability 9 | - Steps to reproduce 10 | - Potential impact 11 | 12 | We'll respond within 48 hours. 13 | -------------------------------------------------------------------------------- /src/shotgun/agents/error/__init__.py: -------------------------------------------------------------------------------- 1 | """Agent error handling module. 2 | 3 | This module provides the AgentErrorContext model used by AgentRunner 4 | for error classification. 5 | """ 6 | 7 | from shotgun.agents.error.models import AgentErrorContext 8 | 9 | __all__ = [ 10 | "AgentErrorContext", 11 | ] 12 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat/codebase_index_selection.py: -------------------------------------------------------------------------------- 1 | """Codebase indexing selection models.""" 2 | 3 | from pathlib import Path 4 | 5 | from pydantic import BaseModel 6 | 7 | 8 | class CodebaseIndexSelection(BaseModel): 9 | """User-selected repository path and name for indexing.""" 10 | 11 | repo_path: Path 12 | name: str 13 | -------------------------------------------------------------------------------- /evals/reporters/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Report formatters for evaluation results. 3 | 4 | Provides console and JSON output formats for evaluation reports. 5 | """ 6 | 7 | from evals.reporters.console import ConsoleReporter 8 | from evals.reporters.json_reporter import JSONReporter 9 | 10 | __all__ = [ 11 | "ConsoleReporter", 12 | "JSONReporter", 13 | ] 14 | -------------------------------------------------------------------------------- /evals/suites/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Evaluation suites for Shotgun agents. 3 | 4 | Organized by agent type: 5 | - router_suites: Router delegation and workflow test suites 6 | """ 7 | 8 | from evals.suites.router_suites import ROUTER_SUITES, router_core, router_smoke 9 | 10 | __all__ = [ 11 | "ROUTER_SUITES", 12 | "router_smoke", 13 | "router_core", 14 | ] 15 | -------------------------------------------------------------------------------- /src/shotgun/codebase/__init__.py: -------------------------------------------------------------------------------- 1 | """Shotgun codebase analysis and graph management.""" 2 | 3 | from shotgun.codebase.models import CodebaseGraph, GraphStatus, QueryResult, QueryType 4 | from shotgun.codebase.service import CodebaseService 5 | 6 | __all__ = [ 7 | "CodebaseService", 8 | "CodebaseGraph", 9 | "GraphStatus", 10 | "QueryResult", 11 | "QueryType", 12 | ] 13 | -------------------------------------------------------------------------------- /src/shotgun/agents/context_analyzer/constants.py: -------------------------------------------------------------------------------- 1 | """Tool category registry for context analysis. 2 | 3 | This module re-exports the tool registry functionality for backward compatibility. 4 | The actual implementation is in shotgun.agents.tools.registry. 5 | """ 6 | 7 | from shotgun.agents.tools.registry import ToolCategory, get_tool_category 8 | 9 | __all__ = ["ToolCategory", "get_tool_category"] 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions & Support 4 | url: https://github.com/shotgun-sh/shotgun/discussions 5 | about: Ask questions and discuss Shotgun with the community 6 | - name: General Discussion 7 | url: https://github.com/shotgun-sh/shotgun/discussions/categories/general 8 | about: General discussion about Shotgun and its features 9 | -------------------------------------------------------------------------------- /src/shotgun/sdk/__init__.py: -------------------------------------------------------------------------------- 1 | """Shotgun SDK - Framework-agnostic business logic for CLI and TUI.""" 2 | 3 | from .codebase import CodebaseSDK 4 | from .exceptions import CodebaseNotFoundError, CodebaseOperationError, ShotgunSDKError 5 | from .services import get_codebase_service 6 | 7 | __all__ = [ 8 | "CodebaseSDK", 9 | "ShotgunSDKError", 10 | "CodebaseNotFoundError", 11 | "CodebaseOperationError", 12 | "get_codebase_service", 13 | ] 14 | -------------------------------------------------------------------------------- /src/shotgun/llm_proxy/constants.py: -------------------------------------------------------------------------------- 1 | """LiteLLM proxy constants and configuration.""" 2 | 3 | # Import from centralized API endpoints module 4 | from shotgun.api_endpoints import ( 5 | LITELLM_PROXY_ANTHROPIC_BASE, 6 | LITELLM_PROXY_BASE_URL, 7 | LITELLM_PROXY_OPENAI_BASE, 8 | ) 9 | 10 | # Re-export for backward compatibility 11 | __all__ = [ 12 | "LITELLM_PROXY_BASE_URL", 13 | "LITELLM_PROXY_ANTHROPIC_BASE", 14 | "LITELLM_PROXY_OPENAI_BASE", 15 | ] 16 | -------------------------------------------------------------------------------- /src/shotgun/agents/router/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Router tools package.""" 2 | 3 | from shotgun.agents.router.tools.plan_tools import ( 4 | add_step, 5 | create_plan, 6 | mark_step_done, 7 | remove_step, 8 | ) 9 | 10 | # Note: Delegation tools are imported directly in router.py to use 11 | # the prepare_delegation_tool function for conditional visibility. 12 | 13 | __all__ = [ 14 | "add_step", 15 | "create_plan", 16 | "mark_step_done", 17 | "remove_step", 18 | ] 19 | -------------------------------------------------------------------------------- /src/shotgun/cli/codebase/models.py: -------------------------------------------------------------------------------- 1 | """Re-export SDK models for backward compatibility.""" 2 | 3 | from shotgun.sdk.models import ( 4 | DeleteResult, 5 | ErrorResult, 6 | IndexResult, 7 | InfoResult, 8 | ListResult, 9 | QueryCommandResult, 10 | ReindexResult, 11 | ) 12 | 13 | __all__ = [ 14 | "ListResult", 15 | "IndexResult", 16 | "DeleteResult", 17 | "InfoResult", 18 | "QueryCommandResult", 19 | "ReindexResult", 20 | "ErrorResult", 21 | ] 22 | -------------------------------------------------------------------------------- /src/shotgun/sdk/exceptions.py: -------------------------------------------------------------------------------- 1 | """SDK-specific exceptions.""" 2 | 3 | 4 | class ShotgunSDKError(Exception): 5 | """Base exception for all SDK operations.""" 6 | 7 | 8 | class CodebaseNotFoundError(ShotgunSDKError): 9 | """Raised when a codebase or graph is not found.""" 10 | 11 | 12 | class CodebaseOperationError(ShotgunSDKError): 13 | """Raised when a codebase operation fails.""" 14 | 15 | 16 | class InvalidPathError(ShotgunSDKError): 17 | """Raised when a provided path is invalid.""" 18 | -------------------------------------------------------------------------------- /test/unit/codebase/conftest.py: -------------------------------------------------------------------------------- 1 | """Configuration and fixtures for codebase unit tests. 2 | 3 | This file now only contains codebase-specific fixtures that are not 4 | shared across different test types. The shared Kuzu cleanup fixtures 5 | are now provided by the root test/conftest.py file. 6 | """ 7 | 8 | # All Kuzu helper fixtures (cleanup_before_tests, cleanup_kuzu_state, 9 | # unique_graph_id, temp_storage_path) are now provided by the shared 10 | # test/conftest.py file and will be automatically available to these tests. 11 | -------------------------------------------------------------------------------- /evals/datasets/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test case datasets for Shotgun agent evaluation. 3 | 4 | Datasets are organized by agent type: 5 | datasets/ 6 | ├── router_agent/ # Router clarifying questions tests 7 | ├── research_agent/ # Research capability tests (future) 8 | ├── specify_agent/ # Specification generation tests (future) 9 | └── ... 10 | """ 11 | 12 | from evals.datasets.router_agent import ALL_ROUTER_CASES, CLARIFYING_QUESTIONS_CASES 13 | 14 | __all__ = ["CLARIFYING_QUESTIONS_CASES", "ALL_ROUTER_CASES"] 15 | -------------------------------------------------------------------------------- /src/shotgun/utils/source_detection.py: -------------------------------------------------------------------------------- 1 | """Utility for detecting the source of function calls (CLI vs TUI).""" 2 | 3 | import inspect 4 | 5 | 6 | def detect_source() -> str: 7 | """Detect if the call originated from CLI or TUI by inspecting the call stack. 8 | 9 | Returns: 10 | "tui" if any frame in the call stack contains "tui" in the filename, 11 | "cli" otherwise. 12 | """ 13 | for frame_info in inspect.stack(): 14 | if "tui" in frame_info.filename.lower(): 15 | return "tui" 16 | return "cli" 17 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS 2 | # This file defines code ownership for the Shotgun repository. 3 | # 4 | # Each line is a file pattern followed by one or more owners. 5 | # When a pull request is opened, the specified owners will be 6 | # automatically requested for review. 7 | # 8 | # For more information about CODEOWNERS, see: 9 | # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 10 | 11 | # Global owner - all files require review from @scottfrasso 12 | * @scottfrasso 13 | -------------------------------------------------------------------------------- /evals/judges/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | LLM judge implementations for Shotgun agent evaluation. 3 | 4 | Judges use LLM-as-a-judge to evaluate agent outputs against rubrics. 5 | """ 6 | 7 | from evals.judges.router_quality_judge import RouterQualityJudge 8 | from evals.models import ( 9 | DimensionScoreOutput, 10 | RouterDimension, 11 | RouterDimensionRubric, 12 | RouterJudgeResult, 13 | ) 14 | 15 | __all__ = [ 16 | "RouterQualityJudge", 17 | "RouterJudgeResult", 18 | "RouterDimensionRubric", 19 | "RouterDimension", 20 | "DimensionScoreOutput", 21 | ] 22 | -------------------------------------------------------------------------------- /evals/aggregators/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Aggregators for combining evaluation results from multiple sources. 3 | 4 | Aggregators combine: 5 | - Deterministic evaluator results 6 | - LLM judge results 7 | - Per-dimension scores 8 | 9 | Into a final score and pass/fail determination. 10 | """ 11 | 12 | from evals.aggregators.router_aggregator import RouterAggregator 13 | from evals.models import AggregatedResult, DimensionAggregate, DimensionSource 14 | 15 | __all__ = [ 16 | "RouterAggregator", 17 | "AggregatedResult", 18 | "DimensionAggregate", 19 | "DimensionSource", 20 | ] 21 | -------------------------------------------------------------------------------- /src/shotgun/agents/config/__init__.py: -------------------------------------------------------------------------------- 1 | """Configuration module for Shotgun CLI.""" 2 | 3 | from .manager import ( 4 | BACKUP_DIR_NAME, 5 | ConfigManager, 6 | ConfigMigrationError, 7 | get_backup_dir, 8 | get_config_manager, 9 | ) 10 | from .models import ProviderType, ShotgunConfig 11 | from .provider import get_provider_model 12 | 13 | __all__ = [ 14 | "BACKUP_DIR_NAME", 15 | "ConfigManager", 16 | "ConfigMigrationError", 17 | "get_backup_dir", 18 | "get_config_manager", 19 | "ProviderType", 20 | "ShotgunConfig", 21 | "get_provider_model", 22 | ] 23 | -------------------------------------------------------------------------------- /src/shotgun/agents/conversation/__init__.py: -------------------------------------------------------------------------------- 1 | """Conversation module for managing conversation history and persistence.""" 2 | 3 | from .filters import ( 4 | filter_incomplete_messages, 5 | filter_orphaned_tool_responses, 6 | is_tool_call_complete, 7 | ) 8 | from .manager import ConversationManager 9 | from .models import ConversationHistory, ConversationState 10 | 11 | __all__ = [ 12 | "ConversationHistory", 13 | "ConversationManager", 14 | "ConversationState", 15 | "filter_incomplete_messages", 16 | "filter_orphaned_tool_responses", 17 | "is_tool_call_complete", 18 | ] 19 | -------------------------------------------------------------------------------- /src/shotgun/agents/error/models.py: -------------------------------------------------------------------------------- 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 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.defaultInterpreter": "${workspaceFolder}/.venv/bin/python", 3 | "python.pythonPath": "${workspaceFolder}/.venv/bin/python", 4 | "python.analysis.extraPaths": [ 5 | "${workspaceFolder}/src" 6 | ], 7 | "python.envFile": "${workspaceFolder}/.env", 8 | "python.testing.pytestEnabled": false, 9 | "python.testing.unittestEnabled": false, 10 | "python.linting.enabled": true, 11 | "python.linting.ruffEnabled": true, 12 | "python.formatting.provider": "ruff", 13 | "python.analysis.autoImportCompletions": true, 14 | "python.analysis.typeCheckingMode": "basic" 15 | } -------------------------------------------------------------------------------- /src/shotgun/prompts/tools/web_search.j2: -------------------------------------------------------------------------------- 1 | Your training data may be old. The current date and time is: {{ current_datetime }} ({{ timezone_name }}, {{ utc_offset }}) 2 | 3 | Please provide current and accurate information about the following query: 4 | 5 | Query: {{ query }} 6 | 7 | Instructions: 8 | - Provide comprehensive, factual information 9 | - Include relevant details and context 10 | - Focus on current and recent information 11 | - Be specific and accurate in your response 12 | - You can't ask the user for details, so assume the most relevant details for the query 13 | 14 | ALWAYS PROVIDE THE SOURCES (urls) TO BACK UP THE INFORMATION YOU PROVIDE. 15 | -------------------------------------------------------------------------------- /src/shotgun/agents/tools/web_search/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for web search tools.""" 2 | 3 | from shotgun.agents.config import get_provider_model 4 | from shotgun.agents.config.models import ProviderType 5 | 6 | 7 | async def is_provider_available(provider: ProviderType) -> bool: 8 | """Check if a provider has API key configured. 9 | 10 | Args: 11 | provider: The provider to check 12 | 13 | Returns: 14 | True if the provider has valid credentials configured (from config or env) 15 | """ 16 | try: 17 | await get_provider_model(provider) 18 | return True 19 | except ValueError: 20 | return False 21 | -------------------------------------------------------------------------------- /src/shotgun/api_endpoints.py: -------------------------------------------------------------------------------- 1 | """Shotgun backend service API endpoints and URLs.""" 2 | 3 | from shotgun.settings import settings 4 | 5 | # Shotgun Web API base URL (for authentication/subscription) 6 | # Can be overridden with SHOTGUN_WEB_BASE_URL environment variable 7 | SHOTGUN_WEB_BASE_URL = settings.api.web_base_url 8 | 9 | # Shotgun's LiteLLM proxy base URL (for AI model requests) 10 | # Can be overridden with SHOTGUN_ACCOUNT_LLM_BASE_URL environment variable 11 | LITELLM_PROXY_BASE_URL = settings.api.account_llm_base_url 12 | 13 | # Provider-specific LiteLLM proxy endpoints 14 | LITELLM_PROXY_ANTHROPIC_BASE = f"{LITELLM_PROXY_BASE_URL}/anthropic" 15 | LITELLM_PROXY_OPENAI_BASE = LITELLM_PROXY_BASE_URL 16 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat_screen/history/__init__.py: -------------------------------------------------------------------------------- 1 | """Chat history package - displays conversation messages in the TUI. 2 | 3 | This package provides widgets for displaying chat history including: 4 | - User questions 5 | - Agent responses 6 | - Tool calls 7 | - Streaming/partial responses 8 | """ 9 | 10 | from .agent_response import AgentResponseWidget 11 | from .chat_history import ChatHistory 12 | from .formatters import ToolFormatter 13 | from .partial_response import PartialResponseWidget 14 | from .user_question import UserQuestionWidget 15 | 16 | __all__ = [ 17 | "ChatHistory", 18 | "PartialResponseWidget", 19 | "AgentResponseWidget", 20 | "UserQuestionWidget", 21 | "ToolFormatter", 22 | ] 23 | -------------------------------------------------------------------------------- /src/shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2: -------------------------------------------------------------------------------- 1 | ## Codebase Graphs Available 2 | 3 | {% if codebase_understanding_graphs -%} 4 | 5 | You have access to the following codebase graphs: 6 | 7 | {% for graph in codebase_understanding_graphs -%} 8 | - {{ graph.name }} ID: {{ graph.graph_id }} Path: {{ graph.repo_path }} 9 | {% endfor -%} 10 | 11 | {% else -%} 12 | 13 | {% if is_tui_context -%} 14 | No codebase has been indexed yet. To enable code analysis, please tell the user to restart the TUI and follow the prompt to 'Index this codebase?' when it appears. 15 | {% else -%} 16 | No codebase has been indexed yet. If the user needs code analysis, ask them to index a codebase first. 17 | {% endif -%} 18 | 19 | {% endif %} -------------------------------------------------------------------------------- /src/shotgun/tui/components/vertical_tail.py: -------------------------------------------------------------------------------- 1 | from textual.containers import VerticalScroll 2 | from textual.geometry import Size 3 | from textual.reactive import reactive 4 | 5 | 6 | class VerticalTail(VerticalScroll): 7 | """A vertical scroll container that automatically scrolls to the bottom when content is added.""" 8 | 9 | auto_scroll = reactive(True, layout=False) 10 | 11 | def watch_auto_scroll(self, value: bool) -> None: 12 | """Handle auto_scroll property changes.""" 13 | if value: 14 | self.scroll_end(animate=False) 15 | 16 | def watch_virtual_size(self, value: Size) -> None: 17 | """Handle virtual_size property changes.""" 18 | 19 | self.call_later(self.scroll_end, animate=False) 20 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/shared_specs/__init__.py: -------------------------------------------------------------------------------- 1 | """Shared specs TUI screens and dialogs.""" 2 | 3 | from shotgun.tui.screens.shared_specs.create_spec_dialog import CreateSpecDialog 4 | from shotgun.tui.screens.shared_specs.models import ( 5 | CreateSpecResult, 6 | ShareSpecsAction, 7 | ShareSpecsResult, 8 | UploadScreenResult, 9 | ) 10 | from shotgun.tui.screens.shared_specs.share_specs_dialog import ShareSpecsDialog 11 | from shotgun.tui.screens.shared_specs.upload_progress_screen import UploadProgressScreen 12 | 13 | __all__ = [ 14 | "CreateSpecDialog", 15 | "CreateSpecResult", 16 | "ShareSpecsAction", 17 | "ShareSpecsDialog", 18 | "ShareSpecsResult", 19 | "UploadProgressScreen", 20 | "UploadScreenResult", 21 | ] 22 | -------------------------------------------------------------------------------- /src/shotgun/cli/error_handler.py: -------------------------------------------------------------------------------- 1 | """CLI-specific error handling utilities. 2 | 3 | This module provides utilities for displaying agent errors in the CLI 4 | by printing formatted messages to the console. 5 | """ 6 | 7 | from rich.console import Console 8 | 9 | from shotgun.exceptions import ErrorNotPickedUpBySentry 10 | 11 | console = Console(stderr=True) 12 | 13 | 14 | def print_agent_error(exception: ErrorNotPickedUpBySentry) -> None: 15 | """Print an agent error to the console in yellow. 16 | 17 | Args: 18 | exception: The error exception with formatting methods 19 | """ 20 | # Get plain text version for CLI 21 | message = exception.to_plain_text() 22 | 23 | # Print with yellow styling 24 | console.print(message, style="yellow") 25 | -------------------------------------------------------------------------------- /src/shotgun/prompts/codebase/enhanced_query_context.j2: -------------------------------------------------------------------------------- 1 | Current datetime: {{ current_datetime }} (Unix timestamp: {{ current_timestamp }}) 2 | 3 | User query: {{ natural_language_query }} 4 | 5 | IMPORTANT: All timestamps in the database are stored as Unix timestamps (INT64). When generating time-based queries: 6 | - For "2 minutes ago": use {{ current_timestamp - 120 }} 7 | - For "1 hour ago": use {{ current_timestamp - 3600 }} 8 | - For "today": use timestamps >= {{ current_timestamp - (current_timestamp % 86400) }} 9 | - For "yesterday": use timestamps between {{ current_timestamp - 86400 - (current_timestamp % 86400) }} and {{ current_timestamp - (current_timestamp % 86400) }} 10 | - NEVER use placeholder values like 1704067200, always calculate based on the current timestamp: {{ current_timestamp }} -------------------------------------------------------------------------------- /src/shotgun/tui/components/splash.py: -------------------------------------------------------------------------------- 1 | from textual.app import RenderResult 2 | from textual.widgets import Static 3 | 4 | ART = """ 5 | 6 | ███████╗██╗ ██╗ ██████╗ ████████╗ ██████╗ ██╗ ██╗███╗ ██╗ 7 | ██╔════╝██║ ██║██╔═══██╗╚══██╔══╝██╔════╝ ██║ ██║████╗ ██║ 8 | ███████╗███████║██║ ██║ ██║ ██║ ███╗██║ ██║██╔██╗ ██║ 9 | ╚════██║██╔══██║██║ ██║ ██║ ██║ ██║██║ ██║██║╚██╗██║ 10 | ███████║██║ ██║╚██████╔╝ ██║ ╚██████╔╝╚██████╔╝██║ ╚████║ 11 | ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ 12 | 13 | """ 14 | 15 | 16 | class SplashWidget(Static): 17 | DEFAULT_CSS = """ 18 | SplashWidget { 19 | text-align: center; 20 | width: 64; 21 | } 22 | """ 23 | 24 | def render(self) -> RenderResult: 25 | return ART 26 | -------------------------------------------------------------------------------- /src/shotgun/agents/tools/codebase/__init__.py: -------------------------------------------------------------------------------- 1 | """Codebase understanding tools for Pydantic AI agents.""" 2 | 3 | from .codebase_shell import codebase_shell 4 | from .directory_lister import directory_lister 5 | from .file_read import file_read 6 | from .models import ( 7 | CodeSnippetResult, 8 | DirectoryListResult, 9 | FileReadResult, 10 | QueryGraphResult, 11 | ShellCommandResult, 12 | ) 13 | from .query_graph import query_graph 14 | from .retrieve_code import retrieve_code 15 | 16 | __all__ = [ 17 | "query_graph", 18 | "retrieve_code", 19 | "file_read", 20 | "directory_lister", 21 | "codebase_shell", 22 | # Result models 23 | "QueryGraphResult", 24 | "CodeSnippetResult", 25 | "FileReadResult", 26 | "DirectoryListResult", 27 | "ShellCommandResult", 28 | ] 29 | -------------------------------------------------------------------------------- /src/shotgun/sdk/services.py: -------------------------------------------------------------------------------- 1 | """Service factory functions for SDK.""" 2 | 3 | from pathlib import Path 4 | 5 | from shotgun.codebase.service import CodebaseService 6 | from shotgun.utils import get_shotgun_home 7 | 8 | 9 | def get_codebase_service(storage_dir: Path | str | None = None) -> CodebaseService: 10 | """Get CodebaseService instance with configurable storage. 11 | 12 | Args: 13 | storage_dir: Optional custom storage directory. 14 | Defaults to ~/.shotgun-sh/codebases/ 15 | 16 | Returns: 17 | Configured CodebaseService instance 18 | """ 19 | if storage_dir is None: 20 | storage_dir = get_shotgun_home() / "codebases" 21 | elif isinstance(storage_dir, str): 22 | storage_dir = Path(storage_dir) 23 | return CodebaseService(storage_dir) 24 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat.tcss: -------------------------------------------------------------------------------- 1 | ChatHistory { 2 | height: auto; 3 | } 4 | 5 | PromptInput { 6 | min-height: 3; 7 | max-height: 7; 8 | height: auto; 9 | } 10 | 11 | StatusBar { 12 | height: auto; 13 | } 14 | 15 | ModeIndicator { 16 | height: auto; 17 | } 18 | 19 | #footer { 20 | dock: bottom; 21 | height: auto; 22 | padding: 1 1 1 2; 23 | max-height: 14; 24 | } 25 | 26 | #window { 27 | align: left bottom; 28 | } 29 | 30 | .hidden { 31 | display: none; 32 | } 33 | 34 | #footer > Grid { 35 | height: auto; 36 | grid-columns: 1fr auto; 37 | grid-size: 2; 38 | } 39 | 40 | 41 | #right-footer-indicators { 42 | width: auto; 43 | height: auto; 44 | layout: vertical; 45 | } 46 | 47 | #context-indicator { 48 | text-align: end; 49 | height: 1; 50 | } 51 | 52 | #indexing-job-display { 53 | text-align: end; 54 | } -------------------------------------------------------------------------------- /src/shotgun/agents/conversation/history/constants.py: -------------------------------------------------------------------------------- 1 | """Constants for history processing and compaction.""" 2 | 3 | from enum import Enum 4 | 5 | # Summary marker for compacted history 6 | SUMMARY_MARKER = "📌 COMPACTED_HISTORY:" 7 | 8 | # Token calculation constants 9 | INPUT_BUFFER_TOKENS = 500 10 | MIN_SUMMARY_TOKENS = 100 11 | TOKEN_LIMIT_RATIO = 0.8 12 | 13 | # Chunked compaction constants 14 | CHUNK_TARGET_RATIO = 0.60 # Target chunk size as % of max_input_tokens 15 | CHUNK_SAFE_RATIO = 0.70 # Max safe ratio before triggering chunked compaction 16 | RETENTION_WINDOW_MESSAGES = 5 # Keep last N message groups outside compaction 17 | 18 | 19 | class SummaryType(Enum): 20 | """Types of summarization requests for logging.""" 21 | 22 | INCREMENTAL = "INCREMENTAL" 23 | FULL = "FULL" 24 | CONTEXT_EXTRACTION = "CONTEXT_EXTRACTION" 25 | -------------------------------------------------------------------------------- /src/shotgun/cli/utils.py: -------------------------------------------------------------------------------- 1 | """Common utilities for CLI commands.""" 2 | 3 | import json 4 | from typing import Any 5 | 6 | from pydantic import BaseModel 7 | 8 | from .models import OutputFormat 9 | 10 | 11 | def format_result_json(result: Any) -> str: 12 | """Format result object as JSON using Pydantic serialization.""" 13 | if isinstance(result, BaseModel): 14 | return result.model_dump_json(indent=2) 15 | else: 16 | # Fallback for non-Pydantic objects 17 | return json.dumps({"result": str(result)}, indent=2) 18 | 19 | 20 | def output_result(result: Any, format_type: OutputFormat = OutputFormat.TEXT) -> None: 21 | """Output result in specified format.""" 22 | if format_type == OutputFormat.JSON: 23 | print(format_result_json(result)) 24 | else: # Default to text 25 | print(str(result)) 26 | -------------------------------------------------------------------------------- /src/shotgun/shotgun_web/exceptions.py: -------------------------------------------------------------------------------- 1 | """Typed exceptions for Shotgun Web API operations.""" 2 | 3 | 4 | class ShotgunWebError(Exception): 5 | """Base exception for Shotgun Web API operations.""" 6 | 7 | 8 | class UnauthorizedError(ShotgunWebError): 9 | """401 - Missing or invalid authentication token.""" 10 | 11 | 12 | class ForbiddenError(ShotgunWebError): 13 | """403 - Insufficient permissions for the requested operation.""" 14 | 15 | 16 | class NotFoundError(ShotgunWebError): 17 | """404 - Resource not found.""" 18 | 19 | 20 | class ConflictError(ShotgunWebError): 21 | """409 - Name conflict or duplicate path.""" 22 | 23 | 24 | class PayloadTooLargeError(ShotgunWebError): 25 | """413 - File exceeds maximum size limit.""" 26 | 27 | 28 | class RateLimitExceededError(ShotgunWebError): 29 | """429 - Rate limit exceeded.""" 30 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/splash.py: -------------------------------------------------------------------------------- 1 | from textual.app import ComposeResult 2 | from textual.containers import Container 3 | from textual.screen import Screen 4 | 5 | from ..components.splash import SplashWidget 6 | 7 | 8 | class SplashScreen(Screen[None]): 9 | CSS = """ 10 | #splash-container { 11 | align: center middle; 12 | width: 100%; 13 | height: 100%; 14 | 15 | } 16 | 17 | SplashWidget { 18 | color: $text-accent; 19 | } 20 | """ 21 | """Splash screen for the app.""" 22 | 23 | def on_mount(self) -> None: 24 | self.set_timer(2, self.on_timer_tick) 25 | 26 | def on_timer_tick(self) -> None: 27 | self.dismiss() 28 | 29 | def compose(self) -> ComposeResult: 30 | with Container(id="splash-container"): 31 | yield SplashWidget() 32 | -------------------------------------------------------------------------------- /src/shotgun/agents/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools package for Pydantic AI agents.""" 2 | 3 | from .codebase import ( 4 | codebase_shell, 5 | directory_lister, 6 | file_read, 7 | query_graph, 8 | retrieve_code, 9 | ) 10 | from .file_management import append_file, read_file, write_file 11 | from .web_search import ( 12 | anthropic_web_search_tool, 13 | gemini_web_search_tool, 14 | get_available_web_search_tools, 15 | openai_web_search_tool, 16 | ) 17 | 18 | __all__ = [ 19 | "openai_web_search_tool", 20 | "anthropic_web_search_tool", 21 | "gemini_web_search_tool", 22 | "get_available_web_search_tools", 23 | "read_file", 24 | "write_file", 25 | "append_file", 26 | # Codebase understanding tools 27 | "query_graph", 28 | "retrieve_code", 29 | "file_read", 30 | "directory_lister", 31 | "codebase_shell", 32 | ] 33 | -------------------------------------------------------------------------------- /test/unit/tui/test_provider_config.py: -------------------------------------------------------------------------------- 1 | """Unit tests for provider_config screen module.""" 2 | 3 | import pytest 4 | 5 | from shotgun.tui.screens.provider_config import get_configurable_providers 6 | 7 | 8 | @pytest.mark.smoke 9 | def test_get_configurable_providers_includes_shotgun(): 10 | """Test that get_configurable_providers always includes shotgun.""" 11 | providers = get_configurable_providers() 12 | 13 | assert "shotgun" in providers 14 | assert "openai" in providers 15 | assert "anthropic" in providers 16 | assert "google" in providers 17 | 18 | 19 | def test_get_configurable_providers_returns_all_providers(): 20 | """Test that get_configurable_providers returns all four providers.""" 21 | providers = get_configurable_providers() 22 | 23 | assert len(providers) == 4 24 | assert providers == ["openai", "anthropic", "google", "shotgun"] 25 | -------------------------------------------------------------------------------- /src/shotgun/agents/config/constants.py: -------------------------------------------------------------------------------- 1 | """Configuration constants for Shotgun agents.""" 2 | 3 | from enum import StrEnum, auto 4 | 5 | # Field names 6 | API_KEY_FIELD = "api_key" 7 | SUPABASE_JWT_FIELD = "supabase_jwt" 8 | SHOTGUN_INSTANCE_ID_FIELD = "shotgun_instance_id" 9 | CONFIG_VERSION_FIELD = "config_version" 10 | 11 | 12 | class ConfigSection(StrEnum): 13 | """Configuration file section names (JSON keys).""" 14 | 15 | OPENAI = auto() 16 | ANTHROPIC = auto() 17 | GOOGLE = auto() 18 | SHOTGUN = auto() 19 | 20 | 21 | # Backwards compatibility - deprecated 22 | OPENAI_PROVIDER = ConfigSection.OPENAI.value 23 | ANTHROPIC_PROVIDER = ConfigSection.ANTHROPIC.value 24 | GOOGLE_PROVIDER = ConfigSection.GOOGLE.value 25 | SHOTGUN_PROVIDER = ConfigSection.SHOTGUN.value 26 | 27 | # Token limits 28 | MEDIUM_TEXT_8K_TOKENS = 8192 # Default max_tokens for web search requests 29 | -------------------------------------------------------------------------------- /src/shotgun/agents/context_analyzer/__init__.py: -------------------------------------------------------------------------------- 1 | """Context analysis module for conversation composition statistics. 2 | 3 | This module provides tools for analyzing conversation context usage, breaking down 4 | token consumption by message type and tool category. 5 | """ 6 | 7 | from .analyzer import ContextAnalyzer 8 | from .constants import ToolCategory, get_tool_category 9 | from .formatter import ContextFormatter 10 | from .models import ( 11 | ContextAnalysis, 12 | ContextAnalysisOutput, 13 | ContextCompositionTelemetry, 14 | MessageTypeStats, 15 | TokenAllocation, 16 | ) 17 | 18 | __all__ = [ 19 | "ContextAnalyzer", 20 | "ContextAnalysis", 21 | "ContextAnalysisOutput", 22 | "ContextCompositionTelemetry", 23 | "ContextFormatter", 24 | "MessageTypeStats", 25 | "TokenAllocation", 26 | "ToolCategory", 27 | "get_tool_category", 28 | ] 29 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | ruff: 5 | run: uv run ruff check --fix {staged_files} 6 | glob: "*.py" 7 | stage_fixed: true 8 | ruff-format: 9 | run: uv run ruff format {staged_files} 10 | glob: "*.py" 11 | stage_fixed: true 12 | mypy: 13 | run: uv run mypy {staged_files} 14 | glob: "*.py" 15 | exclude: "(^test/|hatch_build\\.py)" 16 | trufflehog-main: 17 | run: trufflehog git file://. --since-commit HEAD --fail --no-update --exclude-globs=uv.lock 18 | trufflehog-lockfile: 19 | run: trufflehog git file://. --since-commit HEAD --fail --no-update --include-paths=.github/trufflehog-include-lockfile.txt --exclude-detectors=SentryToken 20 | actionlint: 21 | run: actionlint {staged_files} 22 | glob: ".github/workflows/*.yml" 23 | skip: true 24 | 25 | commit-msg: 26 | commands: 27 | commitizen: 28 | run: uv run cz check --commit-msg-file {1} 29 | -------------------------------------------------------------------------------- /src/shotgun/shotgun_web/shared_specs/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for shared specs module.""" 2 | 3 | from enum import StrEnum 4 | 5 | 6 | class UploadPhase(StrEnum): 7 | """Upload pipeline phases.""" 8 | 9 | CREATING = "creating" # Creating spec/version via API 10 | SCANNING = "scanning" 11 | HASHING = "hashing" 12 | UPLOADING = "uploading" 13 | CLOSING = "closing" 14 | COMPLETE = "complete" 15 | ERROR = "error" 16 | 17 | 18 | def format_bytes(size: int) -> str: 19 | """Format bytes as human-readable string. 20 | 21 | Args: 22 | size: Size in bytes 23 | 24 | Returns: 25 | Human-readable string like "1.5 KB" or "2.3 MB" 26 | """ 27 | if size < 1024: 28 | return f"{size} B" 29 | elif size < 1024 * 1024: 30 | return f"{size / 1024:.1f} KB" 31 | elif size < 1024 * 1024 * 1024: 32 | return f"{size / (1024 * 1024):.1f} MB" 33 | else: 34 | return f"{size / (1024 * 1024 * 1024):.1f} GB" 35 | -------------------------------------------------------------------------------- /src/shotgun/llm_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | """LiteLLM proxy client utilities and configuration.""" 2 | 3 | from .client import LiteLLMProxyClient, get_budget_info 4 | from .clients import ( 5 | create_anthropic_proxy_provider, 6 | create_litellm_provider, 7 | ) 8 | from .constants import ( 9 | LITELLM_PROXY_ANTHROPIC_BASE, 10 | LITELLM_PROXY_BASE_URL, 11 | LITELLM_PROXY_OPENAI_BASE, 12 | ) 13 | from .models import ( 14 | BudgetInfo, 15 | BudgetSource, 16 | KeyInfoData, 17 | KeyInfoResponse, 18 | TeamInfoData, 19 | TeamInfoResponse, 20 | ) 21 | 22 | __all__ = [ 23 | "LITELLM_PROXY_BASE_URL", 24 | "LITELLM_PROXY_ANTHROPIC_BASE", 25 | "LITELLM_PROXY_OPENAI_BASE", 26 | "create_litellm_provider", 27 | "create_anthropic_proxy_provider", 28 | "LiteLLMProxyClient", 29 | "get_budget_info", 30 | "BudgetInfo", 31 | "BudgetSource", 32 | "KeyInfoData", 33 | "KeyInfoResponse", 34 | "TeamInfoData", 35 | "TeamInfoResponse", 36 | ] 37 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat/chat.tcss: -------------------------------------------------------------------------------- 1 | ChatHistory { 2 | height: auto; 3 | } 4 | 5 | PromptInput { 6 | min-height: 3; 7 | max-height: 7; 8 | height: auto; 9 | } 10 | 11 | StatusBar { 12 | height: auto; 13 | } 14 | 15 | ModeIndicator { 16 | height: auto; 17 | } 18 | 19 | ModeIndicator.mode-planning { 20 | /* Blue/cyan accent for planning mode */ 21 | } 22 | 23 | ModeIndicator.mode-drafting { 24 | /* Green accent for drafting mode */ 25 | } 26 | 27 | #footer { 28 | dock: bottom; 29 | height: auto; 30 | padding: 1 1 1 2; 31 | max-height: 24; 32 | } 33 | 34 | #window { 35 | align: left bottom; 36 | } 37 | 38 | .hidden { 39 | display: none; 40 | } 41 | 42 | #footer > Grid { 43 | height: auto; 44 | grid-columns: 1fr auto; 45 | grid-size: 2; 46 | } 47 | 48 | 49 | #right-footer-indicators { 50 | width: auto; 51 | height: auto; 52 | layout: vertical; 53 | } 54 | 55 | #context-indicator { 56 | text-align: end; 57 | height: 1; 58 | } 59 | 60 | #indexing-job-display { 61 | text-align: end; 62 | } -------------------------------------------------------------------------------- /evals/evaluators/deterministic/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Deterministic evaluators for Router agent evaluation. 3 | 4 | These evaluators apply rule-based checks with deterministic outcomes. 5 | """ 6 | 7 | from evals.evaluators.deterministic.router_evaluators import ( 8 | ClarifyingQuestionsEvaluator, 9 | ContentAssertionEvaluator, 10 | DelegationCorrectnessEvaluator, 11 | DisallowedToolUsageEvaluator, 12 | ExecutionFailureEvaluator, 13 | ExpectedToolPresenceEvaluator, 14 | RouterDeterministicEvaluator, 15 | run_all_deterministic_evaluators, 16 | ) 17 | from evals.models import EvaluatorResult, EvaluatorSeverity 18 | 19 | __all__ = [ 20 | "EvaluatorSeverity", 21 | "EvaluatorResult", 22 | "RouterDeterministicEvaluator", 23 | "DisallowedToolUsageEvaluator", 24 | "ExecutionFailureEvaluator", 25 | "ExpectedToolPresenceEvaluator", 26 | "ContentAssertionEvaluator", 27 | "DelegationCorrectnessEvaluator", 28 | "ClarifyingQuestionsEvaluator", 29 | "run_all_deterministic_evaluators", 30 | ] 31 | -------------------------------------------------------------------------------- /src/shotgun/shotgun_web/supabase_client.py: -------------------------------------------------------------------------------- 1 | """Supabase Storage download utilities.""" 2 | 3 | import httpx 4 | 5 | from shotgun.logging_config import get_logger 6 | 7 | logger = get_logger(__name__) 8 | 9 | 10 | async def download_file_from_url(download_url: str) -> bytes: 11 | """Download a file from a presigned Supabase Storage URL. 12 | 13 | The API returns presigned URLs with embedded tokens that don't require 14 | any authentication headers. 15 | 16 | Args: 17 | download_url: Presigned Supabase Storage URL 18 | (e.g., "https://...supabase.co/storage/v1/object/sign/...?token=...") 19 | 20 | Returns: 21 | File contents as bytes 22 | 23 | Raises: 24 | httpx.HTTPStatusError: If download fails 25 | """ 26 | logger.debug("Downloading file from: %s", download_url) 27 | 28 | async with httpx.AsyncClient(timeout=60.0) as client: 29 | response = await client.get(download_url) 30 | response.raise_for_status() 31 | return response.content 32 | -------------------------------------------------------------------------------- /src/shotgun/shotgun_web/shared_specs/__init__.py: -------------------------------------------------------------------------------- 1 | """Shared specs file utilities. 2 | 3 | This module provides utilities for scanning and hashing files in the 4 | .shotgun/ directory for upload to the shared specs API. 5 | """ 6 | 7 | from shotgun.shotgun_web.shared_specs.file_scanner import ( 8 | get_shotgun_directory, 9 | scan_shotgun_directory, 10 | ) 11 | from shotgun.shotgun_web.shared_specs.hasher import ( 12 | calculate_sha256, 13 | calculate_sha256_with_size, 14 | ) 15 | from shotgun.shotgun_web.shared_specs.models import ( 16 | UploadProgress, 17 | UploadResult, 18 | ) 19 | from shotgun.shotgun_web.shared_specs.upload_pipeline import run_upload_pipeline 20 | from shotgun.shotgun_web.shared_specs.utils import UploadPhase, format_bytes 21 | 22 | __all__ = [ 23 | "UploadPhase", 24 | "UploadProgress", 25 | "UploadResult", 26 | "calculate_sha256", 27 | "calculate_sha256_with_size", 28 | "format_bytes", 29 | "get_shotgun_directory", 30 | "run_upload_pipeline", 31 | "scan_shotgun_directory", 32 | ] 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a new feature or enhancement for Shotgun 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ## Problem Description 10 | 11 | 14 | 15 | 16 | 17 | ## Proposed Solution 18 | 19 | 20 | 21 | 22 | 23 | ## Alternative Solutions Considered 24 | 25 | 27 | 28 | 29 | 30 | ## Additional Context 31 | 32 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | # Python dependencies (using uv) 7 | - package-ecosystem: "uv" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | time: "04:00" 12 | timezone: "America/New_York" 13 | open-pull-requests-limit: 10 14 | assignees: 15 | - "scottfrasso" 16 | labels: 17 | - "dependencies" 18 | - "python" 19 | commit-message: 20 | prefix: "build" 21 | prefix-development: "build" 22 | include: "scope" 23 | 24 | # GitHub Actions 25 | - package-ecosystem: "github-actions" 26 | directory: "/" 27 | schedule: 28 | interval: "daily" 29 | time: "04:00" 30 | timezone: "America/New_York" 31 | open-pull-requests-limit: 10 32 | assignees: 33 | - "scottfrasso" 34 | labels: 35 | - "dependencies" 36 | - "github-actions" 37 | commit-message: 38 | prefix: "ci" 39 | include: "scope" -------------------------------------------------------------------------------- /src/shotgun/agents/conversation/history/token_counting/__init__.py: -------------------------------------------------------------------------------- 1 | """Real token counting for all supported providers. 2 | 3 | This module provides accurate token counting using each provider's official 4 | APIs and libraries, eliminating the need for rough character-based estimation. 5 | """ 6 | 7 | from .anthropic import AnthropicTokenCounter 8 | from .base import TokenCounter, extract_text_from_messages 9 | from .openai import OpenAITokenCounter 10 | from .sentencepiece_counter import SentencePieceTokenCounter 11 | from .utils import ( 12 | count_post_summary_tokens, 13 | count_tokens_from_message_parts, 14 | count_tokens_from_messages, 15 | get_token_counter, 16 | ) 17 | 18 | __all__ = [ 19 | # Base classes 20 | "TokenCounter", 21 | # Counter implementations 22 | "OpenAITokenCounter", 23 | "AnthropicTokenCounter", 24 | "SentencePieceTokenCounter", 25 | # Utility functions 26 | "get_token_counter", 27 | "count_tokens_from_messages", 28 | "count_post_summary_tokens", 29 | "count_tokens_from_message_parts", 30 | "extract_text_from_messages", 31 | ] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mimir AI, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /test/integration/pytest.ini: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | # Configuration for integration tests 3 | testpaths = . 4 | python_files = test_*.py 5 | python_classes = Test* 6 | python_functions = test_* 7 | 8 | # Markers 9 | markers = 10 | integration: Integration tests that require LLM configuration 11 | slow: Tests that take a long time to run 12 | llm: Tests that make actual LLM calls 13 | 14 | # Async support 15 | asyncio_mode = auto 16 | 17 | # Logging 18 | log_cli = true 19 | log_cli_level = INFO 20 | log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s 21 | log_cli_date_format = %Y-%m-%d %H:%M:%S 22 | 23 | # Test discovery 24 | addopts = 25 | --strict-markers 26 | --tb=short 27 | -v 28 | 29 | # Timeout for tests (in seconds) 30 | timeout = 300 31 | 32 | # Minimum coverage 33 | [coverage:run] 34 | source = ../../../src/shotgun/codebase 35 | omit = 36 | */test* 37 | */__pycache__/* 38 | */conftest.py 39 | 40 | [coverage:report] 41 | exclude_lines = 42 | pragma: no cover 43 | def __repr__ 44 | raise AssertionError 45 | raise NotImplementedError 46 | if __name__ == .__main__.: 47 | if TYPE_CHECKING: -------------------------------------------------------------------------------- /evals/evaluators/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Evaluator implementations for Shotgun agent evaluation. 3 | 4 | Organized by evaluator type: 5 | - deterministic/: Rule-based evaluators with deterministic outcomes 6 | - llm_judges/: LLM-as-a-judge evaluators (in evals/judges/) 7 | """ 8 | 9 | from evals.evaluators.deterministic.router_evaluators import ( 10 | ClarifyingQuestionsEvaluator, 11 | ContentAssertionEvaluator, 12 | DelegationCorrectnessEvaluator, 13 | DisallowedToolUsageEvaluator, 14 | ExecutionFailureEvaluator, 15 | ExpectedToolPresenceEvaluator, 16 | RouterDeterministicEvaluator, 17 | run_all_deterministic_evaluators, 18 | ) 19 | from evals.models import EvaluatorResult, EvaluatorSeverity 20 | 21 | __all__ = [ 22 | # Base types 23 | "EvaluatorSeverity", 24 | "EvaluatorResult", 25 | "RouterDeterministicEvaluator", 26 | # Evaluators 27 | "DisallowedToolUsageEvaluator", 28 | "ExecutionFailureEvaluator", 29 | "ExpectedToolPresenceEvaluator", 30 | "ContentAssertionEvaluator", 31 | "DelegationCorrectnessEvaluator", 32 | "ClarifyingQuestionsEvaluator", 33 | # Runner 34 | "run_all_deterministic_evaluators", 35 | ] 36 | -------------------------------------------------------------------------------- /src/shotgun/agents/messages.py: -------------------------------------------------------------------------------- 1 | """Custom message types for Shotgun agents. 2 | 3 | This module defines specialized SystemPromptPart subclasses to distinguish 4 | between different types of system prompts in the agent pipeline. 5 | """ 6 | 7 | from dataclasses import dataclass, field 8 | 9 | from pydantic_ai.messages import SystemPromptPart 10 | 11 | from shotgun.agents.models import AgentType 12 | 13 | 14 | @dataclass 15 | class AgentSystemPrompt(SystemPromptPart): 16 | """System prompt containing the main agent instructions. 17 | 18 | This is the primary system prompt that defines the agent's role, 19 | capabilities, and behavior. It should be preserved during compaction. 20 | """ 21 | 22 | prompt_type: str = "agent" 23 | agent_mode: AgentType | None = field(default=None) 24 | 25 | 26 | @dataclass 27 | class SystemStatusPrompt(SystemPromptPart): 28 | """System prompt containing current system status information. 29 | 30 | This includes table of contents, available files, and other contextual 31 | information about the current state. Only the most recent status should 32 | be preserved during compaction. 33 | """ 34 | 35 | prompt_type: str = "status" 36 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Development and test directories 2 | test/ 3 | playground/ 4 | tmp/ 5 | 6 | # Build and coverage artifacts 7 | htmlcov/ 8 | dist/ 9 | build/ 10 | *.egg-info/ 11 | 12 | # Virtual environments 13 | .venv/ 14 | venv/ 15 | env/ 16 | ENV/ 17 | 18 | # Project-specific directories 19 | .shotgun/ 20 | 21 | # IDE and editor files 22 | .vscode/ 23 | .idea/ 24 | *.swp 25 | *.swo 26 | *~ 27 | 28 | # Git 29 | .git/ 30 | .gitignore 31 | .gitattributes 32 | 33 | # GitHub workflows and configs 34 | .github/ 35 | 36 | # Claude Code 37 | .claude/ 38 | 39 | # Python cache and compiled files 40 | __pycache__/ 41 | *.py[cod] 42 | *$py.class 43 | *.so 44 | 45 | # Linting and type checking caches 46 | .ruff_cache/ 47 | .pytest_cache/ 48 | .mypy_cache/ 49 | .pytype/ 50 | 51 | # Testing and coverage 52 | .coverage 53 | .coverage.* 54 | .cache 55 | nosetests.xml 56 | coverage.xml 57 | *.cover 58 | *.py,cover 59 | .hypothesis/ 60 | cover/ 61 | 62 | # Documentation 63 | docs/ 64 | !docs/README_DOCKER.md 65 | *.md 66 | !README.md 67 | 68 | # OS files 69 | .DS_Store 70 | Thumbs.db 71 | 72 | # Environment files 73 | .env 74 | .env.* 75 | 76 | # Logs 77 | *.log 78 | 79 | # Other development files 80 | lefthook.yml 81 | -------------------------------------------------------------------------------- /src/shotgun/agents/router/__init__.py: -------------------------------------------------------------------------------- 1 | """Router Agent - The intelligent orchestrator for shotgun agents.""" 2 | 3 | from shotgun.agents.router.models import ( 4 | CascadeScope, 5 | CreatePlanInput, 6 | DelegationInput, 7 | DelegationResult, 8 | ExecutionPlan, 9 | ExecutionStep, 10 | ExecutionStepInput, 11 | MarkStepDoneInput, 12 | PlanApprovalStatus, 13 | RemoveStepInput, 14 | RouterDeps, 15 | RouterMode, 16 | StepCheckpointAction, 17 | SubAgentResult, 18 | SubAgentResultStatus, 19 | ToolResult, 20 | ) 21 | from shotgun.agents.router.router import create_router_agent, run_router_agent 22 | 23 | __all__ = [ 24 | # Agent factory 25 | "create_router_agent", 26 | "run_router_agent", 27 | # Enums 28 | "RouterMode", 29 | "PlanApprovalStatus", 30 | "StepCheckpointAction", 31 | "CascadeScope", 32 | "SubAgentResultStatus", 33 | # Plan models 34 | "ExecutionStep", 35 | "ExecutionPlan", 36 | # Tool I/O models 37 | "ExecutionStepInput", 38 | "CreatePlanInput", 39 | "MarkStepDoneInput", 40 | "RemoveStepInput", 41 | "DelegationInput", 42 | "ToolResult", 43 | "DelegationResult", 44 | "SubAgentResult", 45 | # Deps 46 | "RouterDeps", 47 | ] 48 | -------------------------------------------------------------------------------- /src/shotgun/prompts/agents/partials/interactive_mode.j2: -------------------------------------------------------------------------------- 1 | {% if interactive_mode -%} 2 | 3 | BEFORE GETTING TO WORK: For complex or multi-step tasks where the request is ambiguous or lacks sufficient detail, use clarifying_questions to ask what they want. 4 | DURING WORK: After using write_file(), you can suggest that the user review it and ask any clarifying questions with clarifying_questions. 5 | For simple, straightforward requests, make reasonable assumptions and proceed. 6 | Only ask critical questions that significantly impact the outcome. 7 | If you don't need to ask questions, set clarifying_questions to null or omit it. 8 | Keep response field concise with a paragraph at most for user communication and follow EXPECTED_FORMAT. 9 | Don't ask multiple questions in one string - use separate array items using EXPECTED_FORMAT. 10 | 11 | {% else -%} 12 | 13 | IMPORTANT: USER INTERACTION IS DISABLED (non-interactive mode). 14 | - You cannot ask clarifying questions (clarifying_questions will be ignored) 15 | - Make reasonable assumptions based on best practices 16 | - Use sensible defaults when information is missing 17 | - When in doubt, make reasonable assumptions and proceed with best practices 18 | {% endif %} 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/shotgun/codebase/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core components for codebase understanding.""" 2 | 3 | from shotgun.codebase.core.code_retrieval import ( 4 | CodeSnippet, 5 | retrieve_code_by_cypher, 6 | retrieve_code_by_qualified_name, 7 | ) 8 | from shotgun.codebase.core.ingestor import ( 9 | CodebaseIngestor, 10 | Ingestor, 11 | SimpleGraphBuilder, 12 | ) 13 | from shotgun.codebase.core.language_config import ( 14 | LANGUAGE_CONFIGS, 15 | LanguageConfig, 16 | get_language_config, 17 | ) 18 | from shotgun.codebase.core.manager import CodebaseGraphManager 19 | from shotgun.codebase.core.nl_query import ( 20 | clean_cypher_response, 21 | generate_cypher, 22 | generate_cypher_openai_async, 23 | ) 24 | from shotgun.codebase.core.parser_loader import load_parsers 25 | 26 | __all__ = [ 27 | # Ingestor classes 28 | "CodebaseIngestor", 29 | "Ingestor", 30 | "SimpleGraphBuilder", 31 | "CodebaseGraphManager", 32 | # Language configuration 33 | "LanguageConfig", 34 | "LANGUAGE_CONFIGS", 35 | "get_language_config", 36 | # Parser loading 37 | "load_parsers", 38 | # Natural language query 39 | "generate_cypher", 40 | "generate_cypher_openai_async", 41 | "clean_cypher_response", 42 | # Code retrieval 43 | "CodeSnippet", 44 | "retrieve_code_by_qualified_name", 45 | "retrieve_code_by_cypher", 46 | ] 47 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat/prompt_history.py: -------------------------------------------------------------------------------- 1 | """Prompt history management for chat screen.""" 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class PromptHistory(BaseModel): 7 | """Manages prompt history for navigation in chat input.""" 8 | 9 | prompts: list[str] = Field(default_factory=lambda: ["Hello there!"]) 10 | curr: int | None = None 11 | 12 | def next(self) -> str: 13 | """Navigate to next prompt in history. 14 | 15 | Returns: 16 | The next prompt in history. 17 | """ 18 | if self.curr is None: 19 | self.curr = -1 20 | else: 21 | self.curr = -1 22 | return self.prompts[self.curr] 23 | 24 | def prev(self) -> str: 25 | """Navigate to previous prompt in history. 26 | 27 | Returns: 28 | The previous prompt in history. 29 | 30 | Raises: 31 | Exception: If current entry is None. 32 | """ 33 | if self.curr is None: 34 | raise Exception("current entry is none") 35 | if self.curr == -1: 36 | self.curr = None 37 | return "" 38 | self.curr += 1 39 | return "" 40 | 41 | def append(self, text: str) -> None: 42 | """Add a new prompt to history. 43 | 44 | Args: 45 | text: The prompt text to add. 46 | """ 47 | self.prompts.append(text) 48 | self.curr = None 49 | -------------------------------------------------------------------------------- /src/shotgun/prompts/history/chunk_summarization.j2: -------------------------------------------------------------------------------- 1 | You are summarizing chunk {{ chunk_index }} of {{ total_chunks }} from a conversation that is too large to summarize at once. 2 | 3 | Focus on preserving: 4 | 1. ALL file paths, function names, variable names, URLs, and identifiers VERBATIM 5 | 2. Key decisions and their rationale 6 | 3. Tool call results (summarize large outputs, keep critical data) 7 | 4. Important errors or issues encountered 8 | 5. User requests and how they were addressed 9 | 10 | 11 | {{ chunk_content }} 12 | 13 | 14 | 15 | ## Topics Covered 16 | Brief bullet points of main topics discussed in this chunk. 17 | 18 | ## Entities & References 19 | - Files: [list all file paths mentioned, verbatim] 20 | - Functions/Classes: [list all function/method/class names, verbatim] 21 | - Variables/Keys: [configuration keys, env vars, etc., verbatim] 22 | - Links/IDs: [any URLs, ticket IDs, etc., verbatim] 23 | 24 | ## Actions & Results 25 | Summarized tool calls and their outcomes. Preserve important data verbatim. 26 | 27 | ## Important Decisions 28 | Key decisions made and their rationale. 29 | 30 | ## Status at Chunk End 31 | Current active task or objective at the end of this chunk (if any), or "N/A" if this chunk completes without active work. 32 | 33 | 34 | Be concise but preserve all critical information. This summary will be combined with others to create a complete conversation summary. 35 | -------------------------------------------------------------------------------- /src/shotgun/prompts/codebase/partials/temporal_context.j2: -------------------------------------------------------------------------------- 1 | **4. Handling Time-based Queries** 2 | When users ask about "today", "yesterday", "last hour", "last week", etc., convert these to Unix timestamp comparisons: 3 | - "today" → Use a timestamp representing start of current day (e.g., WHERE f.created_at > [today's start timestamp]) 4 | - "yesterday" → Use timestamps for yesterday's range 5 | - "last hour" → Current time minus 3600 seconds 6 | - "last 24 hours" → Current time minus 86400 seconds 7 | - "last week" → Current time minus 604800 seconds 8 | 9 | Since you cannot calculate the current time, use reasonable example timestamps that would work for the query. 10 | The actual timestamp calculation will be handled by the calling application. 11 | 12 | **5. Handling Queries About Deleted/Removed Entities** 13 | When users ask about "removed", "deleted", or "no longer exist" entities: 14 | - Query the DeletionLog table which tracks all deletions 15 | - Filter by entity_type (Function, Method, Class, Module) based on what they're asking about 16 | - Use timestamp comparisons for time-based deletion queries 17 | - The deletion_reason field indicates why it was deleted (e.g., "removed_from_file", "file_deleted") 18 | 19 | Examples: 20 | - "What functions were removed today?" → Query DeletionLog WHERE entity_type = 'Function' AND deleted_at > [today's timestamp] 21 | - "Show deleted classes from auth module" → Query DeletionLog WHERE entity_type = 'Class' AND entity_qualified_name CONTAINS 'auth' -------------------------------------------------------------------------------- /src/shotgun/prompts/agents/partials/router_delegation_mode.j2: -------------------------------------------------------------------------------- 1 | {% if sub_agent_context and sub_agent_context.is_router_delegated %} 2 | 3 | 4 | You are being orchestrated by the Router agent. 5 | {% if sub_agent_context.plan_goal %} 6 | 7 | {{ sub_agent_context.plan_goal }} 8 | 9 | {% endif %} 10 | {% if sub_agent_context.current_step_title %} 11 | 12 | {{ sub_agent_context.current_step_title }} 13 | 14 | {% endif %} 15 | 16 | 17 | 18 | Do the work first to accomplish the CURRENT_TASK_CONTEXT via tool usage like read_file, write_file, query_graph, web_search, etc. 19 | You must complete the work for CURRENT_TASK_CONTEXT before calling final_result. 20 | You cannot answer with "please wait" or tell the user to "be patient" or "this will take a few minutes" you must complete the work via tool usage now. 21 | Skip greetings, pleasantries, or preamble and start the work immediately. 22 | Be concise with your response in final_result. 23 | 24 | 25 | 1. Call read_file to check existing research 26 | 2. Call query_graph or web_search to gather information 27 | 3. Call write_file to save research findings 28 | 4. Call final_result with "Research complete. Updated research.md with findings on X and Y." 29 | 30 | 31 | 1. Call final_result with "I'll research this, please be patient..." and exit without doing any work. 32 | 33 | 34 | 35 | {% endif %} 36 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat_screen/history/user_question.py: -------------------------------------------------------------------------------- 1 | """User question widget for chat history.""" 2 | 3 | from collections.abc import Sequence 4 | 5 | from pydantic_ai.messages import ( 6 | ModelRequest, 7 | ModelRequestPart, 8 | ToolReturnPart, 9 | UserPromptPart, 10 | ) 11 | from textual.app import ComposeResult 12 | from textual.widget import Widget 13 | from textual.widgets import Markdown 14 | 15 | 16 | class UserQuestionWidget(Widget): 17 | """Widget that displays user prompts in the chat history.""" 18 | 19 | def __init__(self, item: ModelRequest | None) -> None: 20 | super().__init__() 21 | self.item = item 22 | 23 | def compose(self) -> ComposeResult: 24 | self.display = self.item is not None 25 | if self.item is None: 26 | yield Markdown(markdown="") 27 | else: 28 | prompt = self.format_prompt_parts(self.item.parts) 29 | yield Markdown(markdown=prompt) 30 | 31 | def format_prompt_parts(self, parts: Sequence[ModelRequestPart]) -> str: 32 | """Format user prompt parts into markdown.""" 33 | acc = "" 34 | for part in parts: 35 | if isinstance(part, UserPromptPart): 36 | acc += ( 37 | f"**>** {part.content if isinstance(part.content, str) else ''}\n\n" 38 | ) 39 | elif isinstance(part, ToolReturnPart): 40 | # Don't show tool return parts in the UI 41 | pass 42 | return acc 43 | -------------------------------------------------------------------------------- /src/shotgun/llm_proxy/clients.py: -------------------------------------------------------------------------------- 1 | """Client creation utilities for LiteLLM proxy.""" 2 | 3 | from pydantic_ai.providers.anthropic import AnthropicProvider 4 | from pydantic_ai.providers.litellm import LiteLLMProvider 5 | 6 | from .constants import LITELLM_PROXY_ANTHROPIC_BASE, LITELLM_PROXY_BASE_URL 7 | 8 | 9 | def create_litellm_provider(api_key: str) -> LiteLLMProvider: 10 | """Create LiteLLM provider for Shotgun Account. 11 | 12 | Args: 13 | api_key: Shotgun API key 14 | 15 | Returns: 16 | Configured LiteLLM provider pointing to Shotgun's proxy 17 | """ 18 | return LiteLLMProvider( 19 | api_base=LITELLM_PROXY_BASE_URL, 20 | api_key=api_key, 21 | ) 22 | 23 | 24 | def create_anthropic_proxy_provider(api_key: str) -> AnthropicProvider: 25 | """Create Anthropic provider configured for LiteLLM proxy. 26 | 27 | This provider uses native Anthropic API format while routing through 28 | the LiteLLM proxy. This preserves Anthropic-specific features like 29 | tool_choice and web search. 30 | 31 | The provider's .client attribute provides access to the async Anthropic 32 | client (AsyncAnthropic), which should be used for all API operations 33 | including token counting. 34 | 35 | Args: 36 | api_key: Shotgun API key 37 | 38 | Returns: 39 | AnthropicProvider configured to use LiteLLM proxy /anthropic endpoint 40 | """ 41 | return AnthropicProvider( 42 | api_key=api_key, 43 | base_url=LITELLM_PROXY_ANTHROPIC_BASE, 44 | ) 45 | -------------------------------------------------------------------------------- /src/shotgun/cli/feedback.py: -------------------------------------------------------------------------------- 1 | """Configuration management CLI commands.""" 2 | 3 | from typing import Annotated 4 | 5 | import typer 6 | from rich.console import Console 7 | 8 | from shotgun.agents.config import get_config_manager 9 | from shotgun.logging_config import get_logger 10 | from shotgun.posthog_telemetry import Feedback, FeedbackKind, submit_feedback_survey 11 | 12 | logger = get_logger(__name__) 13 | console = Console() 14 | 15 | app = typer.Typer( 16 | name="feedback", 17 | help="Send us feedback", 18 | no_args_is_help=True, 19 | ) 20 | 21 | 22 | @app.callback(invoke_without_command=True) 23 | def send_feedback( 24 | description: Annotated[str, typer.Argument(help="Description of the feedback")], 25 | kind: Annotated[ 26 | FeedbackKind, 27 | typer.Option("--type", "-t", help="Feedback type"), 28 | ], 29 | ) -> None: 30 | """Initialize Shotgun configuration.""" 31 | import asyncio 32 | 33 | config_manager = get_config_manager() 34 | asyncio.run(config_manager.load()) 35 | shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id()) 36 | 37 | if not description: 38 | console.print( 39 | '❌ Please add your feedback (shotgun feedback "").', 40 | style="red", 41 | ) 42 | raise typer.Exit(1) 43 | 44 | feedback = Feedback( 45 | kind=kind, description=description, shotgun_instance_id=shotgun_instance_id 46 | ) 47 | 48 | submit_feedback_survey(feedback) 49 | 50 | console.print("Feedback sent. Thank you!") 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug or unexpected behavior in Shotgun 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## What happened? 10 | 11 | 12 | 13 | 14 | 15 | ## What did you expect to happen? 16 | 17 | 18 | 19 | 20 | 21 | ## How can I reproduce this issue? 22 | 23 | 24 | 25 | 1. 26 | 2. 27 | 3. 28 | 4. 29 | 30 | 31 | 32 | ## Screenshots 33 | 34 | 35 | 36 | 37 | 38 | ## Environment Details 39 | 40 | **Operating System:** 41 | 42 | 43 | 44 | **Installation Method:** 45 | 46 | 47 | 48 | **Shotgun Version:** 49 | 50 | ``` 51 | shotgun --version 52 | ``` 53 | 54 | **Python Version:** 55 | 56 | ``` 57 | python --version 58 | ``` 59 | 60 | 61 | ## Additional Context 62 | 63 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /src/shotgun/utils/env_utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for working with environment variables.""" 2 | 3 | 4 | def is_shotgun_account_enabled() -> bool: 5 | """Check if Shotgun Account feature is enabled via environment variable. 6 | 7 | Returns: 8 | True always (Shotgun Account is now live for all users) 9 | 10 | Note: 11 | This function is deprecated and always returns True. 12 | Shotgun Account is now available to all users by default. 13 | """ 14 | return True 15 | 16 | 17 | def is_truthy(value: str | None) -> bool: 18 | """Check if a string value represents true. 19 | 20 | Args: 21 | value: String value to check (e.g., from environment variable) 22 | 23 | Returns: 24 | True if value is "true", "1", or "yes" (case-insensitive) 25 | False otherwise (including None, empty string, or any other value) 26 | """ 27 | if not value: 28 | return False 29 | return value.lower() in ("true", "1", "yes") 30 | 31 | 32 | def is_falsy(value: str | None) -> bool: 33 | """Check if a string value explicitly represents false. 34 | 35 | Args: 36 | value: String value to check (e.g., from environment variable) 37 | 38 | Returns: 39 | True if value is "false", "0", or "no" (case-insensitive) 40 | False otherwise (including None, empty string, or any other value) 41 | 42 | Note: 43 | This is NOT the opposite of is_truthy(). A value can be neither 44 | truthy nor falsy (e.g., None, "", "maybe", etc.) 45 | """ 46 | if not value: 47 | return False 48 | return value.lower() in ("false", "0", "no") 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Request 3 | about: Report missing or unclear documentation 4 | title: '[DOCS] ' 5 | labels: documentation 6 | assignees: '' 7 | --- 8 | 9 | ## What documentation is missing or unclear? 10 | 11 | 16 | 17 | 18 | 19 | ## Where did you look? 20 | 21 | 26 | 27 | 28 | 29 | ## What would you like to see documented or clarified? 30 | 31 | 36 | 37 | 38 | 39 | ## Suggested Improvements 40 | 41 | 47 | 48 | 49 | 50 | ## Additional Context 51 | 52 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/shotgun/cli/spec/models.py: -------------------------------------------------------------------------------- 1 | """Pydantic models for spec CLI commands.""" 2 | 3 | from datetime import datetime 4 | from enum import StrEnum 5 | 6 | from pydantic import BaseModel, Field 7 | 8 | 9 | class PullSource(StrEnum): 10 | """Source of spec pull operation for analytics.""" 11 | 12 | CLI = "cli" 13 | TUI = "tui" 14 | 15 | 16 | class PullPhase(StrEnum): 17 | """Phases during spec pull operation for analytics.""" 18 | 19 | STARTING = "starting" 20 | FETCHING = "fetching" 21 | BACKUP = "backup" 22 | DOWNLOADING = "downloading" 23 | FINALIZING = "finalizing" 24 | 25 | 26 | class SpecMeta(BaseModel): 27 | """Metadata stored in .shotgun/meta.json after pulling a spec. 28 | 29 | This file tracks the source of the local spec files and is used 30 | by the TUI to display version information and enable future sync operations. 31 | """ 32 | 33 | version_id: str = Field(description="Pulled version UUID") 34 | spec_id: str = Field(description="Spec UUID") 35 | spec_name: str = Field(description="Spec name at time of pull") 36 | workspace_id: str = Field(description="Workspace UUID") 37 | is_latest: bool = Field( 38 | description="Whether this was the latest version when pulled" 39 | ) 40 | pulled_at: datetime = Field(description="Timestamp when spec was pulled (UTC)") 41 | backup_path: str | None = Field( 42 | default=None, 43 | description="Path where previous .shotgun/ files were backed up", 44 | ) 45 | web_url: str | None = Field( 46 | default=None, 47 | description="URL to view this version in the web UI", 48 | ) 49 | -------------------------------------------------------------------------------- /src/shotgun/prompts/agents/partials/content_formatting.j2: -------------------------------------------------------------------------------- 1 | 2 | Always use professionaly formatted markdown for the section content with proper headings and subheadings so it's easy to read and understand. 3 | AVOID using --- line dividers in the section content. 4 | When formatting code use full Markdown code blocks (```) and format them with proper language identifier for code parts longer than a line. 5 | For short code parts like that that go into a sentence, use Markdown `class Foo` syntax instead of code blocks. 6 | Make text easier to read by bolding important parts of text and use links for external references. 7 | Use Emojis sparingly. 8 | 9 | 10 | 11 | 12 | To visualize in your artifacts, you can use all of the following mermaid features: 13 | * Flowchart 14 | * Sequence Diagram 15 | * Class Diagram 16 | * State Diagram 17 | * Entity Relationship Diagram 18 | * User Journey 19 | * Gantt 20 | * Pie Chart 21 | * Quadrant Chart 22 | * Requirement Diagram 23 | * GitGraph (Git) Diagram 24 | * C4 Diagram (but avoid using "all" as a context) 25 | * Mindmaps 26 | * Timeline 27 | * ZenUML 28 | * Sankey 29 | * XY Chart 30 | * Block Diagram 31 | * Packet 32 | * Kanban 33 | * Architecture 34 | * Radar 35 | * Treemap 36 | 37 | 38 | Avoid 'as' in diagrams 39 | AVOID using "FOO as BAR" in the diagrams. 40 | AVOID using <>, <> and <> in the diagrams and similar. 41 | AVOID using custom stereotype syntax in the diagrams, like <<(L,#6fa8dc)>>. 42 | AVOID using ";" in the diagrams. 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/shotgun/prompts/agents/state/system_state.j2: -------------------------------------------------------------------------------- 1 | 2 | Your training data may be old. The current date and time is: {{ current_datetime }} in {{ timezone_name }} (UTC{{ utc_offset }}) 3 | 4 | 5 | {% include 'agents/state/codebase/codebase_graphs_available.j2' %} 6 | 7 | {% if execution_plan %} 8 | 9 | {{ execution_plan }} 10 | 11 | 12 | {% if pending_approval %} 13 | 14 | The current plan is pending approval for the user. 15 | The plan above requires user approval before execution can begin. 16 | You MUST call `final_result` now to present this plan to the user. 17 | Do NOT attempt to delegate to any sub-agents until the user approves. 18 | 19 | {% endif %} 20 | 21 | {% endif %} 22 | 23 | 24 | {% if existing_files %} 25 | The following files already exist. 26 | Before doing a web search check the information in these files before continuing. 27 | Your working files are: 28 | {% for file in existing_files %} 29 | - `{{ file }}` 30 | {% endfor %} 31 | {% else %} 32 | No research or planning documents exist yet. Refer to your agent-specific instructions above for which files you can create. 33 | {% endif %} 34 | 35 | {% if markdown_toc %} 36 | 37 | {{ markdown_toc }} 38 | 39 | 40 | It is imporant that TABLE_OF_CONTENTS shows ONLY the Table of Contents from prior stages in the pipeline. You must review this context before asking questions or creating new content. 41 | {% else %} 42 | Review the existing documents above before adding new content to avoid duplication. 43 | {% endif %} 44 | -------------------------------------------------------------------------------- /test/unit/codebase/tools/test_query_graph.py: -------------------------------------------------------------------------------- 1 | """Tests for query_graph tool.""" 2 | 3 | import pytest 4 | 5 | from shotgun.agents.tools.codebase import QueryGraphResult, query_graph 6 | from shotgun.codebase.models import QueryResult 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_query_graph_success(mock_run_context, mock_codebase_service): 11 | """Test successful graph query.""" 12 | # Setup mock response 13 | query_result = QueryResult( 14 | query="test query", 15 | cypher_query="MATCH (n) RETURN n", 16 | results=[{"name": "TestClass", "type": "class"}], 17 | column_names=["name", "type"], 18 | row_count=1, 19 | execution_time_ms=50.0, 20 | success=True, 21 | error=None, 22 | ) 23 | mock_codebase_service.execute_query.return_value = query_result 24 | 25 | # Execute 26 | result = await query_graph(mock_run_context, "graph-id", "test query") 27 | 28 | # Verify 29 | assert isinstance(result, QueryGraphResult) 30 | assert result.success is True 31 | assert result.query == "test query" 32 | assert result.cypher_query == "MATCH (n) RETURN n" 33 | assert result.row_count == 1 34 | assert "TestClass" in str(result) 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_query_graph_no_service(mock_run_context): 39 | """Test query_graph with no codebase service.""" 40 | mock_run_context.deps.codebase_service = None 41 | 42 | result = await query_graph(mock_run_context, "graph-id", "test query") 43 | 44 | assert isinstance(result, QueryGraphResult) 45 | assert result.success is False 46 | assert result.error and "No codebase indexed" in result.error 47 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/shared_specs/models.py: -------------------------------------------------------------------------------- 1 | """Pydantic models for shared specs TUI screens.""" 2 | 3 | from enum import StrEnum 4 | 5 | from pydantic import BaseModel 6 | 7 | 8 | class ShareSpecsAction(StrEnum): 9 | """Actions from share specs dialog.""" 10 | 11 | CREATE = "create" 12 | ADD_VERSION = "add_version" 13 | 14 | 15 | class UploadScreenResult(BaseModel): 16 | """Result from UploadProgressScreen. 17 | 18 | Attributes: 19 | success: Whether the upload completed successfully 20 | web_url: URL to view the spec version (on success) 21 | cancelled: Whether the upload was cancelled 22 | """ 23 | 24 | success: bool 25 | web_url: str | None = None 26 | cancelled: bool = False 27 | 28 | 29 | class ShareSpecsResult(BaseModel): 30 | """Result from ShareSpecsDialog. 31 | 32 | Attributes: 33 | action: CREATE to create new spec, ADD_VERSION to add to existing, None if cancelled 34 | workspace_id: Workspace ID (fetched by dialog) 35 | spec_id: Spec ID if adding version to existing spec 36 | spec_name: Spec name if adding version to existing spec 37 | """ 38 | 39 | action: ShareSpecsAction | None = None 40 | workspace_id: str | None = None 41 | spec_id: str | None = None 42 | spec_name: str | None = None 43 | 44 | 45 | class CreateSpecResult(BaseModel): 46 | """Result from CreateSpecDialog. 47 | 48 | Attributes: 49 | name: Spec name (required) 50 | description: Optional description 51 | is_public: Whether spec should be public (default: False) 52 | """ 53 | 54 | name: str 55 | description: str | None = None 56 | is_public: bool = False 57 | -------------------------------------------------------------------------------- /test/unit/codebase/tools/test_directory_lister.py: -------------------------------------------------------------------------------- 1 | """Tests for directory_lister tool.""" 2 | 3 | import pytest 4 | 5 | from shotgun.agents.tools.codebase import DirectoryListResult, directory_lister 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_directory_lister_success( 10 | mock_run_context, mock_codebase_service, mock_graph, tmp_path 11 | ): 12 | """Test successful directory listing.""" 13 | # Create test directory structure 14 | (tmp_path / "subdir").mkdir() 15 | (tmp_path / "file.py").write_text("test") 16 | (tmp_path / "README.md").write_text("readme") 17 | 18 | mock_graph.repo_path = str(tmp_path) 19 | mock_codebase_service.list_graphs.return_value = [mock_graph] 20 | 21 | # Execute 22 | result = await directory_lister(mock_run_context, "test-graph-id", ".") 23 | 24 | # Verify 25 | assert isinstance(result, DirectoryListResult) 26 | assert result.success is True 27 | assert result.directory == "." 28 | assert "subdir" in result.directories 29 | assert any(fname == "file.py" for fname, _ in result.files) 30 | assert "📁 subdir/" in str(result) 31 | assert "📄 file.py" in str(result) 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_directory_lister_security_violation( 36 | mock_run_context, mock_codebase_service, mock_graph, tmp_path 37 | ): 38 | """Test directory_lister prevents path traversal.""" 39 | mock_graph.repo_path = str(tmp_path) 40 | mock_codebase_service.list_graphs.return_value = [mock_graph] 41 | 42 | result = await directory_lister(mock_run_context, "test-graph-id", "../../../etc") 43 | 44 | assert isinstance(result, DirectoryListResult) 45 | assert result.success is False 46 | assert result.error and "Access denied" in result.error 47 | -------------------------------------------------------------------------------- /test/integration/codebase/tools/test_query_graph.py: -------------------------------------------------------------------------------- 1 | """Integration tests for query_graph codebase tool.""" 2 | 3 | import pytest 4 | from pydantic_ai import RunContext 5 | 6 | from shotgun.agents.models import AgentDeps 7 | from shotgun.agents.tools.codebase import query_graph 8 | from shotgun.codebase.models import CodebaseGraph 9 | 10 | 11 | @pytest.mark.integration 12 | @pytest.mark.asyncio 13 | async def test_query_graph_invalid_graph_id( 14 | run_context: RunContext[AgentDeps], 15 | ): 16 | """Test querying with invalid graph ID.""" 17 | result = await query_graph( 18 | ctx=run_context, 19 | graph_id="nonexistent-graph-id", 20 | query="What classes are in this codebase?", 21 | ) 22 | 23 | # Should fail gracefully 24 | assert result.success is False 25 | assert result.error is not None 26 | assert "not found" in result.error.lower() or "nonexistent" in result.error.lower() 27 | 28 | 29 | @pytest.mark.integration 30 | @pytest.mark.asyncio 31 | async def test_query_graph_no_codebase_service( 32 | indexed_graph: CodebaseGraph, 33 | ): 34 | """Test querying when no codebase service is available.""" 35 | 36 | # Create run context without codebase service 37 | class MockRunContextNoService: 38 | def __init__(self): 39 | self.deps = type("MockDeps", (), {"codebase_service": None})() 40 | 41 | context = MockRunContextNoService() 42 | 43 | result = await query_graph( 44 | ctx=context, 45 | graph_id=indexed_graph.graph_id, 46 | query="What classes are in this codebase?", 47 | ) 48 | 49 | # Should fail gracefully 50 | assert result.success is False 51 | assert result.error is not None 52 | assert "codebase service" in result.error.lower() 53 | -------------------------------------------------------------------------------- /src/shotgun/utils/file_system_utils.py: -------------------------------------------------------------------------------- 1 | """File system utility functions.""" 2 | 3 | from pathlib import Path 4 | 5 | import aiofiles 6 | 7 | from shotgun.settings import settings 8 | 9 | 10 | def get_shotgun_base_path() -> Path: 11 | """Get the absolute path to the .shotgun directory.""" 12 | return Path.cwd() / ".shotgun" 13 | 14 | 15 | def get_shotgun_home() -> Path: 16 | """Get the Shotgun home directory path. 17 | 18 | Can be overridden with SHOTGUN_HOME environment variable for testing. 19 | 20 | Returns: 21 | Path to shotgun home directory (default: ~/.shotgun-sh/) 22 | """ 23 | # Allow override via environment variable (useful for testing) 24 | if custom_home := settings.dev.home: 25 | return Path(custom_home) 26 | 27 | return Path.home() / ".shotgun-sh" 28 | 29 | 30 | def ensure_shotgun_directory_exists() -> Path: 31 | """Ensure the .shotgun directory exists and return its path. 32 | 33 | Returns: 34 | Path: The path to the .shotgun directory. 35 | """ 36 | shotgun_dir = get_shotgun_base_path() 37 | shotgun_dir.mkdir(exist_ok=True) 38 | # Note: Removed logger to avoid circular dependency with logging_config 39 | return shotgun_dir 40 | 41 | 42 | async def async_copy_file(src: Path, dst: Path) -> None: 43 | """Asynchronously copy a file from src to dst. 44 | 45 | Args: 46 | src: Source file path 47 | dst: Destination file path 48 | 49 | Raises: 50 | FileNotFoundError: If source file doesn't exist 51 | OSError: If copy operation fails 52 | """ 53 | async with aiofiles.open(src, "rb") as src_file: 54 | content = await src_file.read() 55 | async with aiofiles.open(dst, "wb") as dst_file: 56 | await dst_file.write(content) 57 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat/help_text.py: -------------------------------------------------------------------------------- 1 | """Helper functions for chat screen help text.""" 2 | 3 | 4 | def help_text_with_codebase(already_indexed: bool = False) -> str: 5 | """Generate help text for when a codebase is available. 6 | 7 | Args: 8 | already_indexed: Whether the codebase is already indexed. 9 | 10 | Returns: 11 | Formatted help text string. 12 | """ 13 | return ( 14 | "Howdy! Welcome to Shotgun - Spec Driven Development for Developers and AI Agents.\n\n" 15 | "Shotgun writes codebase-aware specs for your AI coding agents so they don't derail.\n\n" 16 | f"{'It' if already_indexed else 'Once your codebase is indexed, it'} can help you:\n" 17 | "- Research your codebase and spec out new features\n" 18 | "- Create implementation plans that fit your architecture\n" 19 | "- Generate AGENTS.md files for AI coding agents\n" 20 | "- Onboard to existing projects or plan refactors\n\n" 21 | "Ready to build something? Let's go.\n" 22 | ) 23 | 24 | 25 | def help_text_empty_dir() -> str: 26 | """Generate help text for empty directory. 27 | 28 | Returns: 29 | Formatted help text string. 30 | """ 31 | return ( 32 | "Howdy! Welcome to Shotgun - Spec Driven Development for Developers and AI Agents.\n\n" 33 | "Shotgun writes codebase-aware specs for your AI coding agents so they don't derail.\n\n" 34 | "It can help you:\n" 35 | "- Research your codebase and spec out new features\n" 36 | "- Create implementation plans that fit your architecture\n" 37 | "- Generate AGENTS.md files for AI coding agents\n" 38 | "- Onboard to existing projects or plan refactors\n\n" 39 | "Ready to build something? Let's go.\n" 40 | ) 41 | -------------------------------------------------------------------------------- /src/shotgun/cli/clear.py: -------------------------------------------------------------------------------- 1 | """Clear command for shotgun CLI.""" 2 | 3 | import asyncio 4 | from pathlib import Path 5 | 6 | import typer 7 | from rich.console import Console 8 | 9 | from shotgun.agents.conversation import ConversationManager 10 | from shotgun.logging_config import get_logger 11 | 12 | app = typer.Typer( 13 | name="clear", help="Clear the conversation history", no_args_is_help=False 14 | ) 15 | logger = get_logger(__name__) 16 | console = Console() 17 | 18 | 19 | @app.callback(invoke_without_command=True) 20 | def clear() -> None: 21 | """Clear the current conversation history. 22 | 23 | This command deletes the conversation file at ~/.shotgun-sh/conversation.json, 24 | removing all conversation history. Other files in ~/.shotgun-sh/ (config, usage, 25 | codebases, logs) are preserved. 26 | """ 27 | try: 28 | # Get conversation file path 29 | conversation_file = Path.home() / ".shotgun-sh" / "conversation.json" 30 | 31 | # Check if file exists 32 | if not conversation_file.exists(): 33 | console.print( 34 | "[yellow]No conversation file found.[/yellow] Nothing to clear.", 35 | style="bold", 36 | ) 37 | return 38 | 39 | # Clear the conversation 40 | manager = ConversationManager(conversation_file) 41 | asyncio.run(manager.clear()) 42 | 43 | console.print( 44 | "[green]✓[/green] Conversation cleared successfully", style="bold" 45 | ) 46 | logger.info("Conversation cleared successfully") 47 | 48 | except Exception as e: 49 | console.print( 50 | f"[red]Error:[/red] Failed to clear conversation: {e}", style="bold" 51 | ) 52 | logger.debug("Full traceback:", exc_info=True) 53 | raise typer.Exit(code=1) from e 54 | -------------------------------------------------------------------------------- /src/shotgun/tui/components/status_bar.py: -------------------------------------------------------------------------------- 1 | """Widget to display the status bar with contextual help text.""" 2 | 3 | from textual.widget import Widget 4 | 5 | from shotgun.tui.protocols import QAStateProvider 6 | 7 | 8 | class StatusBar(Widget): 9 | """Widget to display the status bar with contextual help text.""" 10 | 11 | DEFAULT_CSS = """ 12 | StatusBar { 13 | text-wrap: wrap; 14 | padding-left: 1; 15 | } 16 | """ 17 | 18 | def __init__(self, working: bool = False) -> None: 19 | """Initialize the status bar. 20 | 21 | Args: 22 | working: Whether an agent is currently working. 23 | """ 24 | super().__init__() 25 | self.working = working 26 | 27 | def render(self) -> str: 28 | """Render the status bar with contextual help text.""" 29 | # Check if in Q&A mode first (highest priority) 30 | if isinstance(self.screen, QAStateProvider) and self.screen.qa_mode: 31 | return ( 32 | "[$foreground-muted][bold $text]esc[/] to exit Q&A mode • " 33 | "[bold $text]enter[/] to send answer • [bold $text]ctrl+j[/] for newline[/]" 34 | ) 35 | 36 | if self.working: 37 | return ( 38 | "[$foreground-muted][bold $text]esc[/] to stop • " 39 | "[bold $text]enter[/] to send • [bold $text]ctrl+j[/] for newline • " 40 | "[bold $text]ctrl+p[/] command palette • [bold $text]shift+tab[/] toggle mode • " 41 | "/help for commands[/]" 42 | ) 43 | else: 44 | return ( 45 | "[$foreground-muted][bold $text]enter[/] to send • " 46 | "[bold $text]ctrl+j[/] for newline • [bold $text]ctrl+p[/] command palette • " 47 | "[bold $text]shift+tab[/] toggle mode • /help for commands[/]" 48 | ) 49 | -------------------------------------------------------------------------------- /src/shotgun/tui/filtered_codebase_service.py: -------------------------------------------------------------------------------- 1 | """Filtered codebase service that restricts access to current directory's codebase only.""" 2 | 3 | from pathlib import Path 4 | 5 | from shotgun.codebase.models import CodebaseGraph 6 | from shotgun.codebase.service import CodebaseService 7 | 8 | 9 | class FilteredCodebaseService(CodebaseService): 10 | """CodebaseService subclass that filters graphs to only those accessible from CWD. 11 | 12 | This ensures TUI agents can only see and access the codebase indexed from the 13 | current working directory, providing isolation between different project directories. 14 | """ 15 | 16 | def __init__(self, storage_dir: Path | str): 17 | """Initialize the filtered service. 18 | 19 | Args: 20 | storage_dir: Directory to store graph databases 21 | """ 22 | super().__init__(storage_dir) 23 | self._cwd = str(Path.cwd().resolve()) 24 | 25 | async def list_graphs(self) -> list[CodebaseGraph]: 26 | """List only graphs accessible from the current working directory. 27 | 28 | Returns: 29 | Filtered list of CodebaseGraph objects accessible from CWD 30 | """ 31 | # Use the existing filtering logic from list_graphs_for_directory 32 | return await super().list_graphs_for_directory(self._cwd) 33 | 34 | async def list_graphs_for_directory( 35 | self, directory: Path | str | None = None 36 | ) -> list[CodebaseGraph]: 37 | """List graphs for directory - always filters to CWD for TUI context. 38 | 39 | Args: 40 | directory: Ignored in TUI context, always uses CWD 41 | 42 | Returns: 43 | Filtered list of CodebaseGraph objects accessible from CWD 44 | """ 45 | # Always use CWD regardless of what directory is passed 46 | return await super().list_graphs_for_directory(self._cwd) 47 | -------------------------------------------------------------------------------- /test/integration/sdk/test_sdk_services.py: -------------------------------------------------------------------------------- 1 | """Integration tests for SDK service factory functions.""" 2 | 3 | import pytest 4 | 5 | from shotgun.sdk.services import get_codebase_service 6 | 7 | 8 | @pytest.mark.integration 9 | def test_get_codebase_service_default_storage(isolated_shotgun_home): 10 | """Test get_codebase_service with default storage directory.""" 11 | service = get_codebase_service() 12 | 13 | assert service is not None 14 | # Should use the test home directory 15 | assert service.storage_dir.parent == isolated_shotgun_home 16 | assert service.storage_dir.name == "codebases" 17 | assert service.storage_dir.exists() # Directory should be created 18 | 19 | 20 | @pytest.mark.integration 21 | def test_get_codebase_service_custom_storage(temp_storage_dir): 22 | """Test get_codebase_service with custom storage directory.""" 23 | custom_dir = temp_storage_dir / "custom_storage" 24 | service = get_codebase_service(custom_dir) 25 | 26 | assert service is not None 27 | assert service.storage_dir == custom_dir 28 | assert custom_dir.exists() 29 | 30 | 31 | @pytest.mark.integration 32 | def test_get_codebase_service_string_path(temp_storage_dir): 33 | """Test get_codebase_service with string path.""" 34 | custom_dir = temp_storage_dir / "string_storage" 35 | service = get_codebase_service(str(custom_dir)) 36 | 37 | assert service is not None 38 | assert service.storage_dir == custom_dir 39 | assert custom_dir.exists() 40 | 41 | 42 | @pytest.mark.integration 43 | @pytest.mark.asyncio 44 | async def test_service_functionality(temp_storage_dir): 45 | """Test that returned service is functional.""" 46 | custom_dir = temp_storage_dir / "functional_test" 47 | service = get_codebase_service(custom_dir) 48 | 49 | # Should be able to list graphs (empty initially) 50 | graphs = await service.list_graphs() 51 | assert graphs == [] 52 | -------------------------------------------------------------------------------- /test/unit/exceptions/test_context_size_exception.py: -------------------------------------------------------------------------------- 1 | """Unit tests for context size exception classes.""" 2 | 3 | from shotgun.exceptions import ContextSizeLimitExceeded, ErrorNotPickedUpBySentry 4 | 5 | 6 | def test_error_not_picked_up_by_sentry_is_exception(): 7 | """ErrorNotPickedUpBySentry should be an Exception.""" 8 | error = ErrorNotPickedUpBySentry("test message") 9 | assert isinstance(error, Exception) 10 | assert str(error) == "test message" 11 | 12 | 13 | def test_context_size_limit_exceeded_inheritance(): 14 | """ContextSizeLimitExceeded should inherit from ErrorNotPickedUpBySentry.""" 15 | error = ContextSizeLimitExceeded(model_name="test-model", max_tokens=1000) 16 | assert isinstance(error, ErrorNotPickedUpBySentry) 17 | assert isinstance(error, Exception) 18 | 19 | 20 | def test_context_size_limit_exceeded_attributes(): 21 | """ContextSizeLimitExceeded should store model_name and max_tokens.""" 22 | error = ContextSizeLimitExceeded(model_name="claude-sonnet-4.5", max_tokens=200000) 23 | 24 | assert error.model_name == "claude-sonnet-4.5" 25 | assert error.max_tokens == 200000 26 | 27 | 28 | def test_context_size_limit_exceeded_message_formatting(): 29 | """ContextSizeLimitExceeded should format message with commas.""" 30 | error = ContextSizeLimitExceeded(model_name="gpt-5", max_tokens=400000) 31 | 32 | message = str(error) 33 | assert "gpt-5" in message 34 | assert "400,000" in message # Should have comma formatting 35 | assert "limit" in message.lower() 36 | 37 | 38 | def test_context_size_limit_exceeded_message_small_number(): 39 | """ContextSizeLimitExceeded should format message correctly for small numbers.""" 40 | error = ContextSizeLimitExceeded(model_name="test-model", max_tokens=999) 41 | 42 | message = str(error) 43 | assert "test-model" in message 44 | assert "999" in message # No comma for numbers < 1000 45 | -------------------------------------------------------------------------------- /src/shotgun/codebase/core/cypher_models.py: -------------------------------------------------------------------------------- 1 | """Pydantic models and exceptions for Cypher query generation.""" 2 | 3 | from typing import Any 4 | 5 | from pydantic import BaseModel, Field 6 | 7 | 8 | class CypherGenerationResponse(BaseModel): 9 | """Structured response from LLM for Cypher query generation. 10 | 11 | This model ensures the LLM explicitly indicates whether it can generate 12 | a valid Cypher query and provides a reason if it cannot. 13 | """ 14 | 15 | cypher_query: str | None = Field( 16 | default=None, 17 | description="The generated Cypher query, or None if generation not possible", 18 | ) 19 | can_generate_valid_cypher: bool = Field( 20 | description="Whether a valid Cypher query can be generated for this request" 21 | ) 22 | reason_cannot_generate: str | None = Field( 23 | default=None, 24 | description="Explanation why query cannot be generated (if applicable)", 25 | ) 26 | 27 | def model_post_init(self, __context: Any) -> None: 28 | """Validate that reason is provided when query cannot be generated.""" 29 | if not self.can_generate_valid_cypher and not self.reason_cannot_generate: 30 | self.reason_cannot_generate = "No reason provided" 31 | if self.can_generate_valid_cypher and not self.cypher_query: 32 | raise ValueError( 33 | "cypher_query must be provided when can_generate_valid_cypher is True" 34 | ) 35 | 36 | 37 | class CypherGenerationNotPossibleError(Exception): 38 | """Raised when LLM cannot generate valid Cypher for the query. 39 | 40 | This typically happens when the query is conceptual rather than structural, 41 | or when it requires interpretation beyond what can be expressed in Cypher. 42 | """ 43 | 44 | def __init__(self, reason: str): 45 | self.reason = reason 46 | super().__init__(f"Cannot generate Cypher query: {reason}") 47 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat_screen/history/partial_response.py: -------------------------------------------------------------------------------- 1 | """Partial response widget for streaming chat messages.""" 2 | 3 | from pydantic_ai.messages import ModelMessage 4 | from textual.app import ComposeResult 5 | from textual.reactive import reactive 6 | from textual.widget import Widget 7 | 8 | from shotgun.tui.protocols import ActiveSubAgentProvider 9 | 10 | from .agent_response import AgentResponseWidget 11 | from .user_question import UserQuestionWidget 12 | 13 | 14 | class PartialResponseWidget(Widget): # TODO: doesn't work lol 15 | """Widget that displays a streaming/partial response in the chat history.""" 16 | 17 | DEFAULT_CSS = """ 18 | PartialResponseWidget { 19 | height: auto; 20 | } 21 | Markdown, AgentResponseWidget, UserQuestionWidget { 22 | height: auto; 23 | } 24 | """ 25 | 26 | item: reactive[ModelMessage | None] = reactive(None, recompose=True) 27 | 28 | def __init__(self, item: ModelMessage | None) -> None: 29 | super().__init__() 30 | self.item = item 31 | 32 | def _is_sub_agent_active(self) -> bool: 33 | """Check if a sub-agent is currently active.""" 34 | if isinstance(self.screen, ActiveSubAgentProvider): 35 | return self.screen.active_sub_agent is not None 36 | return False 37 | 38 | def compose(self) -> ComposeResult: 39 | if self.item is None: 40 | pass 41 | elif self.item.kind == "response": 42 | yield AgentResponseWidget( 43 | self.item, is_sub_agent=self._is_sub_agent_active() 44 | ) 45 | elif self.item.kind == "request": 46 | yield UserQuestionWidget(self.item) 47 | 48 | def watch_item(self, item: ModelMessage | None) -> None: 49 | """React to changes in the item.""" 50 | if item is None: 51 | self.display = False 52 | else: 53 | self.display = True 54 | -------------------------------------------------------------------------------- /test/unit/shotgun_web/test_supabase_client.py: -------------------------------------------------------------------------------- 1 | """Tests for shotgun_web.supabase_client module.""" 2 | 3 | from unittest.mock import AsyncMock, MagicMock, patch 4 | 5 | import httpx 6 | import pytest 7 | 8 | from shotgun.shotgun_web.supabase_client import download_file_from_url 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_download_file_from_url_success(): 13 | """Test download_file_from_url returns file content.""" 14 | mock_response = MagicMock() 15 | mock_response.content = b"Test file content" 16 | mock_response.raise_for_status = MagicMock() 17 | 18 | mock_client = AsyncMock() 19 | mock_client.get = AsyncMock(return_value=mock_response) 20 | 21 | with patch( 22 | "httpx.AsyncClient", 23 | return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_client)), 24 | ): 25 | result = await download_file_from_url( 26 | "https://storage.example.com/file.md?token=abc123" 27 | ) 28 | 29 | assert result == b"Test file content" 30 | mock_client.get.assert_called_once_with( 31 | "https://storage.example.com/file.md?token=abc123" 32 | ) 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_download_file_from_url_http_error(): 37 | """Test download_file_from_url propagates HTTP errors.""" 38 | mock_response = MagicMock() 39 | mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( 40 | "Not Found", 41 | request=MagicMock(), 42 | response=MagicMock(status_code=404), 43 | ) 44 | 45 | mock_client = AsyncMock() 46 | mock_client.get = AsyncMock(return_value=mock_response) 47 | 48 | with patch( 49 | "httpx.AsyncClient", 50 | return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_client)), 51 | ): 52 | with pytest.raises(httpx.HTTPStatusError): 53 | await download_file_from_url( 54 | "https://storage.example.com/nonexistent.md?token=abc123" 55 | ) 56 | -------------------------------------------------------------------------------- /test/unit/cli/spec/test_models.py: -------------------------------------------------------------------------------- 1 | """Tests for CLI spec models.""" 2 | 3 | from datetime import datetime, timezone 4 | 5 | from shotgun.cli.spec.models import SpecMeta 6 | 7 | 8 | def test_spec_meta_creation() -> None: 9 | """Test SpecMeta model creation.""" 10 | now = datetime.now(timezone.utc) 11 | meta = SpecMeta( 12 | version_id="version-123", 13 | spec_id="spec-456", 14 | spec_name="My Test Spec", 15 | workspace_id="workspace-789", 16 | is_latest=True, 17 | pulled_at=now, 18 | ) 19 | 20 | assert meta.version_id == "version-123" 21 | assert meta.spec_id == "spec-456" 22 | assert meta.spec_name == "My Test Spec" 23 | assert meta.workspace_id == "workspace-789" 24 | assert meta.is_latest is True 25 | assert meta.pulled_at == now 26 | 27 | 28 | def test_spec_meta_serialization() -> None: 29 | """Test SpecMeta model serialization to JSON.""" 30 | now = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc) 31 | meta = SpecMeta( 32 | version_id="version-123", 33 | spec_id="spec-456", 34 | spec_name="My Test Spec", 35 | workspace_id="workspace-789", 36 | is_latest=False, 37 | pulled_at=now, 38 | ) 39 | 40 | json_str = meta.model_dump_json(indent=2) 41 | assert '"version_id": "version-123"' in json_str 42 | assert '"spec_name": "My Test Spec"' in json_str 43 | assert '"is_latest": false' in json_str 44 | 45 | 46 | def test_spec_meta_from_json() -> None: 47 | """Test SpecMeta model deserialization from JSON.""" 48 | json_str = """{ 49 | "version_id": "v-id", 50 | "spec_id": "s-id", 51 | "spec_name": "Test", 52 | "workspace_id": "w-id", 53 | "is_latest": true, 54 | "pulled_at": "2024-01-15T10:30:00Z" 55 | }""" 56 | 57 | meta = SpecMeta.model_validate_json(json_str) 58 | assert meta.version_id == "v-id" 59 | assert meta.spec_name == "Test" 60 | assert meta.is_latest is True 61 | -------------------------------------------------------------------------------- /evals/datasets/router_agent/planning_cases.py: -------------------------------------------------------------------------------- 1 | """ 2 | Router agent test cases for planning behavior. 3 | 4 | Tests that the Router asks clarifying questions or creates plans 5 | based on request specificity. 6 | """ 7 | 8 | from evals.models import ( 9 | AgentType, 10 | ExpectedAgentOutput, 11 | ShotgunTestCase, 12 | TestCaseContext, 13 | TestCaseInput, 14 | ) 15 | 16 | FEATURE_REQUEST_ASKS_QUESTIONS = ShotgunTestCase( 17 | name="feature_request_asks_questions", 18 | inputs=TestCaseInput( 19 | prompt="I want to write a spec for adding support for local models to this project", 20 | agent_type=AgentType.ROUTER, 21 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"), 22 | ), 23 | expected=ExpectedAgentOutput(min_clarifying_questions=1), 24 | ) 25 | 26 | COMPLEX_FEATURE_ASKS_QUESTIONS = ShotgunTestCase( 27 | name="complex_feature_asks_questions", 28 | inputs=TestCaseInput( 29 | prompt="Add authentication to this project", 30 | agent_type=AgentType.ROUTER, 31 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"), 32 | ), 33 | expected=ExpectedAgentOutput(min_clarifying_questions=1), 34 | ) 35 | 36 | SPECIFIC_FEATURE_CREATES_PLAN = ShotgunTestCase( 37 | name="specific_feature_creates_plan", 38 | inputs=TestCaseInput( 39 | prompt="I want to add Ollama support for local model inference. Research what's needed and write a spec for it.", 40 | agent_type=AgentType.ROUTER, 41 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"), 42 | ), 43 | expected=ExpectedAgentOutput( 44 | expected_tools=["create_plan"], 45 | expected_response="Plan should have research as the first step and writing a specification as the second step", 46 | ), 47 | ) 48 | 49 | PLANNING_CASES: list[ShotgunTestCase] = [ 50 | FEATURE_REQUEST_ASKS_QUESTIONS, 51 | COMPLEX_FEATURE_ASKS_QUESTIONS, 52 | SPECIFIC_FEATURE_CREATES_PLAN, 53 | ] 54 | -------------------------------------------------------------------------------- /src/shotgun/prompts/codebase/partials/graph_schema.j2: -------------------------------------------------------------------------------- 1 | 2 | # Codebase Graph Schema Definition 3 | The database contains information about a codebase, structured with the following nodes and relationships. 4 | 5 | THE ONLY PROPERTIES THAT ARE AVAILABLE ARE THE ONES LISTED BELOW. DO NOT MAKE UP ANY OTHER PROPERTIES. 6 | PAY ATTENTION TO THE PROPERTY TYPES. 7 | - Project: {name: string} 8 | - Package: {qualified_name: string, name: string, path: string} 9 | - Folder: {path: string, name: string} 10 | - File: {path: string, name: string, extension: string} // Note: extension includes the dot (e.g., ".ts", ".py", ".js") 11 | - FileMetadata: {filepath: string, mtime: int64, hash: string, last_updated: int64} 12 | - Module: {qualified_name: string, name: string, path: string, created_at: int64, updated_at: int64} 13 | - Class: {qualified_name: string, name: string, decorators: list[string], line_start: int, line_end: int, created_at: int64, updated_at: int64} 14 | - Function: {qualified_name: string, name: string, decorators: list[string], line_start: int, line_end: int, created_at: int64, updated_at: int64} 15 | - Method: {qualified_name: string, name: string, decorators: list[string], line_start: int, line_end: int, created_at: int64, updated_at: int64} 16 | - ExternalPackage: {name: string, version_spec: string} 17 | - DeletionLog: {id: string, entity_type: string, entity_qualified_name: string, deleted_from_file: string, deleted_at: int64, deletion_reason: string} 18 | 19 | Relationships (source)-[REL_TYPE]->(target): 20 | - (Project|Package|Folder) -[:CONTAINS_PACKAGE|CONTAINS_FOLDER|CONTAINS_FILE|CONTAINS_MODULE]-> (various) 21 | - Module -[:DEFINES]-> (Class|Function) 22 | - Class -[:DEFINES_METHOD]-> Method 23 | - (Child Class) -[:INHERITS]-> (Parent Class) 24 | - Method -[:OVERRIDES]-> Method 25 | - Project -[:DEPENDS_ON_EXTERNAL]-> ExternalPackage 26 | - (Function|Method) -[:CALLS]-> (Function|Method) 27 | - FileMetadata -[:TRACKS_Module]-> Module 28 | - FileMetadata -[:TRACKS_Class]-> Class 29 | - FileMetadata -[:TRACKS_Function]-> Function 30 | - FileMetadata -[:TRACKS_Method]-> Method -------------------------------------------------------------------------------- /src/shotgun/prompts/agents/partials/common_agent_system_prompt.j2: -------------------------------------------------------------------------------- 1 | You are an experienced Software Architect with experience in Business Analysis, Product Management, Software Architecture, and Software Development 2 | 3 | ## YOUR ROLE IN THE PIPELINE 4 | 5 | 6 | It is critical that you understand you are a DOCUMENTATION and PLANNING agent, NOT a coding/implementation agent. 7 | You produce DOCUMENTS (research, specifications, plans, tasks) that AI coding agents will consume 8 | You do NOT try to write production code, implement features, or make code changes. 9 | NEVER offer to "move forward with implementation" or "start coding" - that's not your job 10 | NEVER ask "would you like me to implement this?" - implementation is done by separate AI coding tools 11 | Your deliverable is always a document file (.md), not code execution 12 | When your work is complete, the user will take your documents to a coding agent (Claude Code, Cursor, etc.) 13 | 14 | 15 | 16 | {% if interactive_mode %} 17 | Ask CLARIFYING QUESTIONS using structured output for complex or multi-step tasks when the request lacks sufficient detail. 18 | - Return your response with the clarifying_questions field populated 19 | - For simple, straightforward requests, make reasonable assumptions and proceed. 20 | - Only ask the most critical questions to avoid overwhelming the user. 21 | - Questions should be clear, specific, and answerable 22 | {% endif %} 23 | Always prioritize user needs and provide actionable assistance. 24 | Avoid redundant work by check existing files in the .shotgun/ and conversation history. 25 | If the user seems not to know something, always be creative and come up with ideas that fit their thinking. 26 | DO NOT repeat yourself. 27 | If a user has agreed to a plan, you DO NOT NEED TO FOLLOW UP with them after every step to ask "is this search query ok?" just execute the plan. 28 | 29 | 30 | {% include 'agents/partials/codebase_understanding.j2' %} 31 | 32 | {% include 'agents/partials/content_formatting.j2' %} 33 | 34 | {% include 'agents/partials/interactive_mode.j2' %} -------------------------------------------------------------------------------- /src/shotgun/shotgun_web/shared_specs/models.py: -------------------------------------------------------------------------------- 1 | """Pydantic models for the shared specs upload pipeline.""" 2 | 3 | from pydantic import BaseModel 4 | 5 | from shotgun.shotgun_web.models import FileMetadata 6 | from shotgun.shotgun_web.shared_specs.utils import UploadPhase 7 | 8 | 9 | class UploadProgress(BaseModel): 10 | """Progress information for the upload pipeline. 11 | 12 | Attributes: 13 | phase: Current phase of the pipeline 14 | current: Current item number in the phase 15 | total: Total items in the phase 16 | current_file: Name of the file currently being processed 17 | bytes_uploaded: Total bytes uploaded so far 18 | total_bytes: Total bytes to upload 19 | message: Human-readable status message 20 | """ 21 | 22 | phase: UploadPhase 23 | current: int = 0 24 | total: int = 0 25 | current_file: str | None = None 26 | bytes_uploaded: int = 0 27 | total_bytes: int = 0 28 | message: str = "" 29 | 30 | 31 | class UploadResult(BaseModel): 32 | """Result of the upload pipeline. 33 | 34 | Attributes: 35 | success: Whether the upload completed successfully 36 | web_url: URL to view the spec version (on success) 37 | error: Error message (on failure) 38 | files_uploaded: Number of files uploaded 39 | total_bytes: Total bytes uploaded 40 | """ 41 | 42 | success: bool 43 | web_url: str | None = None 44 | error: str | None = None 45 | files_uploaded: int = 0 46 | total_bytes: int = 0 47 | 48 | 49 | class FileWithHash(BaseModel): 50 | """File metadata with computed hash.""" 51 | 52 | metadata: FileMetadata 53 | content_hash: str = "" 54 | 55 | 56 | class UploadState(BaseModel): 57 | """Internal state for upload progress tracking.""" 58 | 59 | files_uploaded: int = 0 60 | bytes_uploaded: int = 0 61 | total_bytes: int = 0 62 | current_file: str | None = None 63 | hashes_completed: int = 0 64 | total_files: int = 0 65 | 66 | 67 | class ScanResult(BaseModel): 68 | """Result of scanning .shotgun/ directory.""" 69 | 70 | files: list[FileMetadata] 71 | total_files_before_filter: int 72 | -------------------------------------------------------------------------------- /test/unit/agents/config/test_models.py: -------------------------------------------------------------------------------- 1 | """Tests for agents.config.models module.""" 2 | 3 | from shotgun.agents.config.models import MODEL_SPECS, ModelName 4 | 5 | 6 | def test_model_spec_short_name_claude_sonnet(): 7 | """Test ModelSpec short_name for Claude Sonnet 4.5.""" 8 | spec = MODEL_SPECS[ModelName.CLAUDE_SONNET_4_5] 9 | assert spec.short_name == "Sonnet 4.5" 10 | 11 | 12 | def test_model_spec_short_name_claude_opus(): 13 | """Test ModelSpec short_name for Claude Opus 4.5.""" 14 | spec = MODEL_SPECS[ModelName.CLAUDE_OPUS_4_5] 15 | assert spec.short_name == "Opus 4.5" 16 | 17 | 18 | def test_model_spec_short_name_claude_haiku(): 19 | """Test ModelSpec short_name for Claude Haiku 4.5.""" 20 | spec = MODEL_SPECS[ModelName.CLAUDE_HAIKU_4_5] 21 | assert spec.short_name == "Haiku 4.5" 22 | 23 | 24 | def test_model_spec_short_name_gpt_5_1(): 25 | """Test ModelSpec short_name for GPT-5.1.""" 26 | spec = MODEL_SPECS[ModelName.GPT_5_1] 27 | assert spec.short_name == "GPT-5.1" 28 | 29 | 30 | def test_model_spec_short_name_gpt_5_2(): 31 | """Test ModelSpec short_name for GPT-5.2.""" 32 | spec = MODEL_SPECS[ModelName.GPT_5_2] 33 | assert spec.short_name == "GPT-5.2" 34 | 35 | 36 | def test_model_spec_short_name_gemini_pro(): 37 | """Test ModelSpec short_name for Gemini 2.5 Pro.""" 38 | spec = MODEL_SPECS[ModelName.GEMINI_2_5_PRO] 39 | assert spec.short_name == "Gemini 2.5 Pro" 40 | 41 | 42 | def test_model_spec_short_name_gemini_flash(): 43 | """Test ModelSpec short_name for Gemini 2.5 Flash.""" 44 | spec = MODEL_SPECS[ModelName.GEMINI_2_5_FLASH] 45 | assert spec.short_name == "Gemini 2.5 Flash" 46 | 47 | 48 | def test_all_models_have_short_name(): 49 | """Test that all ModelName enum values have a short_name in MODEL_SPECS.""" 50 | for model_name in ModelName: 51 | spec = MODEL_SPECS[model_name] 52 | # Should have a short_name field 53 | assert hasattr(spec, "short_name") 54 | # Should not return the enum string representation 55 | assert spec.short_name != str(model_name) 56 | # Should return a non-empty string 57 | assert len(spec.short_name) > 0 58 | -------------------------------------------------------------------------------- /test/unit/codebase/tools/conftest.py: -------------------------------------------------------------------------------- 1 | """Shared fixtures for codebase tools tests.""" 2 | 3 | from unittest.mock import AsyncMock, MagicMock 4 | 5 | import pytest 6 | from pydantic_ai import RunContext 7 | 8 | from shotgun.agents.config.models import ( 9 | KeyProvider, 10 | ModelConfig, 11 | ModelName, 12 | ProviderType, 13 | ) 14 | from shotgun.agents.models import AgentDeps, AgentRuntimeOptions 15 | from shotgun.codebase.models import CodebaseGraph, GraphStatus 16 | 17 | 18 | @pytest.fixture 19 | def mock_codebase_service(): 20 | """Create a mock codebase service.""" 21 | service = MagicMock() 22 | service.list_graphs = AsyncMock() 23 | service.execute_query = AsyncMock() 24 | service.manager = MagicMock() 25 | service.manager._execute_query = AsyncMock() 26 | return service 27 | 28 | 29 | @pytest.fixture 30 | def mock_graph(): 31 | """Create a mock codebase graph.""" 32 | return CodebaseGraph( 33 | graph_id="test-graph-id", 34 | repo_path="/tmp/test-repo", # noqa: S108 35 | graph_path="/tmp/test-graph.db", # noqa: S108 36 | name="Test Graph", 37 | created_at=1234567890.0, 38 | updated_at=1234567890.0, 39 | status=GraphStatus.READY, 40 | ) 41 | 42 | 43 | @pytest.fixture 44 | def mock_agent_deps(mock_codebase_service): 45 | """Create mock agent dependencies.""" 46 | runtime_options = AgentRuntimeOptions() 47 | 48 | # Create a real ModelConfig instead of a mock 49 | model_config = ModelConfig( 50 | name=ModelName.GPT_5_1, 51 | provider=ProviderType.OPENAI, 52 | key_provider=KeyProvider.BYOK, 53 | max_input_tokens=4096, 54 | max_output_tokens=2048, 55 | api_key="test-api-key", 56 | ) 57 | 58 | # Use model_construct to bypass validation entirely for the mock 59 | deps = AgentDeps.model_construct( 60 | **runtime_options.model_dump(), 61 | llm_model=model_config, 62 | codebase_service=mock_codebase_service, 63 | ) 64 | return deps 65 | 66 | 67 | @pytest.fixture 68 | def mock_run_context(mock_agent_deps): 69 | """Create mock run context.""" 70 | return MagicMock(spec=RunContext, deps=mock_agent_deps) 71 | -------------------------------------------------------------------------------- /test/unit/codebase/tools/test_file_read.py: -------------------------------------------------------------------------------- 1 | """Tests for file_read tool.""" 2 | 3 | import pytest 4 | 5 | from shotgun.agents.tools.codebase import FileReadResult, file_read 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_file_read_success( 10 | mock_run_context, mock_codebase_service, mock_graph, tmp_path 11 | ): 12 | """Test successful file reading.""" 13 | # Setup test file 14 | test_file = tmp_path / "test.py" 15 | test_content = "# Test file\nprint('hello')" 16 | test_file.write_text(test_content) 17 | 18 | # Mock graph with tmp_path as repo 19 | mock_graph.repo_path = str(tmp_path) 20 | mock_codebase_service.list_graphs.return_value = [mock_graph] 21 | 22 | # Execute 23 | result = await file_read(mock_run_context, "test-graph-id", "test.py") 24 | 25 | # Verify 26 | assert isinstance(result, FileReadResult) 27 | assert result.success is True 28 | assert result.file_path == "test.py" 29 | assert result.content == test_content 30 | assert test_content in str(result) 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_file_read_security_violation( 35 | mock_run_context, mock_codebase_service, mock_graph, tmp_path 36 | ): 37 | """Test file_read prevents path traversal.""" 38 | mock_graph.repo_path = str(tmp_path) 39 | mock_codebase_service.list_graphs.return_value = [mock_graph] 40 | 41 | # Try to read outside repo 42 | result = await file_read(mock_run_context, "test-graph-id", "../../../etc/passwd") 43 | 44 | assert isinstance(result, FileReadResult) 45 | assert result.success is False 46 | assert result.error and "Access denied" in result.error 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_file_read_file_not_found( 51 | mock_run_context, mock_codebase_service, mock_graph, tmp_path 52 | ): 53 | """Test file_read with non-existent file.""" 54 | mock_graph.repo_path = str(tmp_path) 55 | mock_codebase_service.list_graphs.return_value = [mock_graph] 56 | 57 | result = await file_read(mock_run_context, "test-graph-id", "nonexistent.py") 58 | 59 | assert isinstance(result, FileReadResult) 60 | assert result.success is False 61 | assert result.error and "not found" in result.error 62 | -------------------------------------------------------------------------------- /test/unit/tui/test_chat_screen_byok_hints.py: -------------------------------------------------------------------------------- 1 | """Tests for BYOK signup hints in ChatScreen error handling.""" 2 | 3 | from shotgun.agents.config.models import KeyProvider 4 | from shotgun.exceptions import SHOTGUN_SIGNUP_URL 5 | 6 | 7 | def test_signup_url_constant_defined(): 8 | """Verify that the SHOTGUN_SIGNUP_URL constant is defined.""" 9 | assert SHOTGUN_SIGNUP_URL == "https://shotgun.sh" 10 | 11 | 12 | def test_byok_account_detection(mock_agent_deps): 13 | """Test that BYOK accounts can be detected via key_provider.""" 14 | # Set as BYOK 15 | mock_agent_deps.llm_model.key_provider = KeyProvider.BYOK 16 | assert not mock_agent_deps.llm_model.is_shotgun_account 17 | 18 | # Set as Shotgun 19 | mock_agent_deps.llm_model.key_provider = KeyProvider.SHOTGUN 20 | assert mock_agent_deps.llm_model.is_shotgun_account 21 | 22 | 23 | def test_error_message_detection_rate_limit(): 24 | """Test that rate limit errors can be detected from error messages.""" 25 | error_message = "Rate limit exceeded for requests" 26 | assert "rate" in error_message.lower() 27 | 28 | 29 | def test_error_message_detection_quota(): 30 | """Test that quota errors can be detected from error messages.""" 31 | error_message = "Insufficient quota remaining" 32 | assert "quota" in error_message.lower() 33 | 34 | 35 | def test_error_message_detection_auth(): 36 | """Test that auth errors can be detected from error messages.""" 37 | error_message = "Invalid API key provided" 38 | assert "invalid" in error_message.lower() and "key" in error_message.lower() 39 | 40 | error_message2 = "Authentication failed" 41 | assert "authentication" in error_message2.lower() 42 | 43 | 44 | def test_error_message_detection_overload(): 45 | """Test that overload errors can be detected from error messages.""" 46 | error_message = "The server is overloaded" 47 | assert "overload" in error_message.lower() 48 | 49 | 50 | def test_error_name_detection(): 51 | """Test that APIStatusError can be detected from exception type name.""" 52 | 53 | # Simulate what happens in the error handler 54 | class APIStatusError(Exception): 55 | pass 56 | 57 | error = APIStatusError("test") 58 | error_name = type(error).__name__ 59 | assert "APIStatusError" in error_name 60 | -------------------------------------------------------------------------------- /src/shotgun/prompts/history/combine_summaries.j2: -------------------------------------------------------------------------------- 1 | You are combining {{ num_summaries }} conversation chunk summaries into a unified summary. 2 | 3 | 4 | {% for summary in chunk_summaries %} 5 | --- Chunk {{ loop.index }} of {{ num_summaries }} --- 6 | {{ summary }} 7 | 8 | {% endfor %} 9 | 10 | 11 | Instructions: 12 | 1. Merge overlapping information - don't repeat the same facts 13 | 2. Preserve ALL entity references (files, functions, variables) from ALL chunks 14 | 3. Maintain chronological timeline of actions 15 | 4. Identify patterns and evolution across chunks 16 | 5. The current status should reflect the LAST chunk's end state 17 | 18 | 19 | # Context 20 | 21 | Short summary of the discussion, grouped by topics or tasks if any. 22 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives. 23 | 24 | # Key elements, learnings and entities 25 | 26 | Present the important elements of context, learnings and entities from the discussion. Be very detailed. 27 | 28 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives. 29 | 30 | If the agent obtained information via tools, preserve it verbatim if it was short, summarize it if it was long focusing on preserving all of the most important facts and information. 31 | 32 | If there are any links mentioned, IDs, filenames, etc. - preserve them verbatim. 33 | 34 | # Timeline 35 | 36 | For important tasks and content, provide 37 | - User: asked to handle a complex task X: 38 | - Assistant: we handle it by doing A, B, C... 39 | - Tools used and output summary 40 | 41 | ... 42 | - User: asked to handle a complex task Y: 43 | - Assistant: we handle it by doing A, B, C... 44 | - Tools used and output summary 45 | 46 | Do not hesitate to merge original messages, remove noise. We want to be as concise and brief as possible. 47 | 48 | # Current status, task and objectives 49 | 50 | Based on the conversation, include here the current status, task and objectives. 51 | 52 | CRITICAL: This is not about summarizing or compacting. We are talking about the status, task and objectives currently active in the conversation to keep as much context as possible regarding the last messages. 53 | 54 | -------------------------------------------------------------------------------- /src/shotgun/shotgun_web/__init__.py: -------------------------------------------------------------------------------- 1 | """Shotgun Web API client for subscription, authentication, and shared specs.""" 2 | 3 | from .client import ShotgunWebClient, check_token_status, create_unification_token 4 | from .exceptions import ( 5 | ConflictError, 6 | ForbiddenError, 7 | NotFoundError, 8 | PayloadTooLargeError, 9 | RateLimitExceededError, 10 | ShotgunWebError, 11 | UnauthorizedError, 12 | ) 13 | from .models import ( 14 | # Specs models 15 | ErrorDetail, 16 | ErrorResponse, 17 | FileListResponse, 18 | FileMetadata, 19 | FileUploadRequest, 20 | FileUploadResponse, 21 | PermissionCheckResponse, 22 | PublicSpecResponse, 23 | SpecCreateRequest, 24 | SpecCreateResponse, 25 | SpecFileResponse, 26 | SpecListResponse, 27 | SpecResponse, 28 | SpecUpdateRequest, 29 | SpecVersionCreateRequest, 30 | SpecVersionResponse, 31 | SpecVersionState, 32 | # Token models 33 | TokenCreateRequest, 34 | TokenCreateResponse, 35 | TokenStatus, 36 | TokenStatusResponse, 37 | VersionCloseResponse, 38 | VersionCreateResponse, 39 | VersionListResponse, 40 | WorkspaceRole, 41 | ) 42 | from .specs_client import SpecsClient 43 | 44 | __all__ = [ 45 | # Existing exports 46 | "ShotgunWebClient", 47 | "create_unification_token", 48 | "check_token_status", 49 | "TokenCreateRequest", 50 | "TokenCreateResponse", 51 | "TokenStatus", 52 | "TokenStatusResponse", 53 | # Specs client 54 | "SpecsClient", 55 | # Exceptions 56 | "ShotgunWebError", 57 | "UnauthorizedError", 58 | "ForbiddenError", 59 | "NotFoundError", 60 | "ConflictError", 61 | "PayloadTooLargeError", 62 | "RateLimitExceededError", 63 | # Specs models 64 | "ErrorDetail", 65 | "ErrorResponse", 66 | "FileListResponse", 67 | "FileMetadata", 68 | "FileUploadRequest", 69 | "FileUploadResponse", 70 | "PermissionCheckResponse", 71 | "PublicSpecResponse", 72 | "SpecCreateRequest", 73 | "SpecCreateResponse", 74 | "SpecFileResponse", 75 | "SpecListResponse", 76 | "SpecResponse", 77 | "SpecUpdateRequest", 78 | "SpecVersionCreateRequest", 79 | "SpecVersionResponse", 80 | "SpecVersionState", 81 | "VersionCloseResponse", 82 | "VersionCreateResponse", 83 | "VersionListResponse", 84 | "WorkspaceRole", 85 | ] 86 | -------------------------------------------------------------------------------- /src/shotgun/prompts/history/summarization.j2: -------------------------------------------------------------------------------- 1 | Summarize the following conversation. Focus on key information and maintain context. 2 | Provide the output as specified in . 3 | Remove noisy information or irrelevant messages. 4 | 5 | 6 | # Context 7 | 8 | Short summary of the discussion, grouped by topics or tasks if any. 9 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives. 10 | 11 | # Key elements, learnings and entities 12 | 13 | Present the important elements of context, learnings and entities from the discussion. Be very detailed. 14 | 15 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives. 16 | 17 | If the agent obtained information via tools, preserve it verbatim if it was short, summarize it if it was long focusing on preserving all of the most important facts and information. 18 | 19 | If the agent has acknowledged the user's request, make sure to include the summary of the work done in the output so it doesn't acknowledge the user again. 20 | 21 | If agent listed files, code or retrieved parts of the code, preserve it verbatim unless it's very long, then summarize it. ALWAYS IN THE CONTEXT OF WHAT THE AGENT NEEDS. 22 | 23 | Keep user's messages verbatim if they are short to medium length, summarize them if they are long focusing on preserving all of the most important facts and information. 24 | 25 | If there are any links mentioned, IDs, filenames, etc. - preserve them verbatim. 26 | 27 | # Timeline 28 | 29 | For important tasks and content, provide 30 | - User: asked to handle a complex task X: 31 | - Assistant: we handle it by doing A, B, C... 32 | - Tools used and output summary 33 | 34 | ... 35 | - User: asked to handle a complex task Y: 36 | - Assistant: we handle it by doing A, B, C... 37 | - Tools used and output summary 38 | 39 | Do not hesitate to merge original messages, remove noise. We want to be as concise and brief as possible. 40 | 41 | # Current status, task and objectives 42 | 43 | Based on the conversation, include here the current status, task and objectives. 44 | 45 | CRITICAL: This is not about summarizing or compacting. We are talking about the status, task and objectives currently active in the convertion to keep as much context as possible regarding the last messages. 46 | -------------------------------------------------------------------------------- /src/shotgun/agents/llm.py: -------------------------------------------------------------------------------- 1 | """LLM request utilities for Shotgun agents.""" 2 | 3 | from typing import Any 4 | 5 | from pydantic_ai.direct import model_request 6 | from pydantic_ai.messages import ModelMessage, ModelResponse 7 | from pydantic_ai.settings import ModelSettings 8 | 9 | from shotgun.agents.config.models import ModelConfig 10 | from shotgun.logging_config import get_logger 11 | 12 | logger = get_logger(__name__) 13 | 14 | 15 | async def shotgun_model_request( 16 | model_config: ModelConfig, 17 | messages: list[ModelMessage], 18 | model_settings: ModelSettings | None = None, 19 | **kwargs: Any, 20 | ) -> ModelResponse: 21 | """Model request wrapper that uses full token capacity by default. 22 | 23 | This wrapper ensures all LLM calls in Shotgun use the maximum available 24 | token capacity of each model, improving response quality and completeness. 25 | The most common issue this fixes is truncated summaries that were cut off 26 | at default token limits (e.g., 4096 for Claude models). 27 | 28 | Args: 29 | model_config: ModelConfig instance with model settings and API key 30 | messages: Messages to send to the model 31 | model_settings: Optional ModelSettings. If None, creates default with max tokens 32 | **kwargs: Additional arguments passed to model_request 33 | 34 | Returns: 35 | ModelResponse from the model 36 | 37 | Example: 38 | # Uses full token capacity (e.g., 4096 for Claude, 128k for GPT-5) 39 | response = await shotgun_model_request(model_config, messages) 40 | 41 | # With custom settings 42 | response = await shotgun_model_request(model_config, messages, model_settings=ModelSettings(max_tokens=1000, temperature=0.7)) 43 | """ 44 | if kwargs.get("max_tokens") is not None: 45 | logger.warning( 46 | "⚠️ 'max_tokens' argument is ignored in shotgun_model_request. " 47 | "Set 'model_settings.max_tokens' instead." 48 | ) 49 | 50 | if not model_settings: 51 | model_settings = ModelSettings() 52 | 53 | if model_settings.get("max_tokens") is None: 54 | model_settings["max_tokens"] = model_config.max_output_tokens 55 | 56 | # Make the model request with full token utilization 57 | return await model_request( 58 | model=model_config.model_instance, 59 | messages=messages, 60 | model_settings=model_settings, 61 | **kwargs, 62 | ) 63 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # Build Configuration 3 | # ============================================================================= 4 | # Build validation is SKIPPED by default (for local dev and CI test jobs). 5 | # To require validation (fail if secrets are missing), set: 6 | # SHOTGUN_BUILD_REQUIRE_VALIDATION=true 7 | # 8 | # This is used in production builds (deploy.yml) to ensure secrets are configured. 9 | 10 | # ============================================================================= 11 | # Telemetry Configuration (SHOTGUN_ prefixed for runtime override) 12 | # ============================================================================= 13 | # These keys are typically embedded at build time via hatch build hooks. 14 | # The SHOTGUN_ prefixed environment variables can be used to override at runtime. 15 | 16 | # Sentry Error Tracking 17 | # Runtime override: export SHOTGUN_SENTRY_DSN="your_sentry_dsn" 18 | SHOTGUN_SENTRY_DSN= 19 | 20 | # PostHog Analytics 21 | # Runtime override: export SHOTGUN_POSTHOG_API_KEY="your_posthog_api_key" 22 | # Note: Hardcoded key "phc_KKnChzZUKeNqZDOTJ6soCBWNQSx3vjiULdwTR9H5Mcr" was removed 23 | # from posthog_telemetry.py on 2025-10-29 and replaced with build constants. 24 | SHOTGUN_POSTHOG_API_KEY= 25 | SHOTGUN_POSTHOG_PROJECT_ID= 26 | 27 | # Logfire (Pydantic) Observability 28 | # Runtime override: export SHOTGUN_LOGFIRE_ENABLED="true" 29 | # Note: Logfire is only embedded in dev builds (versions containing dev, rc, alpha, beta) 30 | SHOTGUN_LOGFIRE_ENABLED=false 31 | SHOTGUN_LOGFIRE_TOKEN= 32 | 33 | # ============================================================================= 34 | # Optional Configuration (SHOTGUN_ prefixed) 35 | # ============================================================================= 36 | 37 | # Logging Configuration 38 | SHOTGUN_LOG_LEVEL=INFO 39 | SHOTGUN_LOGGING_TO_CONSOLE=false 40 | SHOTGUN_LOGGING_TO_FILE=true 41 | 42 | # Testing / Development Configuration 43 | SHOTGUN_HOME=~/.shotgun-sh 44 | SHOTGUN_PIPX_SIMULATE=false 45 | SHOTGUN_WEB_BASE_URL=https://api-219702594231.us-east4.run.app 46 | SHOTGUN_ACCOUNT_LLM_BASE_URL=https://litellm-219702594231.us-east4.run.app 47 | 48 | # ============================================================================= 49 | # PyPI Publishing Configuration 50 | # ============================================================================= 51 | PYPI_TOKEN=your_pypi_api_token_here 52 | TEST_PYPI_TOKEN=your_test_pypi_api_token_here 53 | -------------------------------------------------------------------------------- /src/shotgun/tui/components/prompt_input.py: -------------------------------------------------------------------------------- 1 | from textual import events 2 | from textual.message import Message 3 | from textual.widgets import TextArea 4 | 5 | 6 | class PromptInput(TextArea): 7 | """A TextArea with a submit binding.""" 8 | 9 | DEFAULT_CSS = """ 10 | PromptInput { 11 | outline: round $primary; 12 | background: transparent; 13 | } 14 | """ 15 | 16 | def check_action(self, action: str, parameters: tuple[object, ...]) -> bool: 17 | if action != "copy": 18 | return True 19 | # run copy action if there is selected text 20 | # otherwise, do nothing, so global ctrl+c still works. 21 | return bool(self.selected_text) 22 | 23 | class Submitted(Message): 24 | """A message to indicate that the text has been submitted.""" 25 | 26 | def __init__(self, text: str) -> None: 27 | super().__init__() 28 | self.text = text 29 | 30 | def action_submit(self) -> None: 31 | """An action to submit the text.""" 32 | self.post_message(self.Submitted(self.text)) 33 | 34 | async def _on_key(self, event: events.Key) -> None: 35 | """Handle key presses which correspond to document inserts.""" 36 | 37 | # Don't handle Enter key here - let the binding handle it 38 | if event.key == "enter": 39 | self.action_submit() 40 | 41 | self._restart_blink() 42 | 43 | if self.read_only: 44 | return 45 | 46 | key = event.key 47 | insert_values = { 48 | "ctrl+j": "\n", 49 | } 50 | if self.tab_behavior == "indent": 51 | if key == "escape": 52 | event.stop() 53 | event.prevent_default() 54 | self.screen.focus_next() 55 | return 56 | if self.indent_type == "tabs": 57 | insert_values["tab"] = "\t" 58 | else: 59 | insert_values["tab"] = " " * self._find_columns_to_next_tab_stop() 60 | 61 | if event.is_printable or key in insert_values: 62 | event.stop() 63 | event.prevent_default() 64 | insert = insert_values.get(key, event.character) 65 | # `insert` is not None because event.character cannot be 66 | # None because we've checked that it's printable. 67 | assert insert is not None # noqa: S101 68 | start, end = self.selection 69 | self._replace_via_keyboard(insert, start, end) 70 | -------------------------------------------------------------------------------- /src/shotgun/shotgun_web/constants.py: -------------------------------------------------------------------------------- 1 | """Constants for Shotgun Web API.""" 2 | 3 | # Import from centralized API endpoints module 4 | from shotgun.api_endpoints import SHOTGUN_WEB_BASE_URL 5 | 6 | # API endpoints 7 | UNIFICATION_TOKEN_CREATE_PATH = "/api/unification/token/create" # noqa: S105 8 | UNIFICATION_TOKEN_STATUS_PATH = "/api/unification/token/{token}/status" # noqa: S105 9 | ME_PATH = "/api/me" 10 | 11 | # Polling configuration 12 | DEFAULT_POLL_INTERVAL_SECONDS = 3 13 | DEFAULT_TOKEN_TIMEOUT_SECONDS = 1800 # 30 minutes 14 | 15 | # Workspaces API endpoint 16 | WORKSPACES_PATH = "/api/workspaces" 17 | 18 | # Specs API endpoints 19 | PERMISSIONS_PATH = "/api/workspaces/{workspace_id}/specs/permissions" 20 | SPECS_BASE_PATH = "/api/workspaces/{workspace_id}/specs" 21 | SPECS_DETAIL_PATH = "/api/workspaces/{workspace_id}/specs/{spec_id}" 22 | VERSIONS_PATH = "/api/workspaces/{workspace_id}/specs/{spec_id}/versions" 23 | VERSION_DETAIL_PATH = ( 24 | "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}" 25 | ) 26 | VERSION_CLOSE_PATH = ( 27 | "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}/close" 28 | ) 29 | VERSION_SET_LATEST_PATH = ( 30 | "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}/set-latest" 31 | ) 32 | FILES_PATH = ( 33 | "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}/files" 34 | ) 35 | FILE_DETAIL_PATH = "/api/workspaces/{workspace_id}/specs/{spec_id}/versions/{version_id}/files/{file_id}" 36 | PUBLIC_SPEC_PATH = "/api/public/specs/{spec_id}" 37 | PUBLIC_SPEC_FILES_PATH = "/api/public/specs/{spec_id}/files" 38 | PUBLIC_FILE_PATH = "/api/public/specs/{spec_id}/files/{file_id}" 39 | 40 | # CLI convenience endpoint (version lookup by ID only) 41 | VERSION_BY_ID_PATH = "/api/versions/{version_id}" 42 | 43 | # Re-export for backward compatibility 44 | __all__ = [ 45 | "SHOTGUN_WEB_BASE_URL", 46 | "UNIFICATION_TOKEN_CREATE_PATH", 47 | "UNIFICATION_TOKEN_STATUS_PATH", 48 | "ME_PATH", 49 | "DEFAULT_POLL_INTERVAL_SECONDS", 50 | "DEFAULT_TOKEN_TIMEOUT_SECONDS", 51 | # Workspaces endpoint 52 | "WORKSPACES_PATH", 53 | # Specs endpoints 54 | "PERMISSIONS_PATH", 55 | "SPECS_BASE_PATH", 56 | "SPECS_DETAIL_PATH", 57 | "VERSIONS_PATH", 58 | "VERSION_DETAIL_PATH", 59 | "VERSION_CLOSE_PATH", 60 | "VERSION_SET_LATEST_PATH", 61 | "FILES_PATH", 62 | "FILE_DETAIL_PATH", 63 | "PUBLIC_SPEC_PATH", 64 | "PUBLIC_SPEC_FILES_PATH", 65 | "PUBLIC_FILE_PATH", 66 | "VERSION_BY_ID_PATH", 67 | ] 68 | -------------------------------------------------------------------------------- /test/unit/shotgun_web/shared_specs/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for shared_specs tests.""" 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def temp_shotgun_dir(tmp_path: Path) -> Path: 10 | """Create a temporary .shotgun/ directory with test files.""" 11 | shotgun_dir = tmp_path / ".shotgun" 12 | shotgun_dir.mkdir() 13 | 14 | # Create some test files 15 | (shotgun_dir / "research.md").write_text("# Research\n\nSome research content.") 16 | (shotgun_dir / "specification.md").write_text("# Specification\n\nSpec content.") 17 | 18 | # Create a subdirectory with files 19 | contracts_dir = shotgun_dir / "contracts" 20 | contracts_dir.mkdir() 21 | (contracts_dir / "api.yaml").write_text("openapi: 3.0.0\ninfo:\n title: Test API") 22 | (contracts_dir / "models.py").write_text("class TestModel:\n pass") 23 | 24 | return tmp_path 25 | 26 | 27 | @pytest.fixture 28 | def temp_shotgun_dir_with_ignored_files(temp_shotgun_dir: Path) -> Path: 29 | """Create a temporary .shotgun/ directory with files that should be ignored.""" 30 | shotgun_dir = temp_shotgun_dir / ".shotgun" 31 | 32 | # Create files that should be ignored 33 | (shotgun_dir / ".DS_Store").write_bytes(b"\x00\x00\x00\x01Bud1") 34 | (shotgun_dir / "file.pyc").write_bytes(b"compiled python") 35 | (shotgun_dir / "file.swp").write_text("vim swap file") 36 | (shotgun_dir / "backup.bak").write_text("backup file") 37 | (shotgun_dir / "temp~").write_text("temp file") 38 | 39 | # Create __pycache__ directory with files 40 | pycache_dir = shotgun_dir / "__pycache__" 41 | pycache_dir.mkdir() 42 | (pycache_dir / "module.cpython-311.pyc").write_bytes(b"cached") 43 | 44 | # Create .vscode directory with files 45 | vscode_dir = shotgun_dir / ".vscode" 46 | vscode_dir.mkdir() 47 | (vscode_dir / "settings.json").write_text('{"key": "value"}') 48 | 49 | return temp_shotgun_dir 50 | 51 | 52 | @pytest.fixture 53 | def temp_file_for_hash(tmp_path: Path) -> Path: 54 | """Create a temporary file with known content for hash testing.""" 55 | test_file = tmp_path / "test_file.txt" 56 | test_file.write_text("Hello, World!") 57 | return test_file 58 | 59 | 60 | @pytest.fixture 61 | def large_temp_file(tmp_path: Path) -> Path: 62 | """Create a larger temporary file (>10MB) for testing chunk size.""" 63 | test_file = tmp_path / "large_file.bin" 64 | # Create a 15MB file 65 | with open(test_file, "wb") as f: 66 | f.write(b"x" * (15 * 1024 * 1024)) 67 | return test_file 68 | -------------------------------------------------------------------------------- /src/shotgun/shotgun_web/shared_specs/hasher.py: -------------------------------------------------------------------------------- 1 | """Async SHA-256 file hashing utilities.""" 2 | 3 | import hashlib 4 | from pathlib import Path 5 | 6 | import aiofiles 7 | 8 | from shotgun.logging_config import get_logger 9 | 10 | logger = get_logger(__name__) 11 | 12 | # Chunk sizes for reading files 13 | SMALL_FILE_CHUNK_SIZE = 65536 # 64KB for files < 10MB 14 | LARGE_FILE_CHUNK_SIZE = 1048576 # 1MB for files >= 10MB 15 | LARGE_FILE_THRESHOLD = 10 * 1024 * 1024 # 10MB 16 | 17 | 18 | def _get_chunk_size(file_size: int) -> int: 19 | """Determine optimal chunk size based on file size. 20 | 21 | Args: 22 | file_size: Size of file in bytes 23 | 24 | Returns: 25 | Chunk size in bytes (64KB for small files, 1MB for large files) 26 | """ 27 | if file_size >= LARGE_FILE_THRESHOLD: 28 | return LARGE_FILE_CHUNK_SIZE 29 | return SMALL_FILE_CHUNK_SIZE 30 | 31 | 32 | async def calculate_sha256(file_path: Path) -> str: 33 | """Calculate SHA-256 hash of a file using streaming. 34 | 35 | Reads file in chunks to avoid loading entire file into memory. 36 | Uses adaptive chunk sizing: 64KB for files <10MB, 1MB for larger files. 37 | 38 | Args: 39 | file_path: Path to file to hash 40 | 41 | Returns: 42 | Hex-encoded SHA-256 hash string (64 characters) 43 | 44 | Raises: 45 | FileNotFoundError: If file does not exist 46 | PermissionError: If file is not readable 47 | """ 48 | file_size = file_path.stat().st_size 49 | chunk_size = _get_chunk_size(file_size) 50 | 51 | logger.debug( 52 | "Calculating SHA-256 for %s (size=%d, chunk_size=%d)", 53 | file_path, 54 | file_size, 55 | chunk_size, 56 | ) 57 | 58 | sha256_hash = hashlib.sha256() 59 | 60 | async with aiofiles.open(file_path, "rb") as f: 61 | while chunk := await f.read(chunk_size): 62 | sha256_hash.update(chunk) 63 | 64 | hex_digest = sha256_hash.hexdigest() 65 | logger.debug("SHA-256 for %s: %s", file_path, hex_digest) 66 | 67 | return hex_digest 68 | 69 | 70 | async def calculate_sha256_with_size(file_path: Path) -> tuple[str, int]: 71 | """Calculate SHA-256 hash and get file size. 72 | 73 | Convenience function that returns both hash and size in one call. 74 | 75 | Args: 76 | file_path: Path to file to hash 77 | 78 | Returns: 79 | Tuple of (hex-encoded SHA-256 hash, file size in bytes) 80 | """ 81 | file_size = file_path.stat().st_size 82 | content_hash = await calculate_sha256(file_path) 83 | return content_hash, file_size 84 | -------------------------------------------------------------------------------- /src/shotgun/agents/tools/codebase/query_graph.py: -------------------------------------------------------------------------------- 1 | """Query codebase knowledge graph using natural language.""" 2 | 3 | from pydantic_ai import RunContext 4 | 5 | from shotgun.agents.models import AgentDeps 6 | from shotgun.agents.tools.registry import ToolCategory, register_tool 7 | from shotgun.codebase.models import QueryType 8 | from shotgun.logging_config import get_logger 9 | 10 | from .models import QueryGraphResult 11 | 12 | logger = get_logger(__name__) 13 | 14 | 15 | @register_tool( 16 | category=ToolCategory.CODEBASE_UNDERSTANDING, 17 | display_text="Querying code", 18 | key_arg="query", 19 | ) 20 | async def query_graph( 21 | ctx: RunContext[AgentDeps], graph_id: str, query: str 22 | ) -> QueryGraphResult: 23 | """Query codebase knowledge graph using natural language. 24 | 25 | Args: 26 | ctx: RunContext containing AgentDeps with codebase service 27 | graph_id: Graph ID to query (use the ID, not the name) 28 | query: Natural language question about the codebase 29 | 30 | Returns: 31 | QueryGraphResult with formatted output via __str__ 32 | """ 33 | logger.debug("🔧 Querying graph %s with query: %s", graph_id, query) 34 | 35 | try: 36 | if not ctx.deps.codebase_service: 37 | return QueryGraphResult( 38 | success=False, 39 | query=query, 40 | error="No codebase indexed", 41 | ) 42 | 43 | # Execute natural language query 44 | result = await ctx.deps.codebase_service.execute_query( 45 | graph_id=graph_id, 46 | query=query, 47 | query_type=QueryType.NATURAL_LANGUAGE, 48 | ) 49 | 50 | # Create QueryGraphResult from service result 51 | graph_result = QueryGraphResult( 52 | success=result.success, 53 | query=query, 54 | cypher_query=result.cypher_query, 55 | column_names=result.column_names, 56 | results=result.results, 57 | row_count=result.row_count, 58 | execution_time_ms=result.execution_time_ms, 59 | error=result.error, 60 | ) 61 | 62 | logger.debug( 63 | "📄 Query completed: %s with %d results", 64 | "success" if graph_result.success else "failed", 65 | graph_result.row_count, 66 | ) 67 | 68 | return graph_result 69 | 70 | except Exception as e: 71 | error_msg = f"Error querying graph: {str(e)}" 72 | logger.error("❌ Query graph failed: %s", str(e)) 73 | return QueryGraphResult(success=False, query=query, error=error_msg) 74 | -------------------------------------------------------------------------------- /src/shotgun/tui/protocols.py: -------------------------------------------------------------------------------- 1 | """Protocol definitions for TUI components. 2 | 3 | These protocols define interfaces that components can depend on without 4 | creating circular imports. Screens like ChatScreen can satisfy these 5 | protocols without explicitly implementing them. 6 | """ 7 | 8 | from typing import Protocol, runtime_checkable 9 | 10 | 11 | @runtime_checkable 12 | class QAStateProvider(Protocol): 13 | """Protocol for screens that provide Q&A mode state. 14 | 15 | This protocol allows components to check if they're on a screen with 16 | Q&A mode without importing the concrete ChatScreen class, eliminating 17 | circular dependencies. 18 | """ 19 | 20 | @property 21 | def qa_mode(self) -> bool: 22 | """Whether Q&A mode is currently active. 23 | 24 | Returns: 25 | True if Q&A mode is active, False otherwise. 26 | """ 27 | ... 28 | 29 | 30 | @runtime_checkable 31 | class ProcessingStateProvider(Protocol): 32 | """Protocol for screens that provide processing state. 33 | 34 | This protocol allows components to check if they're on a screen with 35 | an active agent processing without importing the concrete ChatScreen class. 36 | """ 37 | 38 | @property 39 | def working(self) -> bool: 40 | """Whether an agent is currently working. 41 | 42 | Returns: 43 | True if an agent is processing, False otherwise. 44 | """ 45 | ... 46 | 47 | 48 | @runtime_checkable 49 | class RouterModeProvider(Protocol): 50 | """Protocol for screens that provide router mode state. 51 | 52 | This protocol allows components to check the current router mode 53 | (Planning or Drafting) without importing the concrete ChatScreen class. 54 | """ 55 | 56 | @property 57 | def router_mode(self) -> str | None: 58 | """The current router mode. 59 | 60 | Returns: 61 | 'planning' or 'drafting' if in router mode, None otherwise. 62 | """ 63 | ... 64 | 65 | 66 | @runtime_checkable 67 | class ActiveSubAgentProvider(Protocol): 68 | """Protocol for screens that provide active sub-agent state. 69 | 70 | This protocol allows components to check which sub-agent is currently 71 | executing during router delegation without importing ChatScreen. 72 | """ 73 | 74 | @property 75 | def active_sub_agent(self) -> str | None: 76 | """The currently executing sub-agent type. 77 | 78 | Returns: 79 | The sub-agent type string (e.g., 'research', 'specify') if 80 | a sub-agent is executing, None if idle. 81 | """ 82 | ... 83 | -------------------------------------------------------------------------------- /src/shotgun/cli/specify.py: -------------------------------------------------------------------------------- 1 | """Specify command for shotgun CLI.""" 2 | 3 | import asyncio 4 | from typing import Annotated 5 | 6 | import typer 7 | 8 | from shotgun.agents.config import ProviderType 9 | from shotgun.agents.models import AgentRuntimeOptions 10 | from shotgun.agents.specify import ( 11 | create_specify_agent, 12 | run_specify_agent, 13 | ) 14 | from shotgun.cli.error_handler import print_agent_error 15 | from shotgun.exceptions import ErrorNotPickedUpBySentry 16 | from shotgun.logging_config import get_logger 17 | 18 | app = typer.Typer( 19 | name="specify", help="Generate comprehensive specifications", no_args_is_help=True 20 | ) 21 | logger = get_logger(__name__) 22 | 23 | 24 | @app.callback(invoke_without_command=True) 25 | def specify( 26 | requirement: Annotated[ 27 | str, typer.Argument(help="Requirement or feature to specify") 28 | ], 29 | non_interactive: Annotated[ 30 | bool, 31 | typer.Option( 32 | "--non-interactive", "-n", help="Disable user interaction (for CI/CD)" 33 | ), 34 | ] = False, 35 | provider: Annotated[ 36 | ProviderType | None, 37 | typer.Option("--provider", "-p", help="AI provider to use (overrides default)"), 38 | ] = None, 39 | ) -> None: 40 | """Generate comprehensive specifications for software features and systems. 41 | 42 | This command creates detailed technical specifications including requirements, 43 | architecture, implementation details, and acceptance criteria based on your 44 | provided requirement or feature description. 45 | """ 46 | 47 | logger.info("📝 Specification Requirement: %s", requirement) 48 | 49 | # Create agent dependencies 50 | agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive) 51 | 52 | # Create the specify agent with deps and provider 53 | agent, deps = asyncio.run(create_specify_agent(agent_runtime_options, provider)) 54 | 55 | # Start specification process with error handling 56 | logger.info("📋 Starting specification generation...") 57 | 58 | async def async_specify() -> None: 59 | try: 60 | result = await run_specify_agent(agent, requirement, deps) 61 | logger.info("✅ Specification Complete!") 62 | logger.info("📋 Results:") 63 | logger.info("%s", result.output) 64 | except ErrorNotPickedUpBySentry as e: 65 | print_agent_error(e) 66 | except Exception as e: 67 | logger.exception("Unexpected error in specify command") 68 | print(f"⚠️ An unexpected error occurred: {str(e)}") 69 | 70 | asyncio.run(async_specify()) 71 | -------------------------------------------------------------------------------- /test/unit/codebase/tools/test_retrieve_code.py: -------------------------------------------------------------------------------- 1 | """Tests for retrieve_code tool.""" 2 | 3 | import importlib 4 | from unittest.mock import AsyncMock, patch 5 | 6 | import pytest 7 | 8 | from shotgun.agents.tools.codebase import CodeSnippetResult 9 | from shotgun.codebase.core.code_retrieval import CodeSnippet 10 | 11 | # Import the actual module, not the function 12 | retrieve_code_module = importlib.import_module( 13 | "shotgun.agents.tools.codebase.retrieve_code" 14 | ) 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_retrieve_code_success(mock_run_context, mock_codebase_service): 19 | """Test successful code retrieval.""" 20 | # Mock the code retrieval function 21 | with patch.object( 22 | retrieve_code_module, "retrieve_code_by_qualified_name", new_callable=AsyncMock 23 | ) as mock_retrieve: 24 | code_snippet = CodeSnippet( 25 | qualified_name="test.module.Class", 26 | source_code="class TestClass:\n pass", 27 | file_path="test/module.py", 28 | line_start=1, 29 | line_end=2, 30 | found=True, 31 | docstring="Test class", 32 | ) 33 | mock_retrieve.return_value = code_snippet 34 | 35 | # Execute 36 | result = await retrieve_code_module.retrieve_code( 37 | mock_run_context, "graph-id", "test.module.Class" 38 | ) 39 | 40 | # Verify 41 | assert isinstance(result, CodeSnippetResult) 42 | assert result.found is True 43 | assert result.qualified_name == "test.module.Class" 44 | assert result.source_code == "class TestClass:\n pass" 45 | assert "```python" in str(result) 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_retrieve_code_not_found(mock_run_context, mock_codebase_service): 50 | """Test code retrieval when entity not found.""" 51 | with patch.object( 52 | retrieve_code_module, "retrieve_code_by_qualified_name", new_callable=AsyncMock 53 | ) as mock_retrieve: 54 | code_snippet = CodeSnippet( 55 | qualified_name="test.module.Missing", 56 | source_code="", 57 | file_path="", 58 | line_start=0, 59 | line_end=0, 60 | found=False, 61 | error_message="Entity not found", 62 | ) 63 | mock_retrieve.return_value = code_snippet 64 | 65 | result = await retrieve_code_module.retrieve_code( 66 | mock_run_context, "graph-id", "test.module.Missing" 67 | ) 68 | 69 | assert isinstance(result, CodeSnippetResult) 70 | assert result.found is False 71 | assert "Not Found" in str(result) 72 | -------------------------------------------------------------------------------- /src/shotgun/prompts/history/incremental_summarization.j2: -------------------------------------------------------------------------------- 1 | You are extending an existing conversation summary with new messages. 2 | 3 | Your task is to merge the new information into the existing summary while maintaining the same structured format and preserving all important information. 4 | 5 | 6 | {{ existing_summary }} 7 | 8 | 9 | 10 | {{ new_messages }} 11 | 12 | 13 | Instructions: 14 | 1. Update the existing summary sections with new information from the NEW_MESSAGES 15 | 2. Maintain the same structure and format as the existing summary 16 | 3. Preserve all important information from both existing and new content 17 | 4. Do not repeat information already covered in the existing summary unless it's been updated or expanded 18 | 5. Focus on integrating new developments, decisions, or progress into the appropriate sections 19 | 20 | 21 | Provide the complete updated summary following the exact same format as the existing summary: 22 | 23 | # Context 24 | 25 | Updated summary of the discussion, incorporating new topics or tasks. Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives. 26 | 27 | # Key elements, learnings and entities 28 | 29 | Present the updated important elements of context, learnings and entities from the entire discussion. Be very detailed. 30 | 31 | Present it as clear bullet points. Be brief but do not lose information that might be important for the current state, task or objectives. 32 | 33 | If new tool results were obtained, preserve them verbatim if short, summarize if long focusing on preserving all important facts. 34 | 35 | If new user messages exist, preserve them verbatim if short to medium length, summarize if long focusing on preserving all important facts. 36 | 37 | If there are any new links, IDs, filenames, etc. - preserve them verbatim. 38 | 39 | # Timeline 40 | 41 | Update the timeline with new important tasks and content: 42 | - User: asked to handle a complex task X: 43 | - Assistant: we handle it by doing A, B, C... 44 | - Tools used and output summary 45 | 46 | [Include any new timeline entries from the new messages] 47 | 48 | # Current status, task and objectives 49 | 50 | Based on the complete conversation including new messages, update the current status, task and objectives. 51 | 52 | CRITICAL: This section should reflect the most current state based on all messages including the new ones. This is not about summarizing but about the status, task and objectives currently active in the conversation to keep as much context as possible. 53 | -------------------------------------------------------------------------------- /src/shotgun/agents/conversation/history/token_counting/base.py: -------------------------------------------------------------------------------- 1 | """Base classes and shared utilities for token counting.""" 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | from pydantic_ai.messages import ModelMessage 6 | 7 | 8 | class TokenCounter(ABC): 9 | """Abstract base class for provider-specific token counting. 10 | 11 | All methods are async to support non-blocking operations like 12 | downloading tokenizer models or making API calls. 13 | """ 14 | 15 | @abstractmethod 16 | async def count_tokens(self, text: str) -> int: 17 | """Count tokens in text using provider-specific method (async). 18 | 19 | Args: 20 | text: Text to count tokens for 21 | 22 | Returns: 23 | Exact token count as determined by the provider 24 | 25 | Raises: 26 | RuntimeError: If token counting fails 27 | """ 28 | 29 | @abstractmethod 30 | async def count_message_tokens(self, messages: list[ModelMessage]) -> int: 31 | """Count tokens in PydanticAI message structures (async). 32 | 33 | Args: 34 | messages: List of messages to count tokens for 35 | 36 | Returns: 37 | Total token count across all messages 38 | 39 | Raises: 40 | RuntimeError: If token counting fails 41 | """ 42 | 43 | 44 | def extract_text_from_messages(messages: list[ModelMessage]) -> str: 45 | """Extract all text content from messages for token counting. 46 | 47 | Args: 48 | messages: List of PydanticAI messages 49 | 50 | Returns: 51 | Combined text content from all messages 52 | """ 53 | text_parts = [] 54 | 55 | for message in messages: 56 | if hasattr(message, "parts"): 57 | for part in message.parts: 58 | if hasattr(part, "content") and isinstance(part.content, str): 59 | # Only add non-empty content 60 | if part.content.strip(): 61 | text_parts.append(part.content) 62 | else: 63 | # Handle non-text parts (tool calls, etc.) 64 | part_str = str(part) 65 | if part_str.strip(): 66 | text_parts.append(part_str) 67 | else: 68 | # Handle messages without parts 69 | msg_str = str(message) 70 | if msg_str.strip(): 71 | text_parts.append(msg_str) 72 | 73 | # If no valid text parts found, return a minimal placeholder 74 | # This ensures we never send completely empty content to APIs 75 | if not text_parts: 76 | return "." 77 | 78 | return "\n".join(text_parts) 79 | -------------------------------------------------------------------------------- /src/shotgun/prompts/codebase/cypher_system.j2: -------------------------------------------------------------------------------- 1 | You are an expert translator that converts natural language questions about code structure into precise Neo4j Cypher queries. 2 | 3 | {% include 'codebase/partials/graph_schema.j2' %} 4 | 5 | {% include 'codebase/partials/cypher_rules.j2' %} 6 | 7 | **3. Query Patterns & Examples** 8 | Your goal is to return appropriate properties for each node type. Common properties: 9 | - All nodes have: `name` 10 | - Nodes with paths: Module, Package, File, Folder (have `path` property) 11 | - Code entities: Class, Function, Method (have `qualified_name` but NO `path` - get path via Module relationship) 12 | - Always include a type indicator (either as a string literal or via CASE statement) 13 | - Do NOT include comments (// or /*) in your queries. 14 | 15 | **IMPORTANT: Handling Entity Names** 16 | - `name` property: Contains only the simple/short name (e.g., 'WebSocketServer', 'start') 17 | - `qualified_name` property: Contains the full qualified path (e.g., 'shotgun2.server.src.shotgun.api.websocket.server.WebSocketServer') 18 | - When users mention a specific class/function/method by name: 19 | - If it looks like a short name, use: `WHERE c.name = 'WebSocketServer'` 20 | - If it contains dots or looks like a full path, use: `WHERE c.qualified_name = 'full.path.to.Class'` 21 | - For partial paths, use: `WHERE c.qualified_name CONTAINS 'partial.path'` or `WHERE c.qualified_name ENDS WITH '.WebSocketServer'` 22 | 23 | {% include 'codebase/cypher_query_patterns.j2' %} 24 | 25 | {% include 'codebase/partials/temporal_context.j2' %} 26 | 27 | **6. Output Format** 28 | You must return a structured JSON response with the following fields: 29 | - `cypher_query`: The generated Cypher query string (or null if not possible) 30 | - `can_generate_valid_cypher`: Boolean indicating if a valid Cypher query can be generated 31 | - `reason_cannot_generate`: String explaining why generation isn't possible (or null if successful) 32 | 33 | **IMPORTANT:** Some queries cannot be expressed in Cypher: 34 | - Conceptual questions requiring interpretation (e.g., "What is the main purpose of this codebase?") 35 | - Questions about code quality or best practices 36 | - Questions requiring semantic understanding beyond structure 37 | 38 | For these, set `can_generate_valid_cypher` to false and provide a clear explanation in `reason_cannot_generate`. 39 | 40 | Examples: 41 | - Query: "Show all classes" → can_generate_valid_cypher: true, cypher_query: "MATCH (c:Class) RETURN c.name, c.qualified_name;" 42 | - Query: "What is the main purpose of this codebase?" → can_generate_valid_cypher: false, reason_cannot_generate: "This is a conceptual question requiring interpretation and analysis of the code's overall design and intent, rather than a structural query about specific code elements." -------------------------------------------------------------------------------- /src/shotgun/cli/plan.py: -------------------------------------------------------------------------------- 1 | """Plan command for shotgun CLI.""" 2 | 3 | import asyncio 4 | from typing import Annotated 5 | 6 | import typer 7 | 8 | from shotgun.agents.config import ProviderType 9 | from shotgun.agents.models import AgentRuntimeOptions 10 | from shotgun.agents.plan import create_plan_agent, run_plan_agent 11 | from shotgun.cli.error_handler import print_agent_error 12 | from shotgun.exceptions import ErrorNotPickedUpBySentry 13 | from shotgun.logging_config import get_logger 14 | from shotgun.posthog_telemetry import track_event 15 | 16 | app = typer.Typer(name="plan", help="Generate structured plans", no_args_is_help=True) 17 | logger = get_logger(__name__) 18 | 19 | 20 | @app.callback(invoke_without_command=True) 21 | def plan( 22 | goal: Annotated[str, typer.Argument(help="Goal or objective to plan for")], 23 | non_interactive: Annotated[ 24 | bool, 25 | typer.Option( 26 | "--non-interactive", "-n", help="Disable user interaction (for CI/CD)" 27 | ), 28 | ] = False, 29 | provider: Annotated[ 30 | ProviderType | None, 31 | typer.Option("--provider", "-p", help="AI provider to use (overrides default)"), 32 | ] = None, 33 | ) -> None: 34 | """Generate a structured plan for achieving the given goal. 35 | 36 | This command will create detailed, actionable plans broken down into steps 37 | and milestones to help achieve your specified objective. It can also update 38 | existing plans based on new requirements or refinements. 39 | """ 40 | 41 | logger.info("📋 Planning Goal: %s", goal) 42 | 43 | # Track plan command usage 44 | track_event( 45 | "plan_command", 46 | { 47 | "non_interactive": non_interactive, 48 | "provider": provider.value if provider else "default", 49 | }, 50 | ) 51 | 52 | # Create agent dependencies 53 | agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive) 54 | 55 | # Create the plan agent with deps and provider 56 | agent, deps = asyncio.run(create_plan_agent(agent_runtime_options, provider)) 57 | 58 | # Start planning process with error handling 59 | logger.info("🎯 Starting planning...") 60 | 61 | async def async_plan() -> None: 62 | try: 63 | result = await run_plan_agent(agent, goal, deps) 64 | logger.info("✅ Planning Complete!") 65 | logger.info("📋 Results:") 66 | logger.info("%s", result.output) 67 | except ErrorNotPickedUpBySentry as e: 68 | print_agent_error(e) 69 | except Exception as e: 70 | logger.exception("Unexpected error in plan command") 71 | print(f"⚠️ An unexpected error occurred: {str(e)}") 72 | 73 | asyncio.run(async_plan()) 74 | -------------------------------------------------------------------------------- /src/shotgun/tui/components/spinner.py: -------------------------------------------------------------------------------- 1 | """Spinner component for showing loading/working state.""" 2 | 3 | from textual.app import ComposeResult 4 | from textual.containers import Container 5 | from textual.css.query import NoMatches 6 | from textual.reactive import reactive 7 | from textual.timer import Timer 8 | from textual.widget import Widget 9 | from textual.widgets import Static 10 | 11 | 12 | class Spinner(Widget): 13 | """A spinner widget that shows a rotating animation when working.""" 14 | 15 | DEFAULT_CSS = """ 16 | Spinner { 17 | width: auto; 18 | height: 1; 19 | } 20 | 21 | Spinner > Container { 22 | width: auto; 23 | height: 1; 24 | layout: horizontal; 25 | } 26 | 27 | Spinner .spinner-icon { 28 | width: 1; 29 | margin-right: 1; 30 | } 31 | 32 | Spinner .spinner-text { 33 | width: auto; 34 | } 35 | """ 36 | 37 | # Animation frames for the spinner 38 | FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] 39 | 40 | text = reactive("Working...") 41 | _frame_index = reactive(0) 42 | 43 | def __init__( 44 | self, 45 | text: str = "Working...", 46 | *, 47 | name: str | None = None, 48 | id: str | None = None, 49 | classes: str | None = None, 50 | ) -> None: 51 | super().__init__(name=name, id=id, classes=classes) 52 | self.text = text 53 | self._timer: Timer | None = None 54 | 55 | def compose(self) -> ComposeResult: 56 | """Compose the spinner widget.""" 57 | with Container(): 58 | yield Static("", classes="spinner-icon") 59 | yield Static(self.text, classes="spinner-text") 60 | 61 | def on_mount(self) -> None: 62 | """Set up the animation timer when mounted.""" 63 | self._timer = self.set_interval(0.1, self._advance_frame) 64 | 65 | def _advance_frame(self) -> None: 66 | """Advance to the next animation frame.""" 67 | self._frame_index = (self._frame_index + 1) % len(self.FRAMES) 68 | self._update_display() 69 | 70 | def _update_display(self) -> None: 71 | """Update the spinner display.""" 72 | try: 73 | icon_widget = self.query_one(".spinner-icon", Static) 74 | icon_widget.update(self.FRAMES[self._frame_index]) 75 | except NoMatches: 76 | # Widget not mounted yet, ignore 77 | pass 78 | 79 | def watch_text(self, text: str) -> None: 80 | """React to changes in the text.""" 81 | try: 82 | text_widget = self.query_one(".spinner-text", Static) 83 | text_widget.update(text) 84 | except NoMatches: 85 | # Widget not mounted yet, ignore 86 | return 87 | -------------------------------------------------------------------------------- /src/shotgun/tui/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Command handling for the TUI chat interface.""" 2 | 3 | from collections.abc import Callable 4 | 5 | 6 | class CommandHandler: 7 | """Handles slash commands in the TUI chat interface.""" 8 | 9 | def __init__(self) -> None: 10 | """Initialize the command handler with available commands.""" 11 | self.commands: dict[str, Callable[[], str]] = { 12 | "help": self.get_help_text, 13 | } 14 | 15 | def is_command(self, text: str) -> bool: 16 | """Check if the text is a command (starts with /).""" 17 | return text.strip().startswith("/") 18 | 19 | def parse_command(self, text: str) -> str: 20 | """Extract the command name from the text.""" 21 | text = text.strip() 22 | if not text.startswith("/"): 23 | return "" 24 | 25 | # Split on whitespace and get the command part 26 | parts = text[1:].split() 27 | return parts[0] if parts else "" 28 | 29 | def handle_command(self, text: str) -> tuple[bool, str]: 30 | """ 31 | Handle a command and return success status and response text. 32 | 33 | Args: 34 | text: The full command text including the leading / 35 | 36 | Returns: 37 | Tuple of (success, response_text) 38 | """ 39 | if not self.is_command(text): 40 | return False, "" 41 | 42 | command = self.parse_command(text) 43 | 44 | if command in self.commands: 45 | response = self.commands[command]() 46 | return True, response 47 | else: 48 | return False, self.get_error_message(command) 49 | 50 | def get_help_text(self) -> str: 51 | """Return the help text for the /help command.""" 52 | return """📚 **Shotgun Help** 53 | 54 | **Commands:** 55 | • `/help` - Show this help message 56 | 57 | **Keyboard Shortcuts:** 58 | 59 | * `Enter` - Send message 60 | * `Ctrl+P` - Open command palette (for usage, context, and other commands) 61 | * `Shift+Tab` - Cycle agent modes 62 | * `Ctrl+C` - Quit application 63 | 64 | **Agent Modes:** 65 | * **Research** - Research topics with web search and synthesize findings 66 | * **Specify** - Create detailed specifications and requirements documents 67 | * **Planning** - Create comprehensive, actionable plans with milestones 68 | * **Tasks** - Generate specific, actionable tasks from research and plans 69 | * **Export** - Export artifacts and findings to various formats 70 | 71 | **Usage:** 72 | Type your message and press Enter to chat with the AI. The AI will respond based on the current mode.""" 73 | 74 | def get_error_message(self, command: str) -> str: 75 | """Return a polite error message for unknown commands.""" 76 | return f"⚠️ Sorry, `/{command}` is not a recognized command. Type `/help` to see available commands." 77 | -------------------------------------------------------------------------------- /src/shotgun/tui/screens/chat_screen/history/agent_response.py: -------------------------------------------------------------------------------- 1 | """Agent response widget for chat history.""" 2 | 3 | from pydantic_ai.messages import ( 4 | BuiltinToolCallPart, 5 | BuiltinToolReturnPart, 6 | ModelResponse, 7 | TextPart, 8 | ThinkingPart, 9 | ToolCallPart, 10 | ) 11 | from textual.app import ComposeResult 12 | from textual.widget import Widget 13 | from textual.widgets import Markdown 14 | 15 | from .formatters import ToolFormatter 16 | 17 | 18 | class AgentResponseWidget(Widget): 19 | """Widget that displays agent responses in the chat history.""" 20 | 21 | def __init__(self, item: ModelResponse | None, is_sub_agent: bool = False) -> None: 22 | super().__init__() 23 | self.item = item 24 | self.is_sub_agent = is_sub_agent 25 | 26 | def compose(self) -> ComposeResult: 27 | self.display = self.item is not None 28 | if self.item is None: 29 | yield Markdown(markdown="") 30 | else: 31 | yield Markdown(markdown=self.compute_output()) 32 | 33 | def compute_output(self) -> str: 34 | """Compute the markdown output for the agent response.""" 35 | acc = "" 36 | if self.item is None: 37 | return "" 38 | 39 | # Use different prefix for sub-agent responses 40 | prefix = "**⏺** " if not self.is_sub_agent else " **↳** " 41 | 42 | for idx, part in enumerate(self.item.parts): 43 | if isinstance(part, TextPart): 44 | # Only show the prefix if there's actual content 45 | if part.content and part.content.strip(): 46 | acc += f"{prefix}{part.content}\n\n" 47 | elif isinstance(part, ToolCallPart): 48 | parts_str = ToolFormatter.format_tool_call_part(part) 49 | if parts_str: # Only add if there's actual content 50 | acc += parts_str + "\n\n" 51 | elif isinstance(part, BuiltinToolCallPart): 52 | # Format builtin tool calls using registry 53 | formatted = ToolFormatter.format_builtin_tool_call(part) 54 | if formatted: # Only add if not hidden 55 | acc += formatted + "\n\n" 56 | elif isinstance(part, BuiltinToolReturnPart): 57 | # Don't show tool return parts in the UI 58 | pass 59 | elif isinstance(part, ThinkingPart): 60 | if ( 61 | idx == len(self.item.parts) - 1 62 | ): # show the thinking part only if it's the last part 63 | acc += ( 64 | f"thinking: {part.content}\n\n" 65 | if part.content 66 | else "Thinking..." 67 | ) 68 | else: 69 | continue 70 | return acc.strip() 71 | -------------------------------------------------------------------------------- /src/shotgun/cli/tasks.py: -------------------------------------------------------------------------------- 1 | """Tasks command for shotgun CLI.""" 2 | 3 | import asyncio 4 | from typing import Annotated 5 | 6 | import typer 7 | 8 | from shotgun.agents.config import ProviderType 9 | from shotgun.agents.models import AgentRuntimeOptions 10 | from shotgun.agents.tasks import ( 11 | create_tasks_agent, 12 | run_tasks_agent, 13 | ) 14 | from shotgun.cli.error_handler import print_agent_error 15 | from shotgun.exceptions import ErrorNotPickedUpBySentry 16 | from shotgun.logging_config import get_logger 17 | from shotgun.posthog_telemetry import track_event 18 | 19 | app = typer.Typer(name="tasks", help="Generate task lists with agentic approach") 20 | logger = get_logger(__name__) 21 | 22 | 23 | @app.callback(invoke_without_command=True) 24 | def tasks( 25 | instruction: Annotated[ 26 | str, typer.Argument(help="Task creation instruction or project description") 27 | ], 28 | non_interactive: Annotated[ 29 | bool, 30 | typer.Option( 31 | "--non-interactive", "-n", help="Disable user interaction (for CI/CD)" 32 | ), 33 | ] = False, 34 | provider: Annotated[ 35 | ProviderType | None, 36 | typer.Option("--provider", "-p", help="AI provider to use (overrides default)"), 37 | ] = None, 38 | ) -> None: 39 | """Generate actionable task lists based on existing research and plans. 40 | 41 | This command creates detailed task breakdowns using AI agents that analyze 42 | your research and plans to generate prioritized, actionable tasks with 43 | acceptance criteria and effort estimates. 44 | """ 45 | 46 | logger.info("📋 Task Creation Instruction: %s", instruction) 47 | 48 | # Track tasks command usage 49 | track_event( 50 | "tasks_command", 51 | { 52 | "non_interactive": non_interactive, 53 | "provider": provider.value if provider else "default", 54 | }, 55 | ) 56 | 57 | # Create agent dependencies 58 | agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive) 59 | 60 | # Create the tasks agent with deps and provider 61 | agent, deps = asyncio.run(create_tasks_agent(agent_runtime_options, provider)) 62 | 63 | # Start task creation process with error handling 64 | logger.info("🎯 Starting task creation...") 65 | 66 | async def async_tasks() -> None: 67 | try: 68 | result = await run_tasks_agent(agent, instruction, deps) 69 | logger.info("✅ Task Creation Complete!") 70 | logger.info("📋 Results:") 71 | logger.info("%s", result.output) 72 | except ErrorNotPickedUpBySentry as e: 73 | print_agent_error(e) 74 | except Exception as e: 75 | logger.exception("Unexpected error in tasks command") 76 | print(f"⚠️ An unexpected error occurred: {str(e)}") 77 | 78 | asyncio.run(async_tasks()) 79 | -------------------------------------------------------------------------------- /src/shotgun/cli/spec/backup.py: -------------------------------------------------------------------------------- 1 | """Backup utility for .shotgun/ directory before pulling specs.""" 2 | 3 | import shutil 4 | import zipfile 5 | from datetime import datetime, timezone 6 | from pathlib import Path 7 | 8 | from shotgun.logging_config import get_logger 9 | 10 | logger = get_logger(__name__) 11 | 12 | # Backup directory location 13 | BACKUP_DIR = Path.home() / ".shotgun-sh" / "backups" 14 | 15 | 16 | async def create_backup(shotgun_dir: Path) -> str | None: 17 | """Create a zip backup of the .shotgun/ directory. 18 | 19 | Creates a timestamped backup at ~/.shotgun-sh/backups/{YYYYMMDD_HHMMSS}.zip. 20 | Only creates backup if the directory exists and has content. 21 | 22 | Args: 23 | shotgun_dir: Path to the .shotgun/ directory to backup 24 | 25 | Returns: 26 | Path to the backup file as string, or None if no backup was created 27 | (e.g., directory doesn't exist or is empty) 28 | 29 | Raises: 30 | Exception: If backup creation fails (caller should handle) 31 | """ 32 | # Check if directory exists and has content 33 | if not shotgun_dir.exists(): 34 | logger.debug("No .shotgun/ directory to backup") 35 | return None 36 | 37 | files_to_backup = list(shotgun_dir.rglob("*")) 38 | if not any(f.is_file() for f in files_to_backup): 39 | logger.debug(".shotgun/ directory is empty, skipping backup") 40 | return None 41 | 42 | # Create backup directory if needed 43 | BACKUP_DIR.mkdir(parents=True, exist_ok=True) 44 | 45 | # Generate timestamp-based filename 46 | timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") 47 | backup_path = BACKUP_DIR / f"{timestamp}.zip" 48 | 49 | logger.info("Creating backup of .shotgun/ at %s", backup_path) 50 | 51 | # Create zip file 52 | with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zipf: 53 | for file_path in files_to_backup: 54 | if file_path.is_file(): 55 | # Store with path relative to shotgun_dir 56 | arcname = file_path.relative_to(shotgun_dir) 57 | zipf.write(file_path, arcname) 58 | logger.debug("Added to backup: %s", arcname) 59 | 60 | logger.info("Backup created successfully: %s", backup_path) 61 | return str(backup_path) 62 | 63 | 64 | def clear_shotgun_dir(shotgun_dir: Path) -> None: 65 | """Clear all contents of the .shotgun/ directory. 66 | 67 | Removes all files and subdirectories but keeps the .shotgun/ directory itself. 68 | 69 | Args: 70 | shotgun_dir: Path to the .shotgun/ directory to clear 71 | """ 72 | if not shotgun_dir.exists(): 73 | return 74 | 75 | for item in shotgun_dir.iterdir(): 76 | if item.is_dir(): 77 | shutil.rmtree(item) 78 | else: 79 | item.unlink() 80 | 81 | logger.debug("Cleared contents of %s", shotgun_dir) 82 | -------------------------------------------------------------------------------- /test/unit/codebase/test_parser_loader.py: -------------------------------------------------------------------------------- 1 | """Unit tests for parser_loader module.""" 2 | 3 | from unittest.mock import patch 4 | 5 | from shotgun.codebase.core.parser_loader import load_parsers 6 | 7 | 8 | def test_load_parsers_returns_tuple(): 9 | """Test that load_parsers returns a tuple structure.""" 10 | result = load_parsers() 11 | 12 | assert isinstance(result, tuple) 13 | assert len(result) == 2 14 | 15 | parsers, queries = result 16 | assert isinstance(parsers, dict) 17 | assert isinstance(queries, dict) 18 | 19 | 20 | def test_load_parsers_empty_config_exits(): 21 | """Test that empty language config causes exit.""" 22 | with ( 23 | patch("shotgun.codebase.core.parser_loader.LANGUAGE_CONFIGS", {}), 24 | patch("sys.exit") as mock_exit, 25 | ): 26 | load_parsers() 27 | mock_exit.assert_called_once_with(1) 28 | 29 | 30 | def test_load_parsers_handles_missing_tree_sitter_modules(): 31 | """Test graceful handling when tree-sitter modules are missing.""" 32 | # Mock missing all tree-sitter modules 33 | with ( 34 | patch.dict( 35 | "sys.modules", 36 | { 37 | "tree_sitter_python": None, 38 | "tree_sitter_javascript": None, 39 | "tree_sitter_typescript": None, 40 | "tree_sitter_go": None, 41 | "tree_sitter_rust": None, 42 | "tree_sitter_languages": None, 43 | }, 44 | clear=False, 45 | ), 46 | patch("sys.exit") as mock_exit, 47 | ): 48 | load_parsers() 49 | # Should exit when no parsers can be loaded 50 | mock_exit.assert_called_once_with(1) 51 | 52 | 53 | def test_load_parsers_with_real_config(): 54 | """Test load_parsers with actual language configuration.""" 55 | # This test uses the real implementation 56 | parsers, queries = load_parsers() 57 | 58 | # Should have loaded some languages 59 | assert isinstance(parsers, dict) 60 | assert isinstance(queries, dict) 61 | 62 | # If any parsers were loaded, they should be valid 63 | for lang, parser in parsers.items(): 64 | assert lang is not None 65 | assert parser is not None 66 | 67 | # If any queries were loaded, they should be dictionaries 68 | for lang, lang_queries in queries.items(): 69 | assert lang is not None 70 | assert isinstance(lang_queries, dict) 71 | 72 | 73 | def test_load_parsers_consistent_languages(): 74 | """Test that parsers and queries have consistent language keys.""" 75 | parsers, queries = load_parsers() 76 | 77 | # All languages in parsers should have corresponding queries 78 | for lang in parsers.keys(): 79 | assert lang in queries 80 | 81 | # All languages in queries should have corresponding parsers 82 | for lang in queries.keys(): 83 | assert lang in parsers 84 | -------------------------------------------------------------------------------- /src/shotgun/utils/datetime_utils.py: -------------------------------------------------------------------------------- 1 | """Datetime utilities for consistent datetime formatting across the application.""" 2 | 3 | from datetime import datetime 4 | 5 | from pydantic import BaseModel, Field 6 | 7 | 8 | class DateTimeContext(BaseModel): 9 | """Structured datetime context with timezone information. 10 | 11 | This model provides consistently formatted datetime information 12 | for use in prompts, templates, and UI display. 13 | 14 | Attributes: 15 | datetime_formatted: Human-readable datetime string 16 | timezone_name: Short timezone name (e.g., "PST", "UTC") 17 | utc_offset: UTC offset formatted with colon (e.g., "UTC-08:00") 18 | 19 | Example: 20 | >>> dt_context = get_datetime_context() 21 | >>> print(dt_context.datetime_formatted) 22 | 'Monday, January 13, 2025 at 3:45:30 PM' 23 | >>> print(dt_context.timezone_name) 24 | 'PST' 25 | >>> print(dt_context.utc_offset) 26 | 'UTC-08:00' 27 | """ 28 | 29 | datetime_formatted: str = Field( 30 | description="Human-readable datetime string in format: 'Day, Month DD, YYYY at HH:MM:SS AM/PM'" 31 | ) 32 | timezone_name: str = Field(description="Short timezone name (e.g., PST, EST, UTC)") 33 | utc_offset: str = Field( 34 | description="UTC offset formatted with colon (e.g., UTC-08:00, UTC+05:30)" 35 | ) 36 | 37 | 38 | def get_datetime_context() -> DateTimeContext: 39 | """Get formatted datetime context with timezone information. 40 | 41 | Returns a Pydantic model containing consistently formatted datetime 42 | information suitable for use in prompts and templates. 43 | 44 | Returns: 45 | DateTimeContext: Structured datetime context with formatted strings 46 | 47 | Example: 48 | >>> dt_context = get_datetime_context() 49 | >>> dt_context.datetime_formatted 50 | 'Monday, January 13, 2025 at 3:45:30 PM' 51 | >>> dt_context.timezone_name 52 | 'PST' 53 | >>> dt_context.utc_offset 54 | 'UTC-08:00' 55 | """ 56 | # Get current datetime with timezone information 57 | now = datetime.now().astimezone() 58 | 59 | # Format datetime in plain English 60 | # Example: "Monday, January 13, 2025 at 3:45:30 PM" 61 | datetime_formatted = now.strftime("%A, %B %d, %Y at %I:%M:%S %p") 62 | 63 | # Get timezone name and UTC offset 64 | # Example: "PST" and "UTC-08:00" 65 | timezone_name = now.strftime("%Z") 66 | utc_offset = now.strftime("%z") # Format: +0800 or -0500 67 | 68 | # Reformat UTC offset to include colon: +08:00 or -05:00 69 | utc_offset_formatted = ( 70 | f"UTC{utc_offset[:3]}:{utc_offset[3:]}" if utc_offset else "UTC" 71 | ) 72 | 73 | return DateTimeContext( 74 | datetime_formatted=datetime_formatted, 75 | timezone_name=timezone_name, 76 | utc_offset=utc_offset_formatted, 77 | ) 78 | -------------------------------------------------------------------------------- /evals/datasets/router_agent/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Router agent test case datasets. 3 | 4 | Contains test cases for evaluating Router agent behavior. 5 | 6 | Test case categories: 7 | - Clarifying questions: Tests Router asks questions for vague/ambiguous prompts 8 | - Planning: Tests Router creates appropriate plans for feature requests 9 | - Research planning: Tests Router plans research-first when adding features to codebase 10 | - Delegation routing: Tests Router correctly delegates to each agent for their files 11 | 12 | Exports: 13 | - CLARIFYING_QUESTIONS_CASES: List of clarifying questions test cases 14 | - PLANNING_CASES: List of planning behavior test cases 15 | - RESEARCH_PLANNING_CASES: List of research-first planning test cases 16 | - DELEGATION_ROUTING_CASES: List of delegation routing test cases 17 | - ALL_ROUTER_CASES: Dict mapping test case names to test case objects 18 | """ 19 | 20 | from evals.datasets.router_agent.clarifying_questions_cases import ( 21 | CACHE_REQUEST_ASKS_QUESTIONS, 22 | CLARIFYING_QUESTIONS_CASES, 23 | OPEN_SOURCE_MODELS_ASKS_QUESTIONS, 24 | PERFORMANCE_REQUEST_ASKS_QUESTIONS, 25 | VAGUE_PROMPT_CLARIFYING_QUESTIONS, 26 | ) 27 | from evals.datasets.router_agent.delegation_routing_cases import ( 28 | DELEGATION_ROUTING_CASES, 29 | MULTI_FILE_UPDATE_DELEGATES_SEPARATELY, 30 | ) 31 | from evals.datasets.router_agent.planning_cases import ( 32 | COMPLEX_FEATURE_ASKS_QUESTIONS, 33 | FEATURE_REQUEST_ASKS_QUESTIONS, 34 | PLANNING_CASES, 35 | SPECIFIC_FEATURE_CREATES_PLAN, 36 | ) 37 | from evals.datasets.router_agent.research_planning_cases import ( 38 | AUTH_FEATURE_PLANS_RESEARCH_FIRST, 39 | CACHE_FEATURE_PLANS_RESEARCH_FIRST, 40 | OLLAMA_FEATURE_PLANS_RESEARCH_FIRST, 41 | RESEARCH_PLANNING_CASES, 42 | ) 43 | 44 | # Index of all Router test cases by name for discovery 45 | ALL_ROUTER_CASES = { 46 | case.name: case 47 | for case in ( 48 | CLARIFYING_QUESTIONS_CASES 49 | + PLANNING_CASES 50 | + RESEARCH_PLANNING_CASES 51 | + DELEGATION_ROUTING_CASES 52 | ) 53 | } 54 | 55 | __all__ = [ 56 | # Collections 57 | "CLARIFYING_QUESTIONS_CASES", 58 | "PLANNING_CASES", 59 | "RESEARCH_PLANNING_CASES", 60 | "DELEGATION_ROUTING_CASES", 61 | "ALL_ROUTER_CASES", 62 | # Clarifying questions test cases 63 | "VAGUE_PROMPT_CLARIFYING_QUESTIONS", 64 | "PERFORMANCE_REQUEST_ASKS_QUESTIONS", 65 | "CACHE_REQUEST_ASKS_QUESTIONS", 66 | "OPEN_SOURCE_MODELS_ASKS_QUESTIONS", 67 | # Planning test cases 68 | "FEATURE_REQUEST_ASKS_QUESTIONS", 69 | "COMPLEX_FEATURE_ASKS_QUESTIONS", 70 | "SPECIFIC_FEATURE_CREATES_PLAN", 71 | # Research planning test cases 72 | "OLLAMA_FEATURE_PLANS_RESEARCH_FIRST", 73 | "AUTH_FEATURE_PLANS_RESEARCH_FIRST", 74 | "CACHE_FEATURE_PLANS_RESEARCH_FIRST", 75 | # Delegation routing test cases 76 | "MULTI_FILE_UPDATE_DELEGATES_SEPARATELY", 77 | ] 78 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Python 3.13 slim as base image 2 | FROM python:3.13-slim 3 | 4 | # Build arguments for telemetry configuration 5 | # These are used during the build process to embed analytics keys 6 | ARG SHOTGUN_SENTRY_DSN="" 7 | ARG SHOTGUN_POSTHOG_API_KEY="" 8 | ARG SHOTGUN_POSTHOG_PROJECT_ID="" 9 | ARG SHOTGUN_LOGFIRE_ENABLED="" 10 | ARG SHOTGUN_LOGFIRE_TOKEN="" 11 | ARG SHOTGUN_BUILD_REQUIRE_VALIDATION="" 12 | 13 | # OCI annotations for package metadata 14 | LABEL org.opencontainers.image.title="Shotgun" \ 15 | org.opencontainers.image.description="AI-powered CLI tool for research, planning, and task management. Always use :latest for production - see README for details." \ 16 | org.opencontainers.image.source="https://github.com/shotgun-sh/shotgun" \ 17 | org.opencontainers.image.licenses="MIT" \ 18 | org.opencontainers.image.vendor="Shotgun" \ 19 | org.opencontainers.image.url="https://shotgun.sh" 20 | 21 | # Set environment variables 22 | ENV PYTHONUNBUFFERED=1 \ 23 | PYTHONDONTWRITEBYTECODE=1 \ 24 | UV_SYSTEM_PYTHON=1 25 | 26 | # Install uv for dependency management 27 | RUN pip install --no-cache-dir uv 28 | 29 | # Create non-root user 30 | RUN useradd -m -u 1000 shotgun 31 | 32 | # Set working directory for build 33 | WORKDIR /app 34 | 35 | # Copy project files 36 | COPY --chown=shotgun:shotgun pyproject.toml uv.lock hatch_build.py ./ 37 | COPY --chown=shotgun:shotgun src/ ./src/ 38 | COPY --chown=shotgun:shotgun README.md LICENSE ./ 39 | COPY --chown=shotgun:shotgun docs/README_DOCKER.md ./docs/ 40 | 41 | # Install dependencies 42 | # Pass build args as environment variables for the build hook 43 | RUN SHOTGUN_SENTRY_DSN="${SHOTGUN_SENTRY_DSN}" \ 44 | SHOTGUN_POSTHOG_API_KEY="${SHOTGUN_POSTHOG_API_KEY}" \ 45 | SHOTGUN_POSTHOG_PROJECT_ID="${SHOTGUN_POSTHOG_PROJECT_ID}" \ 46 | SHOTGUN_LOGFIRE_ENABLED="${SHOTGUN_LOGFIRE_ENABLED}" \ 47 | SHOTGUN_LOGFIRE_TOKEN="${SHOTGUN_LOGFIRE_TOKEN}" \ 48 | SHOTGUN_BUILD_REQUIRE_VALIDATION="${SHOTGUN_BUILD_REQUIRE_VALIDATION}" \ 49 | uv sync --frozen --no-dev 50 | 51 | # Create directories for workspace and config 52 | RUN mkdir -p /workspace /home/shotgun/.shotgun-sh && \ 53 | chown -R shotgun:shotgun /workspace /home/shotgun/.shotgun-sh 54 | 55 | # Switch to non-root user 56 | USER shotgun 57 | 58 | # Set working directory to workspace 59 | WORKDIR /workspace 60 | 61 | # Expose default web server port 62 | EXPOSE 8000 63 | 64 | # Health check for web server 65 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 66 | CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000').read()" || exit 1 67 | 68 | # Add .venv/bin to PATH so shotgun command is available 69 | ENV PATH="/app/.venv/bin:$PATH" 70 | 71 | # Entry point - run shotgun in web mode with force-reindex for Docker 72 | ENTRYPOINT ["shotgun", "--web", "--host", "0.0.0.0", "--force-reindex"] 73 | 74 | # Default port (can be overridden) 75 | CMD ["--port", "8000"] 76 | -------------------------------------------------------------------------------- /src/shotgun/cli/export.py: -------------------------------------------------------------------------------- 1 | """Export command for shotgun CLI.""" 2 | 3 | import asyncio 4 | from typing import Annotated 5 | 6 | import typer 7 | 8 | from shotgun.agents.config import ProviderType 9 | from shotgun.agents.export import ( 10 | create_export_agent, 11 | run_export_agent, 12 | ) 13 | from shotgun.agents.models import AgentRuntimeOptions 14 | from shotgun.cli.error_handler import print_agent_error 15 | from shotgun.exceptions import ErrorNotPickedUpBySentry 16 | from shotgun.logging_config import get_logger 17 | from shotgun.posthog_telemetry import track_event 18 | 19 | app = typer.Typer( 20 | name="export", help="Export artifacts to various formats with agentic approach" 21 | ) 22 | logger = get_logger(__name__) 23 | 24 | 25 | @app.callback(invoke_without_command=True) 26 | def export( 27 | instruction: Annotated[ 28 | str, typer.Argument(help="Export instruction or format specification") 29 | ], 30 | non_interactive: Annotated[ 31 | bool, 32 | typer.Option( 33 | "--non-interactive", "-n", help="Disable user interaction (for CI/CD)" 34 | ), 35 | ] = False, 36 | provider: Annotated[ 37 | ProviderType | None, 38 | typer.Option("--provider", "-p", help="AI provider to use (overrides default)"), 39 | ] = None, 40 | ) -> None: 41 | """Export artifacts and findings to various formats. 42 | 43 | This command exports research, plans, tasks, and other project artifacts 44 | to different formats like Markdown, HTML, JSON, CSV, or project management 45 | tool formats. The AI agent will analyze available content and transform 46 | it according to your export requirements. 47 | """ 48 | 49 | logger.info("📤 Export Instruction: %s", instruction) 50 | 51 | # Track export command usage 52 | track_event( 53 | "export_command", 54 | { 55 | "non_interactive": non_interactive, 56 | "provider": provider.value if provider else "default", 57 | }, 58 | ) 59 | 60 | # Create agent dependencies 61 | agent_runtime_options = AgentRuntimeOptions(interactive_mode=not non_interactive) 62 | 63 | # Create the export agent with deps and provider 64 | agent, deps = asyncio.run(create_export_agent(agent_runtime_options, provider)) 65 | 66 | # Start export process with error handling 67 | logger.info("🎯 Starting export...") 68 | 69 | async def async_export() -> None: 70 | try: 71 | result = await run_export_agent(agent, instruction, deps) 72 | logger.info("✅ Export Complete!") 73 | logger.info("📤 Results:") 74 | logger.info("%s", result.output) 75 | except ErrorNotPickedUpBySentry as e: 76 | print_agent_error(e) 77 | except Exception as e: 78 | logger.exception("Unexpected error in export command") 79 | print(f"⚠️ An unexpected error occurred: {str(e)}") 80 | 81 | asyncio.run(async_export()) 82 | -------------------------------------------------------------------------------- /src/shotgun/agents/conversation/history/token_counting/openai.py: -------------------------------------------------------------------------------- 1 | """OpenAI token counting using tiktoken.""" 2 | 3 | from pydantic_ai.messages import ModelMessage 4 | 5 | from shotgun.logging_config import get_logger 6 | 7 | from .base import TokenCounter, extract_text_from_messages 8 | 9 | logger = get_logger(__name__) 10 | 11 | 12 | class OpenAITokenCounter(TokenCounter): 13 | """Token counter for OpenAI models using tiktoken.""" 14 | 15 | # Official encoding mappings for OpenAI models 16 | ENCODING_MAP = { 17 | "gpt-5": "o200k_base", 18 | "gpt-4o": "o200k_base", 19 | "gpt-4": "cl100k_base", 20 | "gpt-3.5-turbo": "cl100k_base", 21 | } 22 | 23 | def __init__(self, model_name: str): 24 | """Initialize OpenAI token counter. 25 | 26 | Args: 27 | model_name: OpenAI model name to get correct encoding for 28 | 29 | Raises: 30 | RuntimeError: If encoding initialization fails 31 | """ 32 | self.model_name = model_name 33 | 34 | import tiktoken 35 | 36 | try: 37 | # Get the appropriate encoding for this model 38 | encoding_name = self.ENCODING_MAP.get(model_name, "o200k_base") 39 | self.encoding = tiktoken.get_encoding(encoding_name) 40 | logger.debug( 41 | f"Initialized OpenAI token counter with {encoding_name} encoding" 42 | ) 43 | except Exception as e: 44 | raise RuntimeError( 45 | f"Failed to initialize tiktoken encoding for {model_name}" 46 | ) from e 47 | 48 | async def count_tokens(self, text: str) -> int: 49 | """Count tokens using tiktoken (async). 50 | 51 | Args: 52 | text: Text to count tokens for 53 | 54 | Returns: 55 | Exact token count using tiktoken 56 | 57 | Raises: 58 | RuntimeError: If token counting fails 59 | """ 60 | # Handle empty text to avoid unnecessary encoding 61 | if not text or not text.strip(): 62 | return 0 63 | 64 | try: 65 | return len(self.encoding.encode(text)) 66 | except BaseException as e: 67 | # Must catch BaseException to handle PanicException from tiktoken's Rust layer 68 | # which can occur with extremely long texts. Regular Exception won't catch it. 69 | raise RuntimeError( 70 | f"Failed to count tokens for OpenAI model {self.model_name}" 71 | ) from e 72 | 73 | async def count_message_tokens(self, messages: list[ModelMessage]) -> int: 74 | """Count tokens across all messages using tiktoken (async). 75 | 76 | Args: 77 | messages: List of PydanticAI messages 78 | 79 | Returns: 80 | Total token count for all messages 81 | 82 | Raises: 83 | RuntimeError: If token counting fails 84 | """ 85 | # Handle empty message list early 86 | if not messages: 87 | return 0 88 | 89 | total_text = extract_text_from_messages(messages) 90 | return await self.count_tokens(total_text) 91 | -------------------------------------------------------------------------------- /docs/README_DOCKER.md: -------------------------------------------------------------------------------- 1 | # Shotgun Docker Image 2 | 3 | AI-powered CLI tool for research, planning, and task management. 4 | 5 | ## ⚠️ Important: Use the Stable Release 6 | 7 | **Always use the `latest` tag for production use:** 8 | 9 | ```bash 10 | docker pull ghcr.io/shotgun-sh/shotgun:latest 11 | ``` 12 | 13 | ### Why NOT to use `:dev` 14 | 15 | **DO NOT use the `:dev` tag unless you are a developer testing new features.** 16 | 17 | Development versions: 18 | - ❌ Have telemetry and logging built-in that you probably don't want 19 | - ❌ Never auto-update (you'll be stuck on that version) 20 | - ❌ Are for internal testing only 21 | 22 | Production versions (`:latest` and version tags like `:v0.1.0`) are secure and only log anonymous event metadata - no user data is collected. 23 | 24 | ## Quick Start 25 | 26 | **Important:** Always run the container from your code directory so Shotgun can analyze your codebase. 27 | 28 | ```bash 29 | cd /path/to/your/project 30 | 31 | docker run -p 8000:8000 \ 32 | -v $(pwd):/workspace \ 33 | -v ~/.shotgun-sh:/home/shotgun/.shotgun-sh \ 34 | ghcr.io/shotgun-sh/shotgun:latest 35 | ``` 36 | 37 | Access the web interface at **http://localhost:8000** 38 | 39 | ### What this does: 40 | 41 | - Maps port 8000 from the container to your host 42 | - Mounts your current directory as `/workspace` inside the container 43 | - Mounts your Shotgun config directory to persist API keys and settings 44 | - Automatically prompts you to index the codebase on startup 45 | 46 | ## Available Tags 47 | 48 | - **`latest`** - ✅ **Recommended** - Latest stable release (secure, minimal telemetry) 49 | - **`v0.1.0`** - Specific version tags (for pinning to a particular version) 50 | - **`dev`** - ❌ **Not recommended** - Development version with full telemetry (for developers only) 51 | 52 | ## Custom Port 53 | 54 | To use a different port: 55 | 56 | ```bash 57 | docker run -p 3000:3000 \ 58 | -v $(pwd):/workspace \ 59 | -v ~/.shotgun-sh:/home/shotgun/.shotgun-sh \ 60 | ghcr.io/shotgun-sh/shotgun:latest --port 3000 61 | ``` 62 | 63 | ## Background Mode 64 | 65 | Run in the background with auto-restart: 66 | 67 | ```bash 68 | docker run -d --restart unless-stopped \ 69 | --name shotgun-web \ 70 | -p 8000:8000 \ 71 | -v $(pwd):/workspace \ 72 | -v ~/.shotgun-sh:/home/shotgun/.shotgun-sh \ 73 | ghcr.io/shotgun-sh/shotgun:latest 74 | ``` 75 | 76 | Stop the container with: 77 | ```bash 78 | docker stop shotgun-web 79 | docker rm shotgun-web 80 | ``` 81 | 82 | ## Configuration 83 | 84 | On first run, configure your API keys through the web UI at http://localhost:8000. The configuration will persist in the mounted `~/.shotgun-sh` directory. 85 | 86 | ## Full Documentation 87 | 88 | For complete Docker documentation including troubleshooting, advanced usage, and deployment guides, see [docs/DOCKER.md](docs/DOCKER.md). 89 | 90 | For general Shotgun documentation, installation options, and development setup, see the [main README](https://github.com/shotgun-sh/shotgun#readme). 91 | 92 | ## Support 93 | 94 | Join our [Discord community](https://discord.gg/5RmY6J2N7s) for help and discussion. 95 | 96 | --- 97 | 98 | **License:** MIT | **Python:** 3.11+ | **Homepage:** [shotgun.sh](https://shotgun.sh/) 99 | -------------------------------------------------------------------------------- /test/unit/agents/test_research_system_prompt.py: -------------------------------------------------------------------------------- 1 | """Test research agent system prompt rendering.""" 2 | 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | 7 | from shotgun.agents.common import build_agent_system_prompt 8 | from shotgun.agents.models import AgentDeps 9 | 10 | 11 | @pytest.fixture 12 | def mock_context(): 13 | """Create mock RunContext for testing.""" 14 | context = MagicMock() 15 | context.deps = MagicMock(spec=AgentDeps) 16 | context.deps.interactive_mode = True 17 | context.deps.sub_agent_context = None 18 | return context 19 | 20 | 21 | def test_research_system_prompt_renders_correctly(mock_context): 22 | """Test that the research agent system prompt renders without errors.""" 23 | result = build_agent_system_prompt("research", mock_context) 24 | 25 | # Verify the result is a non-empty string 26 | assert isinstance(result, str) 27 | assert len(result) > 0 28 | 29 | # Verify key content is present 30 | assert "Research Assistant" in result 31 | # The prompt should contain research-specific instructions 32 | assert "research" in result.lower() 33 | assert "artifact" in result.lower() 34 | 35 | 36 | def test_research_system_prompt_with_interactive_mode_variations(mock_context): 37 | """Test system prompt rendering with different interactive_mode values.""" 38 | # Test with interactive_mode=True 39 | mock_context.deps.interactive_mode = True 40 | result_interactive = build_agent_system_prompt("research", mock_context) 41 | assert isinstance(result_interactive, str) 42 | assert len(result_interactive) > 0 43 | 44 | # Test with interactive_mode=False 45 | mock_context.deps.interactive_mode = False 46 | result_non_interactive = build_agent_system_prompt("research", mock_context) 47 | assert isinstance(result_non_interactive, str) 48 | assert len(result_non_interactive) > 0 49 | 50 | # Both should render successfully 51 | assert ( 52 | result_interactive != result_non_interactive 53 | ) # Should be different due to interactive_mode 54 | 55 | 56 | def test_research_system_prompt_template_loading(): 57 | """Test that the system prompt function loads the correct template.""" 58 | from unittest.mock import patch 59 | 60 | mock_context = MagicMock() 61 | # Use spec=AgentDeps to prevent auto-creation of router_mode attribute 62 | mock_context.deps = MagicMock(spec=AgentDeps) 63 | mock_context.deps.interactive_mode = True 64 | mock_context.deps.sub_agent_context = None 65 | 66 | with patch("shotgun.agents.common.PromptLoader") as mock_loader_class: 67 | mock_loader = MagicMock() 68 | mock_loader_class.return_value = mock_loader 69 | mock_loader.render.return_value = "Test system prompt" 70 | 71 | result = build_agent_system_prompt("research", mock_context) 72 | 73 | # Verify render was called with correct parameters 74 | mock_loader.render.assert_called_once_with( 75 | "agents/research.j2", 76 | interactive_mode=True, 77 | mode="research", 78 | sub_agent_context=None, 79 | router_mode=None, 80 | ) 81 | assert result == "Test system prompt" 82 | -------------------------------------------------------------------------------- /src/shotgun/telemetry.py: -------------------------------------------------------------------------------- 1 | """Observability setup for Logfire.""" 2 | 3 | from shotgun.logging_config import get_early_logger 4 | from shotgun.settings import settings 5 | 6 | # Use early logger to prevent automatic StreamHandler creation 7 | logger = get_early_logger(__name__) 8 | 9 | 10 | def setup_logfire_observability() -> bool: 11 | """Set up Logfire observability if enabled. 12 | 13 | Returns: 14 | True if Logfire was successfully set up, False otherwise 15 | """ 16 | # Get Logfire configuration from settings (handles build constants + env vars) 17 | logfire_enabled = settings.telemetry.logfire_enabled 18 | logfire_token = settings.telemetry.logfire_token 19 | 20 | # Check if Logfire observability is enabled 21 | if not logfire_enabled: 22 | logger.debug("Logfire observability disabled") 23 | return False 24 | 25 | try: 26 | import logfire 27 | 28 | # Check for Logfire token 29 | if not logfire_token: 30 | logger.warning("Logfire token not set, Logfire observability disabled") 31 | return False 32 | 33 | # Configure Logfire 34 | # Always disable console output - we only want telemetry sent to the web service 35 | logfire.configure( 36 | token=logfire_token, 37 | console=False, # Never output to console, only send to Logfire service 38 | ) 39 | 40 | # Instrument Pydantic AI for better observability 41 | logfire.instrument_pydantic_ai() 42 | 43 | # Add LogfireLoggingHandler to root logger so logfire logs also go to file 44 | import logging 45 | 46 | root_logger = logging.getLogger() 47 | logfire_handler = logfire.LogfireLoggingHandler() 48 | root_logger.addHandler(logfire_handler) 49 | logger.debug("Added LogfireLoggingHandler to root logger for file integration") 50 | 51 | # Set user context using baggage for all logs and spans 52 | try: 53 | import asyncio 54 | 55 | from opentelemetry import baggage, context 56 | 57 | from shotgun.agents.config import get_config_manager 58 | 59 | config_manager = get_config_manager() 60 | shotgun_instance_id = asyncio.run(config_manager.get_shotgun_instance_id()) 61 | 62 | # Set shotgun_instance_id as baggage in global context - this will be included in all logs/spans 63 | ctx = baggage.set_baggage("shotgun_instance_id", shotgun_instance_id) 64 | context.attach(ctx) 65 | logger.debug( 66 | "Logfire user context set with shotgun_instance_id: %s", 67 | shotgun_instance_id, 68 | ) 69 | except Exception as e: 70 | logger.warning("Failed to set Logfire user context: %s", e) 71 | 72 | logger.debug("Logfire observability configured successfully") 73 | logger.debug("Token configured: %s", "Yes" if logfire_token else "No") 74 | return True 75 | 76 | except ImportError as e: 77 | logger.warning("Logfire not available: %s", e) 78 | return False 79 | except Exception as e: 80 | logger.warning("Failed to setup Logfire observability: %s", e) 81 | return False 82 | -------------------------------------------------------------------------------- /evals/datasets/router_agent/clarifying_questions_cases.py: -------------------------------------------------------------------------------- 1 | """ 2 | Router agent test cases for clarifying questions behavior. 3 | 4 | Tests that the Router asks clarifying questions when given vague/ambiguous prompts 5 | before taking action or delegating. 6 | """ 7 | 8 | from evals.models import ( 9 | AgentType, 10 | ExpectedAgentOutput, 11 | ShotgunTestCase, 12 | TestCaseContext, 13 | TestCaseInput, 14 | ) 15 | 16 | # Plan tools that should NOT be called when asking clarifying questions 17 | PLAN_TOOLS = ["create_plan", "edit_plan", "update_plan", "append_plan"] 18 | 19 | VAGUE_PROMPT_CLARIFYING_QUESTIONS = ShotgunTestCase( 20 | name="vague_prompt_clarifying_questions", 21 | inputs=TestCaseInput( 22 | prompt="I want to add a new feature to this project", 23 | agent_type=AgentType.ROUTER, 24 | context=TestCaseContext(has_codebase_indexed=False), 25 | ), 26 | expected=ExpectedAgentOutput( 27 | min_clarifying_questions=1, 28 | disallowed_tools=PLAN_TOOLS, 29 | expected_response="Questions should be high-level and help understand what feature the user wants to build", 30 | ), 31 | ) 32 | 33 | PERFORMANCE_REQUEST_ASKS_QUESTIONS = ShotgunTestCase( 34 | name="performance_request_asks_questions", 35 | inputs=TestCaseInput( 36 | prompt="This app is slow, make it faster", 37 | agent_type=AgentType.ROUTER, 38 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"), 39 | ), 40 | expected=ExpectedAgentOutput( 41 | min_clarifying_questions=1, 42 | disallowed_tools=PLAN_TOOLS, 43 | expected_response="Questions should ask about where the slowness is observed, what operations are slow, and what performance targets are expected", 44 | ), 45 | ) 46 | 47 | CACHE_REQUEST_ASKS_QUESTIONS = ShotgunTestCase( 48 | name="cache_request_asks_questions", 49 | inputs=TestCaseInput( 50 | prompt="Add a cache", 51 | agent_type=AgentType.ROUTER, 52 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"), 53 | ), 54 | expected=ExpectedAgentOutput( 55 | min_clarifying_questions=1, 56 | disallowed_tools=PLAN_TOOLS, 57 | expected_response="Questions should ask about what to cache, cache backend preferences, TTL requirements, and invalidation strategy", 58 | ), 59 | ) 60 | 61 | OPEN_SOURCE_MODELS_ASKS_QUESTIONS = ShotgunTestCase( 62 | name="open_source_models_asks_questions", 63 | inputs=TestCaseInput( 64 | prompt="I want to write a spec to add support for open source models for this project", 65 | agent_type=AgentType.ROUTER, 66 | context=TestCaseContext(has_codebase_indexed=True, codebase_name="shotgun"), 67 | ), 68 | expected=ExpectedAgentOutput( 69 | min_clarifying_questions=1, 70 | disallowed_tools=PLAN_TOOLS, 71 | expected_response="Questions should ask about which open source models, what inference backend (Ollama, vLLM, etc), and integration requirements", 72 | ), 73 | ) 74 | 75 | CLARIFYING_QUESTIONS_CASES: list[ShotgunTestCase] = [ 76 | VAGUE_PROMPT_CLARIFYING_QUESTIONS, 77 | PERFORMANCE_REQUEST_ASKS_QUESTIONS, 78 | CACHE_REQUEST_ASKS_QUESTIONS, 79 | OPEN_SOURCE_MODELS_ASKS_QUESTIONS, 80 | ] 81 | -------------------------------------------------------------------------------- /src/shotgun/agents/conversation/history/message_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for working with PydanticAI messages.""" 2 | 3 | from pydantic_ai.messages import ( 4 | ModelMessage, 5 | ModelRequest, 6 | SystemPromptPart, 7 | UserPromptPart, 8 | ) 9 | 10 | from shotgun.agents.messages import AgentSystemPrompt, SystemStatusPrompt 11 | 12 | 13 | def get_first_user_request(messages: list[ModelMessage]) -> str | None: 14 | """Extract first user request content from messages.""" 15 | for msg in messages: 16 | if isinstance(msg, ModelRequest): 17 | for part in msg.parts: 18 | if isinstance(part, UserPromptPart) and isinstance(part.content, str): 19 | return part.content 20 | return None 21 | 22 | 23 | def get_last_user_request(messages: list[ModelMessage]) -> ModelRequest | None: 24 | """Extract the last user request from messages.""" 25 | for msg in reversed(messages): 26 | if isinstance(msg, ModelRequest): 27 | for part in msg.parts: 28 | if isinstance(part, UserPromptPart): 29 | return msg 30 | return None 31 | 32 | 33 | def get_user_content_from_request(request: ModelRequest) -> str | None: 34 | """Extract user prompt content from a ModelRequest.""" 35 | for part in request.parts: 36 | if isinstance(part, UserPromptPart) and isinstance(part.content, str): 37 | return part.content 38 | return None 39 | 40 | 41 | def get_system_prompt(messages: list[ModelMessage]) -> str | None: 42 | """Extract system prompt from messages (any SystemPromptPart).""" 43 | for msg in messages: 44 | if isinstance(msg, ModelRequest): 45 | for part in msg.parts: 46 | if isinstance(part, SystemPromptPart): 47 | return part.content 48 | return None 49 | 50 | 51 | def get_agent_system_prompt(messages: list[ModelMessage]) -> str | None: 52 | """Extract the main agent system prompt from messages. 53 | 54 | Prioritizes AgentSystemPrompt but falls back to generic SystemPromptPart 55 | if no AgentSystemPrompt is found. 56 | """ 57 | # First try to find AgentSystemPrompt 58 | for msg in messages: 59 | if isinstance(msg, ModelRequest): 60 | for part in msg.parts: 61 | if isinstance(part, AgentSystemPrompt): 62 | return part.content 63 | 64 | # Fall back to any SystemPromptPart (excluding SystemStatusPrompt) 65 | for msg in messages: 66 | if isinstance(msg, ModelRequest): 67 | for part in msg.parts: 68 | if isinstance(part, SystemPromptPart) and not isinstance( 69 | part, SystemStatusPrompt 70 | ): 71 | return part.content 72 | 73 | return None 74 | 75 | 76 | def get_latest_system_status(messages: list[ModelMessage]) -> str | None: 77 | """Extract the most recent system status prompt from messages.""" 78 | # Iterate in reverse to find the most recent status 79 | for msg in reversed(messages): 80 | if isinstance(msg, ModelRequest): 81 | for part in msg.parts: 82 | if isinstance(part, SystemStatusPrompt): 83 | return part.content 84 | return None 85 | --------------------------------------------------------------------------------