code-puppy 0.0.83__tar.gz → 0.0.85__tar.gz
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.
- {code_puppy-0.0.83 → code_puppy-0.0.85}/PKG-INFO +1 -1
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/__init__.py +1 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/agent.py +8 -35
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/agent_prompts.py +1 -3
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/command_line/motd.py +1 -1
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/main.py +24 -13
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/message_history_processor.py +128 -36
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/model_factory.py +20 -14
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/state_management.py +9 -5
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/summarization_agent.py +2 -4
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/tools/__init__.py +4 -1
- code_puppy-0.0.85/code_puppy/tools/command_runner.py +432 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/tools/file_modifications.py +3 -1
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/tools/file_operations.py +30 -23
- {code_puppy-0.0.83 → code_puppy-0.0.85}/pyproject.toml +1 -1
- code_puppy-0.0.83/code_puppy/session_memory.py +0 -83
- code_puppy-0.0.83/code_puppy/tools/command_runner.py +0 -193
- {code_puppy-0.0.83 → code_puppy-0.0.85}/.gitignore +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/LICENSE +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/README.md +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/command_line/__init__.py +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/command_line/file_path_completion.py +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/command_line/meta_command_handler.py +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/command_line/model_picker_completion.py +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/command_line/prompt_toolkit_completion.py +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/command_line/utils.py +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/config.py +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/models.json +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/tools/common.py +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/tools/ts_code_map.py +0 -0
- {code_puppy-0.0.83 → code_puppy-0.0.85}/code_puppy/version_checker.py +0 -0
| @@ -7,7 +7,6 @@ from pydantic_ai.mcp import MCPServerSSE | |
| 7 7 |  | 
| 8 8 | 
             
            from code_puppy.agent_prompts import get_system_prompt
         | 
| 9 9 | 
             
            from code_puppy.model_factory import ModelFactory
         | 
| 10 | 
            -
            from code_puppy.session_memory import SessionMemory
         | 
| 11 10 | 
             
            from code_puppy.state_management import message_history_accumulator
         | 
| 12 11 | 
             
            from code_puppy.tools import register_all_tools
         | 
| 13 12 | 
             
            from code_puppy.tools.common import console
         | 
| @@ -20,24 +19,16 @@ from code_puppy.tools.common import console | |
| 20 19 |  | 
| 21 20 | 
             
            MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH", None)
         | 
| 22 21 |  | 
| 23 | 
            -
             | 
| 24 | 
            -
            PUPPY_RULES_PATH = Path("AGENT.md")
         | 
| 25 | 
            -
            PUPPY_RULES = None
         | 
| 26 | 
            -
             | 
| 27 | 
            -
             | 
| 28 | 
            -
            def load_puppy_rules(path=None):
         | 
| 22 | 
            +
            def load_puppy_rules():
         | 
| 29 23 | 
             
                global PUPPY_RULES
         | 
| 30 | 
            -
                 | 
| 31 | 
            -
                if  | 
| 32 | 
            -
                    with open( | 
| 33 | 
            -
                         | 
| 34 | 
            -
             | 
| 35 | 
            -
                    PUPPY_RULES = None
         | 
| 36 | 
            -
             | 
| 24 | 
            +
                puppy_rules_path = Path("AGENT.md")
         | 
| 25 | 
            +
                if puppy_rules_path.exists():
         | 
| 26 | 
            +
                    with open(puppy_rules_path, "r") as f:
         | 
| 27 | 
            +
                        puppy_rules = f.read()
         | 
| 28 | 
            +
                        return puppy_rules
         | 
| 37 29 |  | 
| 38 30 | 
             
            # Load at import
         | 
| 39 | 
            -
            load_puppy_rules()
         | 
| 40 | 
            -
             | 
| 31 | 
            +
            PUPPY_RULES = load_puppy_rules()
         | 
| 41 32 |  | 
| 42 33 | 
             
            class AgentResponse(pydantic.BaseModel):
         | 
| 43 34 | 
             
                """Represents a response from the agent."""
         | 
| @@ -50,20 +41,7 @@ class AgentResponse(pydantic.BaseModel): | |
| 50 41 | 
             
                )
         | 
| 51 42 |  | 
| 52 43 |  | 
| 53 | 
            -
            # --- NEW DYNAMIC AGENT LOGIC ---
         | 
| 54 | 
            -
            _LAST_MODEL_NAME = None
         | 
| 55 44 | 
             
            _code_generation_agent = None
         | 
| 56 | 
            -
            _session_memory = None
         | 
| 57 | 
            -
             | 
| 58 | 
            -
             | 
| 59 | 
            -
            def session_memory():
         | 
| 60 | 
            -
                """
         | 
| 61 | 
            -
                Returns a singleton SessionMemory instance to allow agent and tools to persist and recall context/history.
         | 
| 62 | 
            -
                """
         | 
| 63 | 
            -
                global _session_memory
         | 
| 64 | 
            -
                if _session_memory is None:
         | 
| 65 | 
            -
                    _session_memory = SessionMemory()
         | 
| 66 | 
            -
                return _session_memory
         | 
