shotgun-sh 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/__init__.py +5 -0
- shotgun/agents/__init__.py +1 -0
- shotgun/agents/agent_manager.py +651 -0
- shotgun/agents/common.py +549 -0
- shotgun/agents/config/__init__.py +13 -0
- shotgun/agents/config/constants.py +17 -0
- shotgun/agents/config/manager.py +294 -0
- shotgun/agents/config/models.py +185 -0
- shotgun/agents/config/provider.py +206 -0
- shotgun/agents/conversation_history.py +106 -0
- shotgun/agents/conversation_manager.py +105 -0
- shotgun/agents/export.py +96 -0
- shotgun/agents/history/__init__.py +5 -0
- shotgun/agents/history/compaction.py +85 -0
- shotgun/agents/history/constants.py +19 -0
- shotgun/agents/history/context_extraction.py +108 -0
- shotgun/agents/history/history_building.py +104 -0
- shotgun/agents/history/history_processors.py +426 -0
- shotgun/agents/history/message_utils.py +84 -0
- shotgun/agents/history/token_counting.py +429 -0
- shotgun/agents/history/token_estimation.py +138 -0
- shotgun/agents/messages.py +35 -0
- shotgun/agents/models.py +275 -0
- shotgun/agents/plan.py +98 -0
- shotgun/agents/research.py +108 -0
- shotgun/agents/specify.py +98 -0
- shotgun/agents/tasks.py +96 -0
- shotgun/agents/tools/__init__.py +34 -0
- shotgun/agents/tools/codebase/__init__.py +28 -0
- shotgun/agents/tools/codebase/codebase_shell.py +256 -0
- shotgun/agents/tools/codebase/directory_lister.py +141 -0
- shotgun/agents/tools/codebase/file_read.py +144 -0
- shotgun/agents/tools/codebase/models.py +252 -0
- shotgun/agents/tools/codebase/query_graph.py +67 -0
- shotgun/agents/tools/codebase/retrieve_code.py +81 -0
- shotgun/agents/tools/file_management.py +218 -0
- shotgun/agents/tools/user_interaction.py +37 -0
- shotgun/agents/tools/web_search/__init__.py +60 -0
- shotgun/agents/tools/web_search/anthropic.py +144 -0
- shotgun/agents/tools/web_search/gemini.py +85 -0
- shotgun/agents/tools/web_search/openai.py +98 -0
- shotgun/agents/tools/web_search/utils.py +20 -0
- shotgun/build_constants.py +20 -0
- shotgun/cli/__init__.py +1 -0
- shotgun/cli/codebase/__init__.py +5 -0
- shotgun/cli/codebase/commands.py +202 -0
- shotgun/cli/codebase/models.py +21 -0
- shotgun/cli/config.py +275 -0
- shotgun/cli/export.py +81 -0
- shotgun/cli/models.py +10 -0
- shotgun/cli/plan.py +73 -0
- shotgun/cli/research.py +85 -0
- shotgun/cli/specify.py +69 -0
- shotgun/cli/tasks.py +78 -0
- shotgun/cli/update.py +152 -0
- shotgun/cli/utils.py +25 -0
- shotgun/codebase/__init__.py +12 -0
- shotgun/codebase/core/__init__.py +46 -0
- shotgun/codebase/core/change_detector.py +358 -0
- shotgun/codebase/core/code_retrieval.py +243 -0
- shotgun/codebase/core/ingestor.py +1497 -0
- shotgun/codebase/core/language_config.py +297 -0
- shotgun/codebase/core/manager.py +1662 -0
- shotgun/codebase/core/nl_query.py +331 -0
- shotgun/codebase/core/parser_loader.py +128 -0
- shotgun/codebase/models.py +111 -0
- shotgun/codebase/service.py +206 -0
- shotgun/logging_config.py +227 -0
- shotgun/main.py +167 -0
- shotgun/posthog_telemetry.py +158 -0
- shotgun/prompts/__init__.py +5 -0
- shotgun/prompts/agents/__init__.py +1 -0
- shotgun/prompts/agents/export.j2 +350 -0
- shotgun/prompts/agents/partials/codebase_understanding.j2 +87 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +37 -0
- shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
- shotgun/prompts/agents/partials/interactive_mode.j2 +26 -0
- shotgun/prompts/agents/plan.j2 +144 -0
- shotgun/prompts/agents/research.j2 +69 -0
- shotgun/prompts/agents/specify.j2 +51 -0
- shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +19 -0
- shotgun/prompts/agents/state/system_state.j2 +31 -0
- shotgun/prompts/agents/tasks.j2 +143 -0
- shotgun/prompts/codebase/__init__.py +1 -0
- shotgun/prompts/codebase/cypher_query_patterns.j2 +223 -0
- shotgun/prompts/codebase/cypher_system.j2 +28 -0
- shotgun/prompts/codebase/enhanced_query_context.j2 +10 -0
- shotgun/prompts/codebase/partials/cypher_rules.j2 +24 -0
- shotgun/prompts/codebase/partials/graph_schema.j2 +30 -0
- shotgun/prompts/codebase/partials/temporal_context.j2 +21 -0
- shotgun/prompts/history/__init__.py +1 -0
- shotgun/prompts/history/incremental_summarization.j2 +53 -0
- shotgun/prompts/history/summarization.j2 +46 -0
- shotgun/prompts/loader.py +140 -0
- shotgun/py.typed +0 -0
- shotgun/sdk/__init__.py +13 -0
- shotgun/sdk/codebase.py +219 -0
- shotgun/sdk/exceptions.py +17 -0
- shotgun/sdk/models.py +189 -0
- shotgun/sdk/services.py +23 -0
- shotgun/sentry_telemetry.py +87 -0
- shotgun/telemetry.py +93 -0
- shotgun/tui/__init__.py +0 -0
- shotgun/tui/app.py +116 -0
- shotgun/tui/commands/__init__.py +76 -0
- shotgun/tui/components/prompt_input.py +69 -0
- shotgun/tui/components/spinner.py +86 -0
- shotgun/tui/components/splash.py +25 -0
- shotgun/tui/components/vertical_tail.py +13 -0
- shotgun/tui/screens/chat.py +782 -0
- shotgun/tui/screens/chat.tcss +43 -0
- shotgun/tui/screens/chat_screen/__init__.py +0 -0
- shotgun/tui/screens/chat_screen/command_providers.py +219 -0
- shotgun/tui/screens/chat_screen/hint_message.py +40 -0
- shotgun/tui/screens/chat_screen/history.py +221 -0
- shotgun/tui/screens/directory_setup.py +113 -0
- shotgun/tui/screens/provider_config.py +221 -0
- shotgun/tui/screens/splash.py +31 -0
- shotgun/tui/styles.tcss +10 -0
- shotgun/tui/utils/__init__.py +5 -0
- shotgun/tui/utils/mode_progress.py +257 -0
- shotgun/utils/__init__.py +5 -0
- shotgun/utils/env_utils.py +35 -0
- shotgun/utils/file_system_utils.py +36 -0
- shotgun/utils/update_checker.py +375 -0
- shotgun_sh-0.1.0.dist-info/METADATA +466 -0
- shotgun_sh-0.1.0.dist-info/RECORD +130 -0
- shotgun_sh-0.1.0.dist-info/WHEEL +4 -0
- shotgun_sh-0.1.0.dist-info/entry_points.txt +2 -0
- shotgun_sh-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Models and utilities for persisting TUI conversation history."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
from pydantic_ai.messages import (
|
|
8
|
+
ModelMessage,
|
|
9
|
+
ModelMessagesTypeAdapter,
|
|
10
|
+
)
|
|
11
|
+
from pydantic_core import to_jsonable_python
|
|
12
|
+
|
|
13
|
+
from shotgun.tui.screens.chat_screen.hint_message import HintMessage
|
|
14
|
+
|
|
15
|
+
SerializedMessage = dict[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConversationState(BaseModel):
|
|
19
|
+
"""Represents the complete state of a conversation in memory."""
|
|
20
|
+
|
|
21
|
+
agent_messages: list[ModelMessage]
|
|
22
|
+
ui_messages: list[ModelMessage | HintMessage] = Field(default_factory=list)
|
|
23
|
+
agent_type: str # Will store AgentType.value
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConversationHistory(BaseModel):
|
|
29
|
+
"""Persistent conversation history for TUI sessions."""
|
|
30
|
+
|
|
31
|
+
version: int = 1
|
|
32
|
+
agent_history: list[SerializedMessage] = Field(
|
|
33
|
+
default_factory=list
|
|
34
|
+
) # Stores serialized ModelMessage objects
|
|
35
|
+
ui_history: list[SerializedMessage] = Field(
|
|
36
|
+
default_factory=list
|
|
37
|
+
) # Stores serialized ModelMessage and HintMessage objects
|
|
38
|
+
last_agent_model: str = "research"
|
|
39
|
+
updated_at: datetime = Field(default_factory=datetime.now)
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
42
|
+
|
|
43
|
+
def set_agent_messages(self, messages: list[ModelMessage]) -> None:
|
|
44
|
+
"""Set agent_history from a list of ModelMessage objects.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
messages: List of ModelMessage objects to serialize and store
|
|
48
|
+
"""
|
|
49
|
+
# Serialize ModelMessage list to JSON-serializable format
|
|
50
|
+
self.agent_history = to_jsonable_python(
|
|
51
|
+
messages, fallback=lambda x: str(x), exclude_none=True
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def set_ui_messages(self, messages: list[ModelMessage | HintMessage]) -> None:
|
|
55
|
+
"""Set ui_history from a list of UI messages."""
|
|
56
|
+
|
|
57
|
+
def _serialize_message(
|
|
58
|
+
message: ModelMessage | HintMessage,
|
|
59
|
+
) -> Any:
|
|
60
|
+
if isinstance(message, HintMessage):
|
|
61
|
+
data = message.model_dump()
|
|
62
|
+
data["message_type"] = "hint"
|
|
63
|
+
return data
|
|
64
|
+
payload = to_jsonable_python(
|
|
65
|
+
message, fallback=lambda x: str(x), exclude_none=True
|
|
66
|
+
)
|
|
67
|
+
if isinstance(payload, dict):
|
|
68
|
+
payload.setdefault("message_type", "model")
|
|
69
|
+
return payload
|
|
70
|
+
|
|
71
|
+
self.ui_history = [_serialize_message(msg) for msg in messages]
|
|
72
|
+
|
|
73
|
+
def get_agent_messages(self) -> list[ModelMessage]:
|
|
74
|
+
"""Get agent_history as a list of ModelMessage objects.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of deserialized ModelMessage objects
|
|
78
|
+
"""
|
|
79
|
+
if not self.agent_history:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
# Deserialize from JSON format back to ModelMessage objects
|
|
83
|
+
return ModelMessagesTypeAdapter.validate_python(self.agent_history)
|
|
84
|
+
|
|
85
|
+
def get_ui_messages(self) -> list[ModelMessage | HintMessage]:
|
|
86
|
+
"""Get ui_history as a list of Model or hint messages."""
|
|
87
|
+
|
|
88
|
+
if not self.ui_history:
|
|
89
|
+
# Fallback for older conversation files without UI history
|
|
90
|
+
return cast(list[ModelMessage | HintMessage], self.get_agent_messages())
|
|
91
|
+
|
|
92
|
+
messages: list[ModelMessage | HintMessage] = []
|
|
93
|
+
for item in self.ui_history:
|
|
94
|
+
message_type = item.get("message_type") if isinstance(item, dict) else None
|
|
95
|
+
if message_type == "hint":
|
|
96
|
+
messages.append(HintMessage.model_validate(item))
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Backwards compatibility: data may not include the type marker
|
|
100
|
+
payload = item
|
|
101
|
+
if isinstance(payload, dict):
|
|
102
|
+
payload = {k: v for k, v in payload.items() if k != "message_type"}
|
|
103
|
+
deserialized = ModelMessagesTypeAdapter.validate_python([payload])
|
|
104
|
+
messages.append(deserialized[0])
|
|
105
|
+
|
|
106
|
+
return messages
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Manager for handling conversation persistence operations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from shotgun.logging_config import get_logger
|
|
7
|
+
from shotgun.utils import get_shotgun_home
|
|
8
|
+
|
|
9
|
+
from .conversation_history import ConversationHistory
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConversationManager:
|
|
15
|
+
"""Handles saving and loading conversation history."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, conversation_path: Path | None = None):
|
|
18
|
+
"""Initialize ConversationManager.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
conversation_path: Path to conversation file.
|
|
22
|
+
If None, uses default ~/.shotgun-sh/conversation.json
|
|
23
|
+
"""
|
|
24
|
+
if conversation_path is None:
|
|
25
|
+
self.conversation_path = get_shotgun_home() / "conversation.json"
|
|
26
|
+
else:
|
|
27
|
+
self.conversation_path = conversation_path
|
|
28
|
+
|
|
29
|
+
def save(self, conversation: ConversationHistory) -> None:
|
|
30
|
+
"""Save conversation history to file.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
conversation: ConversationHistory to save
|
|
34
|
+
"""
|
|
35
|
+
# Ensure directory exists
|
|
36
|
+
self.conversation_path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
# Update timestamp
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
|
|
42
|
+
conversation.updated_at = datetime.now()
|
|
43
|
+
|
|
44
|
+
# Serialize to JSON using Pydantic's model_dump
|
|
45
|
+
data = conversation.model_dump(mode="json")
|
|
46
|
+
|
|
47
|
+
with open(self.conversation_path, "w", encoding="utf-8") as f:
|
|
48
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
49
|
+
|
|
50
|
+
logger.debug("Conversation saved to %s", self.conversation_path)
|
|
51
|
+
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.error(
|
|
54
|
+
"Failed to save conversation to %s: %s", self.conversation_path, e
|
|
55
|
+
)
|
|
56
|
+
# Don't raise - we don't want to interrupt the user's session
|
|
57
|
+
|
|
58
|
+
def load(self) -> ConversationHistory | None:
|
|
59
|
+
"""Load conversation history from file.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
ConversationHistory if file exists and is valid, None otherwise
|
|
63
|
+
"""
|
|
64
|
+
if not self.conversation_path.exists():
|
|
65
|
+
logger.debug("No conversation history found at %s", self.conversation_path)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with open(self.conversation_path, encoding="utf-8") as f:
|
|
70
|
+
data = json.load(f)
|
|
71
|
+
|
|
72
|
+
conversation = ConversationHistory.model_validate(data)
|
|
73
|
+
logger.debug(
|
|
74
|
+
"Conversation loaded from %s with %d agent messages",
|
|
75
|
+
self.conversation_path,
|
|
76
|
+
len(conversation.agent_history),
|
|
77
|
+
)
|
|
78
|
+
return conversation
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(
|
|
82
|
+
"Failed to load conversation from %s: %s", self.conversation_path, e
|
|
83
|
+
)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def clear(self) -> None:
|
|
87
|
+
"""Delete the conversation history file."""
|
|
88
|
+
if self.conversation_path.exists():
|
|
89
|
+
try:
|
|
90
|
+
self.conversation_path.unlink()
|
|
91
|
+
logger.debug(
|
|
92
|
+
"Conversation history cleared at %s", self.conversation_path
|
|
93
|
+
)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.error(
|
|
96
|
+
"Failed to clear conversation at %s: %s", self.conversation_path, e
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def exists(self) -> bool:
|
|
100
|
+
"""Check if a conversation history file exists.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if conversation file exists, False otherwise
|
|
104
|
+
"""
|
|
105
|
+
return self.conversation_path.exists()
|
shotgun/agents/export.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Export agent factory and functions using Pydantic AI with file-based memory."""
|
|
2
|
+
|
|
3
|
+
from functools import partial
|
|
4
|
+
|
|
5
|
+
from pydantic_ai import (
|
|
6
|
+
Agent,
|
|
7
|
+
DeferredToolRequests,
|
|
8
|
+
)
|
|
9
|
+
from pydantic_ai.agent import AgentRunResult
|
|
10
|
+
from pydantic_ai.messages import ModelMessage
|
|
11
|
+
|
|
12
|
+
from shotgun.agents.config import ProviderType
|
|
13
|
+
from shotgun.logging_config import get_logger
|
|
14
|
+
|
|
15
|
+
from .common import (
|
|
16
|
+
add_system_status_message,
|
|
17
|
+
build_agent_system_prompt,
|
|
18
|
+
create_base_agent,
|
|
19
|
+
create_usage_limits,
|
|
20
|
+
run_agent,
|
|
21
|
+
)
|
|
22
|
+
from .models import AgentDeps, AgentRuntimeOptions, AgentType
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_export_agent(
|
|
28
|
+
agent_runtime_options: AgentRuntimeOptions, provider: ProviderType | None = None
|
|
29
|
+
) -> tuple[Agent[AgentDeps, str | DeferredToolRequests], AgentDeps]:
|
|
30
|
+
"""Create an export agent with file management capabilities.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
agent_runtime_options: Agent runtime options for the agent
|
|
34
|
+
provider: Optional provider override. If None, uses configured default
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Tuple of (Configured Pydantic AI agent for export management, Agent dependencies)
|
|
38
|
+
"""
|
|
39
|
+
logger.debug("Initializing export agent")
|
|
40
|
+
# Use partial to create system prompt function for export agent
|
|
41
|
+
system_prompt_fn = partial(build_agent_system_prompt, "export")
|
|
42
|
+
|
|
43
|
+
agent, deps = create_base_agent(
|
|
44
|
+
system_prompt_fn,
|
|
45
|
+
agent_runtime_options,
|
|
46
|
+
provider=provider,
|
|
47
|
+
agent_mode=AgentType.EXPORT,
|
|
48
|
+
)
|
|
49
|
+
return agent, deps
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run_export_agent(
|
|
53
|
+
agent: Agent[AgentDeps, str | DeferredToolRequests],
|
|
54
|
+
instruction: str,
|
|
55
|
+
deps: AgentDeps,
|
|
56
|
+
message_history: list[ModelMessage] | None = None,
|
|
57
|
+
) -> AgentRunResult[str | DeferredToolRequests]:
|
|
58
|
+
"""Export artifacts based on the given instruction.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
agent: The configured export agent
|
|
62
|
+
instruction: The export instruction
|
|
63
|
+
deps: Agent dependencies
|
|
64
|
+
message_history: Optional message history for conversation continuity
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
AgentRunResult containing the export process output
|
|
68
|
+
"""
|
|
69
|
+
logger.debug("📤 Starting export for instruction: %s", instruction)
|
|
70
|
+
|
|
71
|
+
message_history = await add_system_status_message(deps, message_history)
|
|
72
|
+
|
|
73
|
+
# Let the agent use its tools to read existing artifacts and export them
|
|
74
|
+
full_prompt = f"Export artifacts or findings based on: {instruction}"
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# Create usage limits for responsible API usage
|
|
78
|
+
usage_limits = create_usage_limits()
|
|
79
|
+
|
|
80
|
+
result = await run_agent(
|
|
81
|
+
agent=agent,
|
|
82
|
+
prompt=full_prompt,
|
|
83
|
+
deps=deps,
|
|
84
|
+
message_history=message_history,
|
|
85
|
+
usage_limits=usage_limits,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
logger.debug("✅ Export completed successfully")
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
import traceback
|
|
93
|
+
|
|
94
|
+
logger.error("Full traceback:\n%s", traceback.format_exc())
|
|
95
|
+
logger.error("❌ Export failed: %s", str(e))
|
|
96
|
+
raise
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Conversation compaction utilities."""
|
|
2
|
+
|
|
3
|
+
from pydantic_ai.messages import ModelMessage
|
|
4
|
+
from pydantic_ai.usage import RequestUsage
|
|
5
|
+
|
|
6
|
+
from shotgun.agents.models import AgentDeps
|
|
7
|
+
from shotgun.logging_config import get_logger
|
|
8
|
+
|
|
9
|
+
from .token_estimation import estimate_tokens_from_messages
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def apply_persistent_compaction(
|
|
15
|
+
messages: list[ModelMessage], deps: AgentDeps
|
|
16
|
+
) -> list[ModelMessage]:
|
|
17
|
+
"""Apply compaction to message history for persistent storage.
|
|
18
|
+
|
|
19
|
+
This ensures that compacted history is actually used as the conversation baseline,
|
|
20
|
+
preventing cascading compaction issues across both CLI and TUI usage patterns.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
messages: Full message history from agent run
|
|
24
|
+
deps: Agent dependencies containing model config
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Compacted message history that should be stored as conversation state
|
|
28
|
+
"""
|
|
29
|
+
from .history_processors import token_limit_compactor
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
# Count actual token usage using shared utility
|
|
33
|
+
estimated_tokens = estimate_tokens_from_messages(messages, deps.llm_model)
|
|
34
|
+
|
|
35
|
+
# Create minimal usage info for compaction check
|
|
36
|
+
usage = RequestUsage(
|
|
37
|
+
input_tokens=estimated_tokens,
|
|
38
|
+
output_tokens=0,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Create a minimal context object for compaction
|
|
42
|
+
class MockContext:
|
|
43
|
+
def __init__(self, deps: AgentDeps, usage: RequestUsage | None):
|
|
44
|
+
self.deps = deps
|
|
45
|
+
self.usage = usage
|
|
46
|
+
|
|
47
|
+
ctx = MockContext(deps, usage)
|
|
48
|
+
compacted_messages = await token_limit_compactor(ctx, messages)
|
|
49
|
+
|
|
50
|
+
# Log the result for monitoring
|
|
51
|
+
original_size = len(messages)
|
|
52
|
+
compacted_size = len(compacted_messages)
|
|
53
|
+
|
|
54
|
+
if compacted_size < original_size:
|
|
55
|
+
reduction_pct = ((original_size - compacted_size) / original_size) * 100
|
|
56
|
+
logger.debug(
|
|
57
|
+
f"Persistent compaction applied: {original_size} → {compacted_size} messages "
|
|
58
|
+
f"({reduction_pct:.1f}% reduction)"
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
logger.debug(
|
|
62
|
+
f"No persistent compaction needed: {original_size} messages unchanged"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return compacted_messages
|
|
66
|
+
|
|
67
|
+
except Exception as e:
|
|
68
|
+
# If compaction fails, return original messages
|
|
69
|
+
# This ensures the system remains functional even if compaction has issues
|
|
70
|
+
logger.warning(f"Persistent compaction failed, using original history: {e}")
|
|
71
|
+
return messages
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def should_apply_persistent_compaction(deps: AgentDeps) -> bool:
|
|
75
|
+
"""Check if persistent compaction should be applied.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
deps: Agent dependencies
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
True if persistent compaction should be applied
|
|
82
|
+
"""
|
|
83
|
+
# For now, always apply persistent compaction
|
|
84
|
+
# Future: Add configuration option in deps or environment variable
|
|
85
|
+
return True
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
|
|
14
|
+
class SummaryType(Enum):
|
|
15
|
+
"""Types of summarization requests for logging."""
|
|
16
|
+
|
|
17
|
+
INCREMENTAL = "INCREMENTAL"
|
|
18
|
+
FULL = "FULL"
|
|
19
|
+
CONTEXT_EXTRACTION = "CONTEXT_EXTRACTION"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Context extraction utilities for history processing."""
|
|
2
|
+
|
|
3
|
+
from pydantic_ai.messages import (
|
|
4
|
+
BuiltinToolCallPart,
|
|
5
|
+
BuiltinToolReturnPart,
|
|
6
|
+
ModelMessage,
|
|
7
|
+
ModelRequest,
|
|
8
|
+
ModelResponse,
|
|
9
|
+
ModelResponsePart,
|
|
10
|
+
RetryPromptPart,
|
|
11
|
+
SystemPromptPart,
|
|
12
|
+
TextPart,
|
|
13
|
+
ThinkingPart,
|
|
14
|
+
ToolCallPart,
|
|
15
|
+
ToolReturnPart,
|
|
16
|
+
UserPromptPart,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_context_from_messages(messages: list[ModelMessage]) -> str:
|
|
21
|
+
"""Extract context from a list of messages for summarization."""
|
|
22
|
+
context = ""
|
|
23
|
+
for msg in messages:
|
|
24
|
+
if isinstance(msg, ModelResponse | ModelRequest):
|
|
25
|
+
for part in msg.parts:
|
|
26
|
+
message_content = extract_context_from_part(part)
|
|
27
|
+
if message_content:
|
|
28
|
+
context += message_content + "\n"
|
|
29
|
+
return context
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract_context_from_message_range(
|
|
33
|
+
messages: list[ModelMessage],
|
|
34
|
+
start_index: int,
|
|
35
|
+
end_index: int | None = None,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Extract context from a specific range of messages."""
|
|
38
|
+
if end_index is None:
|
|
39
|
+
end_index = len(messages)
|
|
40
|
+
|
|
41
|
+
message_slice = messages[start_index:end_index]
|
|
42
|
+
return extract_context_from_messages(message_slice)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def has_meaningful_content(messages: list[ModelMessage]) -> bool:
|
|
46
|
+
"""Check if messages contain meaningful content worth summarizing.
|
|
47
|
+
|
|
48
|
+
Only ModelResponse messages are considered meaningful for summarization.
|
|
49
|
+
User requests alone don't need summarization.
|
|
50
|
+
"""
|
|
51
|
+
for msg in messages:
|
|
52
|
+
if isinstance(msg, ModelResponse):
|
|
53
|
+
for part in msg.parts:
|
|
54
|
+
if extract_context_from_part(part):
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_context_from_part(
|
|
60
|
+
message_part: (
|
|
61
|
+
SystemPromptPart
|
|
62
|
+
| UserPromptPart
|
|
63
|
+
| ToolReturnPart
|
|
64
|
+
| RetryPromptPart
|
|
65
|
+
| ModelResponsePart
|
|
66
|
+
),
|
|
67
|
+
) -> str:
|
|
68
|
+
"""Extract context from a single message part."""
|
|
69
|
+
if isinstance(message_part, SystemPromptPart):
|
|
70
|
+
return "" # Exclude system prompts from summary
|
|
71
|
+
|
|
72
|
+
elif isinstance(message_part, UserPromptPart):
|
|
73
|
+
if isinstance(message_part.content, str):
|
|
74
|
+
return f"<USER_PROMPT>\n{message_part.content}\n</USER_PROMPT>"
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
elif isinstance(message_part, ToolReturnPart):
|
|
78
|
+
return f"<TOOL_RETURN>\n{str(message_part.content)}\n</TOOL_RETURN>"
|
|
79
|
+
|
|
80
|
+
elif isinstance(message_part, RetryPromptPart):
|
|
81
|
+
if isinstance(message_part.content, str):
|
|
82
|
+
return f"<RETRY_PROMPT>\n{message_part.content}\n</RETRY_PROMPT>"
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
# Handle ModelResponsePart types
|
|
86
|
+
elif isinstance(message_part, TextPart):
|
|
87
|
+
return f"<ASSISTANT_TEXT>\n{message_part.content}\n</ASSISTANT_TEXT>"
|
|
88
|
+
|
|
89
|
+
elif isinstance(message_part, ToolCallPart):
|
|
90
|
+
if isinstance(message_part.args, dict):
|
|
91
|
+
args_str = ", ".join(f"{k}={repr(v)}" for k, v in message_part.args.items())
|
|
92
|
+
tool_call_str = f"{message_part.tool_name}({args_str})"
|
|
93
|
+
else:
|
|
94
|
+
tool_call_str = f"{message_part.tool_name}({message_part.args})"
|
|
95
|
+
return f"<TOOL_CALL>\n{tool_call_str}\n</TOOL_CALL>"
|
|
96
|
+
|
|
97
|
+
elif isinstance(message_part, BuiltinToolCallPart):
|
|
98
|
+
return f"<BUILTIN_TOOL_CALL>\n{message_part.tool_name}\n</BUILTIN_TOOL_CALL>"
|
|
99
|
+
|
|
100
|
+
elif isinstance(message_part, BuiltinToolReturnPart):
|
|
101
|
+
return (
|
|
102
|
+
f"<BUILTIN_TOOL_RETURN>\n{message_part.tool_name}\n</BUILTIN_TOOL_RETURN>"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
elif isinstance(message_part, ThinkingPart):
|
|
106
|
+
return f"<THINKING>\n{message_part.content}\n</THINKING>"
|
|
107
|
+
|
|
108
|
+
return ""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Functions for building compacted message history."""
|
|
2
|
+
|
|
3
|
+
from pydantic_ai.messages import (
|
|
4
|
+
ModelMessage,
|
|
5
|
+
ModelRequest,
|
|
6
|
+
ModelRequestPart,
|
|
7
|
+
ModelResponse,
|
|
8
|
+
SystemPromptPart,
|
|
9
|
+
TextPart,
|
|
10
|
+
UserPromptPart,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .message_utils import (
|
|
14
|
+
get_first_user_request,
|
|
15
|
+
get_last_user_request,
|
|
16
|
+
get_system_prompt,
|
|
17
|
+
get_user_content_from_request,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_clean_compacted_history(
|
|
22
|
+
summary_part: TextPart,
|
|
23
|
+
all_messages: list[ModelMessage],
|
|
24
|
+
last_summary_index: int | None = None,
|
|
25
|
+
) -> list[ModelMessage]:
|
|
26
|
+
"""Build a clean compacted history without preserving old verbose content.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
summary_part: The marked summary part to include
|
|
30
|
+
all_messages: Original message history
|
|
31
|
+
last_summary_index: Index of the last summary (if any)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Clean compacted message history
|
|
35
|
+
"""
|
|
36
|
+
# Extract essential context from pre-summary messages (if any)
|
|
37
|
+
system_prompt = ""
|
|
38
|
+
first_user_prompt = ""
|
|
39
|
+
|
|
40
|
+
if last_summary_index is not None and last_summary_index > 0:
|
|
41
|
+
# Get system and first user from original conversation
|
|
42
|
+
pre_summary_messages = all_messages[:last_summary_index]
|
|
43
|
+
system_prompt = get_system_prompt(pre_summary_messages) or ""
|
|
44
|
+
first_user_prompt = get_first_user_request(pre_summary_messages) or ""
|
|
45
|
+
|
|
46
|
+
# Build the base structure
|
|
47
|
+
compacted_messages: list[ModelMessage] = []
|
|
48
|
+
|
|
49
|
+
# Add system/user context if it exists and is meaningful
|
|
50
|
+
if system_prompt or first_user_prompt:
|
|
51
|
+
compacted_messages.append(
|
|
52
|
+
_create_base_request(system_prompt, first_user_prompt)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Add the summary
|
|
56
|
+
summary_message = ModelResponse(parts=[summary_part])
|
|
57
|
+
compacted_messages.append(summary_message)
|
|
58
|
+
|
|
59
|
+
# Ensure proper ending
|
|
60
|
+
return ensure_ends_with_model_request(compacted_messages, all_messages)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ensure_ends_with_model_request(
|
|
64
|
+
compacted_messages: list[ModelMessage],
|
|
65
|
+
original_messages: list[ModelMessage],
|
|
66
|
+
) -> list[ModelMessage]:
|
|
67
|
+
"""Ensure the message history ends with ModelRequest for PydanticAI compatibility."""
|
|
68
|
+
last_user_request = get_last_user_request(original_messages)
|
|
69
|
+
|
|
70
|
+
if not last_user_request:
|
|
71
|
+
return compacted_messages
|
|
72
|
+
|
|
73
|
+
# Check if we need to add the last request or restructure
|
|
74
|
+
if compacted_messages and isinstance(compacted_messages[0], ModelRequest):
|
|
75
|
+
first_request = compacted_messages[0]
|
|
76
|
+
last_user_content = get_user_content_from_request(last_user_request)
|
|
77
|
+
first_user_content = get_user_content_from_request(first_request)
|
|
78
|
+
|
|
79
|
+
if last_user_content != first_user_content:
|
|
80
|
+
# Different messages - append the last request
|
|
81
|
+
compacted_messages.append(last_user_request)
|
|
82
|
+
else:
|
|
83
|
+
# Same message - restructure to end with ModelRequest
|
|
84
|
+
if len(compacted_messages) >= 2:
|
|
85
|
+
summary_message = compacted_messages[1] # The summary
|
|
86
|
+
compacted_messages = [summary_message, first_request]
|
|
87
|
+
else:
|
|
88
|
+
# No first request, just add the last one
|
|
89
|
+
compacted_messages.append(last_user_request)
|
|
90
|
+
|
|
91
|
+
return compacted_messages
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _create_base_request(system_prompt: str, user_prompt: str) -> ModelRequest:
|
|
95
|
+
"""Create the base ModelRequest with system and user prompts."""
|
|
96
|
+
parts: list[ModelRequestPart] = []
|
|
97
|
+
|
|
98
|
+
if system_prompt:
|
|
99
|
+
parts.append(SystemPromptPart(content=system_prompt))
|
|
100
|
+
|
|
101
|
+
if user_prompt:
|
|
102
|
+
parts.append(UserPromptPart(content=user_prompt))
|
|
103
|
+
|
|
104
|
+
return ModelRequest(parts=parts)
|