| 67 45 |  | 
| 68 46 |  | 
| 69 47 | 
             
            def _load_mcp_servers():
         | 
| @@ -101,16 +79,11 @@ def reload_code_generation_agent(): | |
| 101 79 | 
             
                    instructions=instructions,
         | 
| 102 80 | 
             
                    output_type=str,
         | 
| 103 81 | 
             
                    retries=3,
         | 
| 104 | 
            -
                    history_processors=[message_history_accumulator]
         | 
| 82 | 
            +
                    history_processors=[message_history_accumulator],
         | 
| 105 83 | 
             
                )
         | 
| 106 84 | 
             
                register_all_tools(agent)
         | 
| 107 85 | 
             
                _code_generation_agent = agent
         | 
| 108 86 | 
             
                _LAST_MODEL_NAME = model_name
         | 
| 109 | 
            -
                # NEW: Log session event
         | 
| 110 | 
            -
                try:
         | 
| 111 | 
            -
                    session_memory().log_task(f"Agent loaded with model: {model_name}")
         | 
| 112 | 
            -
                except Exception:
         | 
| 113 | 
            -
                    pass
         | 
| 114 87 | 
             
                return _code_generation_agent
         | 
| 115 88 |  | 
| 116 89 |  | 
| @@ -101,9 +101,7 @@ Important rules: | |
| 101 101 |  | 
| 102 102 | 
             
            Your solutions should be production-ready, maintainable, and follow best practices for the chosen language.
         | 
| 103 103 |  | 
| 104 | 
            -
            Return your final response as a  | 
| 105 | 
            -
             * output_message: The final output message to display to the user
         | 
| 106 | 
            -
             * awaiting_user_input: True if user input is needed to continue the task. If you get an error, you might consider asking the user for help.
         | 
| 104 | 
            +
            Return your final response as a string output
         | 
| 107 105 | 
             
            """
         | 
| 108 106 |  | 
| 109 107 |  | 
| @@ -10,7 +10,7 @@ from rich.syntax import Syntax | |
| 10 10 | 
             
            from rich.text import Text
         | 
| 11 11 |  | 
| 12 12 | 
             
            from code_puppy import __version__, state_management
         | 
| 13 | 
            -
            from code_puppy.agent import get_code_generation_agent | 
| 13 | 
            +
            from code_puppy.agent import get_code_generation_agent
         | 
| 14 14 | 
             
            from code_puppy.command_line.prompt_toolkit_completion import (
         | 
| 15 15 | 
             
                get_input_with_combined_completion,
         | 
| 16 16 | 
             
                get_prompt_with_active_model,
         | 
| @@ -21,7 +21,8 @@ from code_puppy.state_management import get_message_history, set_message_history | |
| 21 21 | 
             
            # Initialize rich console for pretty output
         | 
| 22 22 | 
             
            from code_puppy.tools.common import console
         | 
| 23 23 | 
             
            from code_puppy.version_checker import fetch_latest_version
         | 
| 24 | 
            -
            from code_puppy.message_history_processor import message_history_processor
         | 
| 24 | 
            +
            from code_puppy.message_history_processor import message_history_processor, prune_interrupted_tool_calls
         | 
| 25 | 
            +
             | 
| 25 26 |  | 
| 26 27 | 
             
            # from code_puppy.tools import *  # noqa: F403
         | 
| 27 28 |  | 
| @@ -193,13 +194,13 @@ async def interactive_mode(history_file_path: str) -> None: | |
| 193 194 | 
             
                        try:
         | 
| 194 195 | 
             
                            prettier_code_blocks()
         | 
| 195 196 | 
             
                            local_cancelled = False
         | 
| 197 | 
            +
             | 
| 196 198 | 
             
                            async def run_agent_task():
         | 
| 197 199 | 
             
                                try:
         | 
| 198 200 | 
             
                                    agent = get_code_generation_agent()
         | 
| 199 201 | 
             
                                    async with agent.run_mcp_servers():
         | 
| 200 202 | 
             
                                        return await agent.run(
         | 
| 201 | 
            -
                                            task,
         | 
| 202 | 
            -
                                            message_history=get_message_history()
         | 
| 203 | 
            +
                                            task, message_history=get_message_history()
         | 
| 203 204 | 
             
                                        )
         | 
| 204 205 | 
             
                                except Exception as e:
         | 
| 205 206 | 
             
                                    console.log("Task failed", e)
         | 
| @@ -207,20 +208,30 @@ async def interactive_mode(history_file_path: str) -> None: | |
| 207 208 | 
             
                            agent_task = asyncio.create_task(run_agent_task())
         | 
| 208 209 |  | 
| 209 210 | 
             
                            import signal
         | 
| 211 | 
            +
                            from code_puppy.tools import kill_all_running_shell_processes
         | 
| 210 212 |  | 
| 211 213 | 
             
                            original_handler = None
         | 
| 212 214 |  | 
| 215 | 
            +
                            # Ensure the interrupt handler only acts once per task
         | 
| 216 | 
            +
                            handled = False
         | 
| 213 217 | 
             
                            def keyboard_interrupt_handler(sig, frame):
         | 
| 214 218 | 
             
                                nonlocal local_cancelled
         | 
| 215 | 
            -
                                 | 
| 216 | 
            -
             | 
| 217 | 
            -
             | 
| 218 | 
            -
             | 
| 219 | 
            -
             | 
| 220 | 
            -
             | 
| 221 | 
            -
                                     | 
| 222 | 
            -
                                     | 
| 223 | 
            -
             | 
| 219 | 
            +
                                nonlocal handled
         | 
| 220 | 
            +
                                if handled:
         | 
| 221 | 
            +
                                    return
         | 
| 222 | 
            +
                                handled = True
         | 
| 223 | 
            +
                                # First, nuke any running shell processes triggered by tools
         | 
| 224 | 
            +
                                try:
         | 
| 225 | 
            +
                                    killed = kill_all_running_shell_processes()
         | 
| 226 | 
            +
                                    if killed:
         | 
| 227 | 
            +
                                        console.print(f"[yellow]Cancelled {killed} running shell process(es).[/yellow]")
         | 
| 228 | 
            +
                                    else:
         | 
| 229 | 
            +
                                        # Then cancel the agent task
         | 
| 230 | 
            +
                                        if not agent_task.done():
         | 
| 231 | 
            +
                                            agent_task.cancel()
         | 
| 232 | 
            +
                                            local_cancelled = True
         | 
| 233 | 
            +
                                except Exception as e:
         | 
| 234 | 
            +
                                    console.print(f"[dim]Shell kill error: {e}[/dim]")
         | 
| 224 235 | 
             
                            try:
         | 
| 225 236 | 
             
                                original_handler = signal.getsignal(signal.SIGINT)
         | 
| 226 237 | 
             
                                signal.signal(signal.SIGINT, keyboard_interrupt_handler)
         | 
| @@ -1,30 +1,40 @@ | |
| 1 1 | 
             
            import json
         | 
| 2 | 
            -
            import  | 
| 3 | 
            -
            from typing import List
         | 
| 2 | 
            +
            from typing import List, Set
         | 
| 4 3 | 
             
            import os
         | 
| 5 4 | 
             
            from pathlib import Path
         | 
| 6 5 |  | 
| 7 6 | 
             
            import pydantic
         | 
| 8 7 | 
             
            import tiktoken
         | 
| 9 | 
            -
            from pydantic_ai.messages import  | 
| 8 | 
            +
            from pydantic_ai.messages import (
         | 
| 9 | 
            +
                ModelMessage,
         | 
| 10 | 
            +
                TextPart,
         | 
| 11 | 
            +
                ModelResponse,
         | 
| 12 | 
            +
                ModelRequest,
         | 
| 13 | 
            +
                ToolCallPart,
         | 
| 14 | 
            +
            )
         | 
| 10 15 |  | 
| 11 | 
            -
            from code_puppy.config import get_message_history_limit
         | 
| 12 16 | 
             
            from code_puppy.tools.common import console
         | 
| 13 17 | 
             
            from code_puppy.model_factory import ModelFactory
         | 
| 14 18 | 
             
            from code_puppy.config import get_model_name
         | 
| 15 19 |  | 
| 16 20 | 
             
            # Import summarization agent
         | 
| 17 21 | 
             
            try:
         | 
| 18 | 
            -
                from code_puppy.summarization_agent import  | 
| 22 | 
            +
                from code_puppy.summarization_agent import (
         | 
| 23 | 
            +
                    get_summarization_agent as _get_summarization_agent,
         | 
| 24 | 
            +
                )
         | 
| 25 | 
            +
             | 
| 19 26 | 
             
                SUMMARIZATION_AVAILABLE = True
         | 
| 20 | 
            -
             | 
| 27 | 
            +
             | 
| 21 28 | 
             
                # Make the function available in this module's namespace for mocking
         | 
| 22 29 | 
             
                def get_summarization_agent():
         | 
| 23 30 | 
             
                    return _get_summarization_agent()
         | 
| 24 | 
            -
             | 
| 31 | 
            +
             | 
| 25 32 | 
             
            except ImportError:
         | 
| 26 33 | 
             
                SUMMARIZATION_AVAILABLE = False
         | 
| 27 | 
            -
                console.print( | 
| 34 | 
            +
                console.print(
         | 
| 35 | 
            +
                    "[yellow]Warning: Summarization agent not available. Message history will be truncated instead of summarized.[/yellow]"
         | 
| 36 | 
            +
                )
         | 
| 37 | 
            +
             | 
| 28 38 | 
             
                def get_summarization_agent():
         | 
| 29 39 | 
             
                    return None
         | 
| 30 40 |  | 
| @@ -40,10 +50,10 @@ def get_tokenizer_for_model(model_name: str): | |
| 40 50 | 
             
            def stringify_message_part(part) -> str:
         | 
| 41 51 | 
             
                """
         | 
| 42 52 | 
             
                Convert a message part to a string representation for token estimation or other uses.
         | 
| 43 | 
            -
             | 
| 53 | 
            +
             | 
| 44 54 | 
             
                Args:
         | 
| 45 55 | 
             
                    part: A message part that may contain content or be a tool call
         | 
| 46 | 
            -
             | 
| 56 | 
            +
             | 
| 47 57 | 
             
                Returns:
         | 
| 48 58 | 
             
                    String representation of the message part
         | 
| 49 59 | 
             
                """
         | 
| @@ -54,7 +64,7 @@ def stringify_message_part(part) -> str: | |
| 54 64 | 
             
                    result += str(type(part)) + ": "
         | 
| 55 65 |  | 
| 56 66 | 
             
                # Handle content
         | 
| 57 | 
            -
                if hasattr(part,  | 
| 67 | 
            +
                if hasattr(part, "content") and part.content:
         | 
| 58 68 | 
             
                    # Handle different content types
         | 
| 59 69 | 
             
                    if isinstance(part.content, str):
         | 
| 60 70 | 
             
                        result = part.content
         | 
| @@ -64,16 +74,16 @@ def stringify_message_part(part) -> str: | |
| 64 74 | 
             
                        result = json.dumps(part.content)
         | 
| 65 75 | 
             
                    else:
         | 
| 66 76 | 
             
                        result = str(part.content)
         | 
| 67 | 
            -
             | 
| 77 | 
            +
             | 
| 68 78 | 
             
                # Handle tool calls which may have additional token costs
         | 
| 69 79 | 
             
                # If part also has content, we'll process tool calls separately
         | 
| 70 | 
            -
                if hasattr(part,  | 
| 80 | 
            +
                if hasattr(part, "tool_name") and part.tool_name:
         | 
| 71 81 | 
             
                    # Estimate tokens for tool name and parameters
         | 
| 72 82 | 
             
                    tool_text = part.tool_name
         | 
| 73 83 | 
             
                    if hasattr(part, "args"):
         | 
| 74 84 | 
             
                        tool_text += f" {str(part.args)}"
         | 
| 75 85 | 
             
                    result += tool_text
         | 
| 76 | 
            -
             | 
| 86 | 
            +
             | 
| 77 87 | 
             
                return result
         | 
| 78 88 |  | 
| 79 89 |  | 
| @@ -84,27 +94,22 @@ def estimate_tokens_for_message(message: ModelMessage) -> int: | |
| 84 94 | 
             
                """
         | 
| 85 95 | 
             
                tokenizer = get_tokenizer_for_model(get_model_name())
         | 
| 86 96 | 
             
                total_tokens = 0
         | 
| 87 | 
            -
             | 
| 97 | 
            +
             | 
| 88 98 | 
             
                for part in message.parts:
         | 
| 89 99 | 
             
                    part_str = stringify_message_part(part)
         | 
| 90 100 | 
             
                    if part_str:
         | 
| 91 101 | 
             
                        tokens = tokenizer.encode(part_str)
         | 
| 92 102 | 
             
                        total_tokens += len(tokens)
         | 
| 93 | 
            -
             | 
| 103 | 
            +
             | 
| 94 104 | 
             
                return max(1, total_tokens)
         | 
| 95 105 |  | 
| 96 106 |  | 
| 97 107 | 
             
            def summarize_messages(messages: List[ModelMessage]) -> ModelMessage:
         | 
| 98 | 
            -
             | 
| 99 | 
            -
                # Get the summarization agent
         | 
| 100 108 | 
             
                summarization_agent = get_summarization_agent()
         | 
| 101 | 
            -
                message_strings = []
         | 
| 102 | 
            -
             | 
| 109 | 
            +
                message_strings: List[str] = []
         | 
| 103 110 | 
             
                for message in messages:
         | 
| 104 111 | 
             
                    for part in message.parts:
         | 
| 105 112 | 
             
                        message_strings.append(stringify_message_part(part))
         | 
| 106 | 
            -
             | 
| 107 | 
            -
             | 
| 108 113 | 
             
                summary_string = "\n".join(message_strings)
         | 
| 109 114 | 
             
                instructions = (
         | 
| 110 115 | 
             
                    "Above I've given you a log of Agentic AI steps that have been taken"
         | 
| @@ -116,19 +121,53 @@ def summarize_messages(messages: List[ModelMessage]) -> ModelMessage: | |
| 116 121 | 
             
                    "\n Make sure your result is a bulleted list of all steps and interactions."
         | 
| 117 122 | 
             
                )
         | 
| 118 123 | 
             
                try:
         | 
| 119 | 
            -
                    # Run the summarization agent
         | 
| 120 124 | 
             
                    result = summarization_agent.run_sync(f"{summary_string}\n{instructions}")
         | 
| 121 | 
            -
                    
         | 
| 122 | 
            -
                    # Create a new message with the summarized content
         | 
| 123 | 
            -
                    summarized_parts = [TextPart(result.output)]
         | 
| 124 | 
            -
                    summarized_message = ModelResponse(parts=summarized_parts)
         | 
| 125 | 
            -
                    return summarized_message
         | 
| 125 | 
            +
                    return ModelResponse(parts=[TextPart(result.output)])
         | 
| 126 126 | 
             
                except Exception as e:
         | 
| 127 127 | 
             
                    console.print(f"Summarization failed during compaction: {e}")
         | 
| 128 | 
            -
                    # Return original message if summarization fails
         | 
| 129 128 | 
             
                    return None
         | 
| 130 129 |  | 
| 131 130 |  | 
| 131 | 
            +
            # New: single-message summarization helper used by tests
         | 
| 132 | 
            +
            # - If the message has a ToolCallPart, return original message (no summarization)
         | 
| 133 | 
            +
            # - If the message has system/instructions, return original message
         | 
| 134 | 
            +
            # - Otherwise, summarize and return a new ModelRequest with the summarized content
         | 
| 135 | 
            +
            # - On any error, return the original message
         | 
| 136 | 
            +
             | 
| 137 | 
            +
             | 
| 138 | 
            +
            def summarize_message(message: ModelMessage) -> ModelMessage:
         | 
| 139 | 
            +
                if not SUMMARIZATION_AVAILABLE:
         | 
| 140 | 
            +
                    return message
         | 
| 141 | 
            +
                try:
         | 
| 142 | 
            +
                    # If the message looks like a system/instructions message, skip summarization
         | 
| 143 | 
            +
                    instructions = getattr(message, "instructions", None)
         | 
| 144 | 
            +
                    if instructions:
         | 
| 145 | 
            +
                        return message
         | 
| 146 | 
            +
                    # If any part is a tool call, skip summarization
         | 
| 147 | 
            +
                    for part in message.parts:
         | 
| 148 | 
            +
                        if isinstance(part, ToolCallPart) or getattr(part, "tool_name", None):
         | 
| 149 | 
            +
                            return message
         | 
| 150 | 
            +
                    # Build prompt from textual content parts
         | 
| 151 | 
            +
                    content_bits: List[str] = []
         | 
| 152 | 
            +
                    for part in message.parts:
         | 
| 153 | 
            +
                        s = stringify_message_part(part)
         | 
| 154 | 
            +
                        if s:
         | 
| 155 | 
            +
                            content_bits.append(s)
         | 
| 156 | 
            +
                    if not content_bits:
         | 
| 157 | 
            +
                        return message
         | 
| 158 | 
            +
                    prompt = (
         | 
| 159 | 
            +
                        "Please summarize the following user message:\n"
         | 
| 160 | 
            +
                        + "\n".join(content_bits)
         | 
| 161 | 
            +
                    )
         | 
| 162 | 
            +
                    agent = get_summarization_agent()
         | 
| 163 | 
            +
                    result = agent.run_sync(prompt)
         | 
| 164 | 
            +
                    summarized = ModelRequest([TextPart(result.output)])
         | 
| 165 | 
            +
                    return summarized
         | 
| 166 | 
            +
                except Exception as e:
         | 
| 167 | 
            +
                    console.print(f"Summarization failed: {e}")
         | 
| 168 | 
            +
                    return message
         | 
| 169 | 
            +
             | 
| 170 | 
            +
             | 
| 132 171 | 
             
            def get_model_context_length() -> int:
         | 
| 133 172 | 
             
                """
         | 
| 134 173 | 
             
                Get the context length for the currently configured model from models.json
         | 
| @@ -139,31 +178,84 @@ def get_model_context_length() -> int: | |
| 139 178 | 
             
                    models_path = Path(__file__).parent / "models.json"
         | 
| 140 179 | 
             
                else:
         | 
| 141 180 | 
             
                    models_path = Path(models_path)
         | 
| 142 | 
            -
             | 
| 181 | 
            +
             | 
| 143 182 | 
             
                model_configs = ModelFactory.load_config(str(models_path))
         | 
| 144 183 | 
             
                model_name = get_model_name()
         | 
| 145 | 
            -
             | 
| 184 | 
            +
             | 
| 146 185 | 
             
                # Get context length from model config
         | 
| 147 186 | 
             
                model_config = model_configs.get(model_name, {})
         | 
| 148 187 | 
             
                context_length = model_config.get("context_length", 128000)  # Default value
         | 
| 149 | 
            -
             | 
| 188 | 
            +
             | 
| 150 189 | 
             
                # Reserve 10% of context for response
         | 
| 151 190 | 
             
                return int(context_length)
         | 
| 152 191 |  | 
| 192 | 
            +
            def prune_interrupted_tool_calls(messages: List[ModelMessage]) -> List[ModelMessage]:
         | 
| 193 | 
            +
                """
         | 
| 194 | 
            +
                Remove any messages that participate in mismatched tool call sequences.
         | 
| 195 | 
            +
             | 
| 196 | 
            +
                A mismatched tool call id is one that appears in a ToolCall (model/tool request)
         | 
| 197 | 
            +
                without a corresponding tool return, or vice versa. We preserve original order
         | 
| 198 | 
            +
                and only drop messages that contain parts referencing mismatched tool_call_ids.
         | 
| 199 | 
            +
                """
         | 
| 200 | 
            +
                if not messages:
         | 
| 201 | 
            +
                    return messages
         | 
| 153 202 |  | 
| 154 | 
            -
             | 
| 203 | 
            +
                tool_call_ids: Set[str] = set()
         | 
| 204 | 
            +
                tool_return_ids: Set[str] = set()
         | 
| 205 | 
            +
             | 
| 206 | 
            +
                # First pass: collect ids for calls vs returns
         | 
| 207 | 
            +
                for msg in messages:
         | 
| 208 | 
            +
                    for part in getattr(msg, "parts", []) or []:
         | 
| 209 | 
            +
                        tool_call_id = getattr(part, "tool_call_id", None)
         | 
| 210 | 
            +
                        if not tool_call_id:
         | 
| 211 | 
            +
                            continue
         | 
| 212 | 
            +
                        # Heuristic: if it's an explicit ToolCallPart or has a tool_name/args,
         | 
| 213 | 
            +
                        # consider it a call; otherwise it's a return/result.
         | 
| 214 | 
            +
                        if part.part_kind == "tool-call":
         | 
| 215 | 
            +
                            tool_call_ids.add(tool_call_id)
         | 
| 216 | 
            +
                        else:
         | 
| 217 | 
            +
                            tool_return_ids.add(tool_call_id)
         | 
| 218 | 
            +
             | 
| 219 | 
            +
                mismatched: Set[str] = tool_call_ids.symmetric_difference(tool_return_ids)
         | 
| 220 | 
            +
                if not mismatched:
         | 
| 221 | 
            +
                    return messages
         | 
| 155 222 |  | 
| 223 | 
            +
                pruned: List[ModelMessage] = []
         | 
| 224 | 
            +
                dropped_count = 0
         | 
| 225 | 
            +
                for msg in messages:
         | 
| 226 | 
            +
                    has_mismatched = False
         | 
| 227 | 
            +
                    for part in getattr(msg, "parts", []) or []:
         | 
| 228 | 
            +
                        tcid = getattr(part, "tool_call_id", None)
         | 
| 229 | 
            +
                        if tcid and tcid in mismatched:
         | 
| 230 | 
            +
                            has_mismatched = True
         | 
| 231 | 
            +
                            break
         | 
| 232 | 
            +
                    if has_mismatched:
         | 
| 233 | 
            +
                        dropped_count += 1
         | 
| 234 | 
            +
                        continue
         | 
| 235 | 
            +
                    pruned.append(msg)
         | 
| 236 | 
            +
             | 
| 237 | 
            +
                if dropped_count:
         | 
| 238 | 
            +
                    console.print(f"[yellow]Pruned {dropped_count} message(s) with mismatched tool_call_id pairs[/yellow]")
         | 
| 239 | 
            +
                return pruned
         | 
| 240 | 
            +
             | 
| 241 | 
            +
             | 
| 242 | 
            +
            def message_history_processor(messages: List[ModelMessage]) -> List[ModelMessage]:
         | 
| 243 | 
            +
                # First, prune any interrupted/mismatched tool-call conversations
         | 
| 156 244 | 
             
                total_current_tokens = sum(estimate_tokens_for_message(msg) for msg in messages)
         | 
| 157 245 |  | 
| 158 246 | 
             
                model_max = get_model_context_length()
         | 
| 159 247 |  | 
| 160 248 | 
             
                proportion_used = total_current_tokens / model_max
         | 
| 161 | 
            -
                console.print(f" | 
| 249 | 
            +
                console.print(f"""
         | 
| 250 | 
            +
            [bold white on blue] Tokens in context: {total_current_tokens}, total model capacity: {model_max}, proportion used: {proportion_used}
         | 
| 251 | 
            +
            """)
         | 
| 162 252 |  | 
| 163 253 | 
             
                if proportion_used > 0.9:
         | 
| 164 254 | 
             
                    summary = summarize_messages(messages)
         | 
| 165 255 | 
             
                    result_messages = [messages[0], summary]
         | 
| 166 | 
            -
                    final_token_count = sum( | 
| 256 | 
            +
                    final_token_count = sum(
         | 
| 257 | 
            +
                        estimate_tokens_for_message(msg) for msg in result_messages
         | 
| 258 | 
            +
                    )
         | 
| 167 259 | 
             
                    console.print(f"Final token count after processing: {final_token_count}")
         | 
| 168 260 | 
             
                    return result_messages
         | 
| 169 | 
            -
                return messages
         | 
| 261 | 
            +
                return messages
         | 
| @@ -42,15 +42,17 @@ def build_httpx_proxy(proxy): | |
| 42 42 | 
             
                """Build an httpx.Proxy object from a proxy string in format ip:port:username:password"""
         | 
| 43 43 | 
             
                proxy_tokens = proxy.split(":")
         | 
| 44 44 | 
             
                if len(proxy_tokens) != 4:
         | 
| 45 | 
            -
                    raise ValueError( | 
| 46 | 
            -
             | 
| 45 | 
            +
                    raise ValueError(
         | 
| 46 | 
            +
                        f"Invalid proxy format: {proxy}. Expected format: ip:port:username:password"
         | 
| 47 | 
            +
                    )
         | 
| 48 | 
            +
             | 
| 47 49 | 
             
                ip, port, username, password = proxy_tokens
         | 
| 48 50 | 
             
                proxy_url = f"http://{ip}:{port}"
         | 
| 49 51 | 
             
                proxy_auth = (username, password)
         | 
| 50 | 
            -
             | 
| 52 | 
            +
             | 
| 51 53 | 
             
                # Log the proxy being used
         | 
| 52 54 | 
             
                console.log(f"Using proxy: {proxy_url} with username: {username}")
         | 
| 53 | 
            -
             | 
| 55 | 
            +
             | 
| 54 56 | 
             
                return httpx.Proxy(url=proxy_url, auth=proxy_auth)
         | 
| 55 57 |  | 
| 56 58 |  | 
| @@ -58,18 +60,22 @@ def get_random_proxy_from_file(file_path): | |
| 58 60 | 
             
                """Reads proxy file and returns a random proxy formatted for httpx.AsyncClient"""
         | 
| 59 61 | 
             
                if not os.path.exists(file_path):
         | 
| 60 62 | 
             
                    raise ValueError(f"Proxy file '{file_path}' not found.")
         | 
| 61 | 
            -
             | 
| 63 | 
            +
             | 
| 62 64 | 
             
                with open(file_path, "r") as f:
         | 
| 63 65 | 
             
                    proxies = [line.strip() for line in f.readlines() if line.strip()]
         | 
| 64 | 
            -
             | 
| 66 | 
            +
             | 
| 65 67 | 
             
                if not proxies:
         | 
| 66 | 
            -
                    raise ValueError( | 
| 67 | 
            -
             | 
| 68 | 
            +
                    raise ValueError(
         | 
| 69 | 
            +
                        f"Proxy file '{file_path}' is empty or contains only whitespace."
         | 
| 70 | 
            +
                    )
         | 
| 71 | 
            +
             | 
| 68 72 | 
             
                selected_proxy = random.choice(proxies)
         | 
| 69 73 | 
             
                try:
         | 
| 70 74 | 
             
                    return build_httpx_proxy(selected_proxy)
         | 
| 71 | 
            -
                except ValueError | 
| 72 | 
            -
                    console.log( | 
| 75 | 
            +
                except ValueError:
         | 
| 76 | 
            +
                    console.log(
         | 
| 77 | 
            +
                        f"Warning: Malformed proxy '{selected_proxy}' found in file '{file_path}', ignoring and continuing without proxy."
         | 
| 78 | 
            +
                    )
         | 
| 73 79 | 
             
                    return None
         | 
| 74 80 |  | 
| 75 81 |  | 
| @@ -147,13 +153,13 @@ class ModelFactory: | |
| 147 153 |  | 
| 148 154 | 
             
                    elif model_type == "custom_anthropic":
         | 
| 149 155 | 
             
                        url, headers, ca_certs_path, api_key = get_custom_config(model_config)
         | 
| 150 | 
            -
             | 
| 156 | 
            +
             | 
| 151 157 | 
             
                        # Check for proxy configuration
         | 
| 152 158 | 
             
                        proxy_file_path = os.environ.get("CODE_PUPPY_PROXIES")
         | 
| 153 159 | 
             
                        proxy = None
         | 
| 154 160 | 
             
                        if proxy_file_path:
         | 
| 155 161 | 
             
                            proxy = get_random_proxy_from_file(proxy_file_path)
         | 
| 156 | 
            -
             | 
| 162 | 
            +
             | 
| 157 163 | 
             
                        # Only pass proxy to client if it's valid
         | 
| 158 164 | 
             
                        client_args = {"headers": headers, "verify": ca_certs_path}
         | 
| 159 165 | 
             
                        if proxy is not None:
         | 
| @@ -223,13 +229,13 @@ class ModelFactory: | |
| 223 229 |  | 
| 224 230 | 
             
                    elif model_type == "custom_openai":
         | 
| 225 231 | 
             
                        url, headers, ca_certs_path, api_key = get_custom_config(model_config)
         | 
| 226 | 
            -
             | 
| 232 | 
            +
             | 
| 227 233 | 
             
                        # Check for proxy configuration
         | 
| 228 234 | 
             
                        proxy_file_path = os.environ.get("CODE_PUPPY_PROXIES")
         | 
| 229 235 | 
             
                        proxy = None
         | 
| 230 236 | 
             
                        if proxy_file_path:
         | 
| 231 237 | 
             
                            proxy = get_random_proxy_from_file(proxy_file_path)
         | 
| 232 | 
            -
             | 
| 238 | 
            +
             | 
| 233 239 | 
             
                        # Only pass proxy to client if it's valid
         | 
| 234 240 | 
             
                        client_args = {"headers": headers, "verify": ca_certs_path}
         | 
| 235 241 | 
             
                        if proxy is not None:
         | 
| @@ -1,24 +1,28 @@ | |
| 1 1 | 
             
            from typing import Any, List
         | 
| 2 2 |  | 
| 3 | 
            -
            from code_puppy.tools.common import console
         | 
| 4 3 | 
             
            from code_puppy.message_history_processor import message_history_processor
         | 
| 5 4 |  | 
| 6 5 | 
             
            _message_history: List[Any] = []
         | 
| 7 6 |  | 
| 7 | 
            +
             | 
| 8 8 | 
             
            def get_message_history() -> List[Any]:
         | 
| 9 9 | 
             
                return _message_history
         | 
| 10 10 |  | 
| 11 | 
            +
             | 
| 11 12 | 
             
            def set_message_history(history: List[Any]) -> None:
         | 
| 12 13 | 
             
                global _message_history
         | 
| 13 14 | 
             
                _message_history = history
         | 
| 14 15 |  | 
| 16 | 
            +
             | 
| 15 17 | 
             
            def clear_message_history() -> None:
         | 
| 16 18 | 
             
                global _message_history
         | 
| 17 19 | 
             
                _message_history = []
         | 
| 18 20 |  | 
| 21 | 
            +
             | 
| 19 22 | 
             
            def append_to_message_history(message: Any) -> None:
         | 
| 20 23 | 
             
                _message_history.append(message)
         | 
| 21 24 |  | 
| 25 | 
            +
             | 
| 22 26 | 
             
            def extend_message_history(history: List[Any]) -> None:
         | 
| 23 27 | 
             
                _message_history.extend(history)
         | 
| 24 28 |  | 
| @@ -37,18 +41,18 @@ def hash_message(message): | |
| 37 41 |  | 
| 38 42 | 
             
            def message_history_accumulator(messages: List[Any]):
         | 
| 39 43 | 
             
                global _message_history
         | 
| 40 | 
            -
             | 
| 44 | 
            +
             | 
| 41 45 | 
             
                message_history_hashes = set([hash_message(m) for m in _message_history])
         | 
| 42 46 | 
             
                for msg in messages:
         | 
| 43 47 | 
             
                    if hash_message(msg) not in message_history_hashes:
         | 
| 44 48 | 
             
                        _message_history.append(msg)
         | 
| 45 | 
            -
             | 
| 49 | 
            +
             | 
| 46 50 | 
             
                # Apply message history trimming using the main processor
         | 
| 47 51 | 
             
                # This ensures we maintain global state while still managing context limits
         | 
| 48 52 | 
             
                trimmed_messages = message_history_processor(_message_history)
         | 
| 49 | 
            -
             | 
| 53 | 
            +
             | 
| 50 54 | 
             
                # Update our global state with the trimmed version
         | 
| 51 55 | 
             
                # This preserves the state but keeps us within token limits
         | 
| 52 56 | 
             
                _message_history = trimmed_messages
         | 
| 53 | 
            -
             | 
| 57 | 
            +
             | 
| 54 58 | 
             
                return _message_history
         | 
| @@ -1,9 +1,7 @@ | |
| 1 1 | 
             
            import os
         | 
| 2 2 | 
             
            from pathlib import Path
         | 
| 3 3 |  | 
| 4 | 
            -
            import pydantic
         | 
| 5 4 | 
             
            from pydantic_ai import Agent
         | 
| 6 | 
            -
            from pydantic_ai.mcp import MCPServerSSE
         | 
| 7 5 |  | 
| 8 6 | 
             
            from code_puppy.model_factory import ModelFactory
         | 
| 9 7 | 
             
            from code_puppy.tools.common import console
         | 
| @@ -33,7 +31,7 @@ def reload_summarization_agent(): | |
| 33 31 | 
             
                    else Path(__file__).parent / "models.json"
         | 
| 34 32 | 
             
                )
         | 
| 35 33 | 
             
                model = ModelFactory.get_model(model_name, ModelFactory.load_config(models_path))
         | 
| 36 | 
            -
             | 
| 34 | 
            +
             | 
| 37 35 | 
             
                # Specialized instructions for summarization
         | 
| 38 36 | 
             
                instructions = """You are a message summarization expert. Your task is to summarize conversation messages 
         | 
| 39 37 | 
             
            while preserving important context and information. The summaries should be concise but capture the essential 
         | 
| @@ -51,7 +49,7 @@ When summarizing: | |
| 51 49 | 
             
                    model=model,
         | 
| 52 50 | 
             
                    instructions=instructions,
         | 
| 53 51 | 
             
                    output_type=str,
         | 
| 54 | 
            -
                    retries=1  # Fewer retries for summarization
         | 
| 52 | 
            +
                    retries=1,  # Fewer retries for summarization
         | 
| 55 53 | 
             
                )
         | 
| 56 54 | 
             
                _summarization_agent = agent
         | 
| 57 55 | 
             
                _LAST_MODEL_NAME = model_name
         | 
| @@ -1,4 +1,7 @@ | |
| 1 | 
            -
            from code_puppy.tools.command_runner import  | 
| 1 | 
            +
            from code_puppy.tools.command_runner import (
         | 
| 2 | 
            +
                register_command_runner_tools,
         | 
| 3 | 
            +
                kill_all_running_shell_processes,
         | 
| 4 | 
            +
            )
         | 
| 2 5 | 
             
            from code_puppy.tools.file_modifications import register_file_modifications_tools
         | 
| 3 6 | 
             
            from code_puppy.tools.file_operations import register_file_operations_tools
         | 
| 4 7 |  |