code-puppy 0.0.163__tar.gz → 0.0.165__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.
Files changed (111) hide show
  1. {code_puppy-0.0.163 → code_puppy-0.0.165}/PKG-INFO +1 -1
  2. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/agent.py +8 -7
  3. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/agents/agent_creator_agent.py +7 -5
  4. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/command_handler.py +22 -20
  5. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/prompt_toolkit_completion.py +2 -2
  6. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/config.py +1 -1
  7. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/message_history_processor.py +123 -13
  8. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/model_factory.py +14 -3
  9. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tools/command_runner.py +0 -1
  10. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tools/file_modifications.py +2 -3
  11. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/components/chat_view.py +12 -6
  12. {code_puppy-0.0.163 → code_puppy-0.0.165}/pyproject.toml +1 -1
  13. {code_puppy-0.0.163 → code_puppy-0.0.165}/.gitignore +0 -0
  14. {code_puppy-0.0.163 → code_puppy-0.0.165}/LICENSE +0 -0
  15. {code_puppy-0.0.163 → code_puppy-0.0.165}/README.md +0 -0
  16. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/__init__.py +0 -0
  17. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/__main__.py +0 -0
  18. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/agents/__init__.py +0 -0
  19. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/agents/agent_code_puppy.py +0 -0
  20. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/agents/agent_manager.py +0 -0
  21. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/agents/agent_orchestrator.json +0 -0
  22. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/agents/base_agent.py +0 -0
  23. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/agents/json_agent.py +0 -0
  24. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/agents/runtime_manager.py +0 -0
  25. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/callbacks.py +0 -0
  26. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/__init__.py +0 -0
  27. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/file_path_completion.py +0 -0
  28. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/load_context_completion.py +0 -0
  29. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/__init__.py +0 -0
  30. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/add_command.py +0 -0
  31. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/base.py +0 -0
  32. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/handler.py +0 -0
  33. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/help_command.py +0 -0
  34. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/install_command.py +0 -0
  35. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/list_command.py +0 -0
  36. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/logs_command.py +0 -0
  37. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/remove_command.py +0 -0
  38. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/restart_command.py +0 -0
  39. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/search_command.py +0 -0
  40. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/start_all_command.py +0 -0
  41. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/start_command.py +0 -0
  42. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/status_command.py +0 -0
  43. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/stop_all_command.py +0 -0
  44. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/stop_command.py +0 -0
  45. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/test_command.py +0 -0
  46. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/utils.py +0 -0
  47. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/mcp/wizard_utils.py +0 -0
  48. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/meta_command_handler.py +0 -0
  49. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/model_picker_completion.py +0 -0
  50. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/motd.py +0 -0
  51. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/command_line/utils.py +0 -0
  52. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/http_utils.py +0 -0
  53. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/main.py +0 -0
  54. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/__init__.py +0 -0
  55. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/async_lifecycle.py +0 -0
  56. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/blocking_startup.py +0 -0
  57. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/captured_stdio_server.py +0 -0
  58. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/circuit_breaker.py +0 -0
  59. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/config_wizard.py +0 -0
  60. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/dashboard.py +0 -0
  61. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/error_isolation.py +0 -0
  62. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/examples/retry_example.py +0 -0
  63. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/health_monitor.py +0 -0
  64. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/managed_server.py +0 -0
  65. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/manager.py +0 -0
  66. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/registry.py +0 -0
  67. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/retry_manager.py +0 -0
  68. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/server_registry_catalog.py +0 -0
  69. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/status_tracker.py +0 -0
  70. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/mcp/system_tools.py +0 -0
  71. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/messaging/__init__.py +0 -0
  72. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/messaging/message_queue.py +0 -0
  73. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/messaging/queue_console.py +0 -0
  74. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/messaging/renderers.py +0 -0
  75. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/messaging/spinner/__init__.py +0 -0
  76. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/messaging/spinner/console_spinner.py +0 -0
  77. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/messaging/spinner/spinner_base.py +0 -0
  78. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/messaging/spinner/textual_spinner.py +0 -0
  79. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/models.json +0 -0
  80. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/plugins/__init__.py +0 -0
  81. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/reopenable_async_client.py +0 -0
  82. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/round_robin_model.py +0 -0
  83. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/state_management.py +0 -0
  84. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/status_display.py +0 -0
  85. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/summarization_agent.py +0 -0
  86. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tools/__init__.py +0 -0
  87. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tools/agent_tools.py +0 -0
  88. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tools/common.py +0 -0
  89. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tools/file_operations.py +0 -0
  90. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tools/tools_content.py +0 -0
  91. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/__init__.py +0 -0
  92. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/app.py +0 -0
  93. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/components/__init__.py +0 -0
  94. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/components/command_history_modal.py +0 -0
  95. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/components/copy_button.py +0 -0
  96. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/components/custom_widgets.py +0 -0
  97. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/components/human_input_modal.py +0 -0
  98. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/components/input_area.py +0 -0
  99. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/components/sidebar.py +0 -0
  100. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/components/status_bar.py +0 -0
  101. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/messages.py +0 -0
  102. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/models/__init__.py +0 -0
  103. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/models/chat_message.py +0 -0
  104. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/models/command_history.py +0 -0
  105. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/models/enums.py +0 -0
  106. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/screens/__init__.py +0 -0
  107. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/screens/help.py +0 -0
  108. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/screens/mcp_install_wizard.py +0 -0
  109. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/screens/settings.py +0 -0
  110. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/tui/screens/tools.py +0 -0
  111. {code_puppy-0.0.163 → code_puppy-0.0.165}/code_puppy/version_checker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.163
3
+ Version: 0.0.165
4
4
  Summary: Code generation agent
5
5
  Project-URL: repository, https://github.com/mpfaffenberger/code_puppy
6
6
  Project-URL: HomePage, https://github.com/mpfaffenberger/code_puppy
@@ -6,7 +6,6 @@ from pydantic_ai import Agent
6
6
  from pydantic_ai.settings import ModelSettings
7
7
  from pydantic_ai.usage import UsageLimits
8
8
 
9
- from code_puppy.agents import get_current_agent_config
10
9
  from code_puppy.message_history_processor import (
11
10
  get_model_context_length,
12
11
  message_history_accumulator,
@@ -136,11 +135,12 @@ def reload_code_generation_agent(message_group: str | None):
136
135
 
137
136
  # Check if current agent has a pinned model
138
137
  from code_puppy.agents import get_current_agent_config
138
+
139
139
  agent_config = get_current_agent_config()
140
140
  agent_model_name = None
141
- if hasattr(agent_config, 'get_model_name'):
141
+ if hasattr(agent_config, "get_model_name"):
142
142
  agent_model_name = agent_config.get_model_name()
143
-
143
+
144
144
  # Use agent-specific model if pinned, otherwise use global model
145
145
  model_name = agent_model_name if agent_model_name else get_model_name()
146
146
  emit_info(
@@ -203,17 +203,18 @@ def get_code_generation_agent(force_reload=False, message_group: str | None = No
203
203
 
204
204
  # Get the global model name
205
205
  global_model_name = get_model_name()
206
-
206
+
207
207
  # Check if current agent has a pinned model
208
208
  from code_puppy.agents import get_current_agent_config
209
+
209
210
  agent_config = get_current_agent_config()
210
211
  agent_model_name = None
211
- if hasattr(agent_config, 'get_model_name'):
212
+ if hasattr(agent_config, "get_model_name"):
212
213
  agent_model_name = agent_config.get_model_name()
213
-
214
+
214
215
  # Use agent-specific model if pinned, otherwise use global model
215
216
  model_name = agent_model_name if agent_model_name else global_model_name
216
-
217
+
217
218
  if _code_generation_agent is None or _LAST_MODEL_NAME != model_name or force_reload:
218
219
  return reload_code_generation_agent(message_group)
219
220
  return _code_generation_agent
@@ -28,15 +28,17 @@ class AgentCreatorAgent(BaseAgent):
28
28
  def get_system_prompt(self) -> str:
29
29
  available_tools = get_available_tool_names()
30
30
  agents_dir = get_user_agents_directory()
31
-
31
+
32
32
  # Load available models dynamically
33
33
  models_config = ModelFactory.load_config()
34
34
  model_descriptions = []
35
35
  for model_name, model_info in models_config.items():
36
- model_type = model_info.get('type', 'Unknown')
37
- context_length = model_info.get('context_length', 'Unknown')
38
- model_descriptions.append(f"- **{model_name}**: {model_type} model with {context_length} context")
39
-
36
+ model_type = model_info.get("type", "Unknown")
37
+ context_length = model_info.get("context_length", "Unknown")
38
+ model_descriptions.append(
39
+ f"- **{model_name}**: {model_type} model with {context_length} context"
40
+ )
41
+
40
42
  available_models_str = "\n".join(model_descriptions)
41
43
 
42
44
  return f"""You are the Agent Creator! 🏗️ Your mission is to help users create awesome JSON agent files through an interactive process.
@@ -57,7 +57,9 @@ def get_commands_help():
57
57
  )
58
58
  help_lines.append(
59
59
  Text("/compact", style="cyan")
60
- + Text(" Summarize and compact current chat history (uses compaction_strategy config)")
60
+ + Text(
61
+ " Summarize and compact current chat history (uses compaction_strategy config)"
62
+ )
61
63
  )
62
64
  help_lines.append(
63
65
  Text("/dump_context", style="cyan")
@@ -371,7 +373,7 @@ def handle_command(command: str):
371
373
 
372
374
  # If no model matched, show available models
373
375
  from code_puppy.command_line.model_picker_completion import load_model_names
374
-
376
+
375
377
  new_input = update_model_in_input(model_command)
376
378
  if new_input is not None:
377
379
  from code_puppy.agents.runtime_manager import get_runtime_agent_manager
@@ -406,76 +408,76 @@ def handle_command(command: str):
406
408
  from code_puppy.agents.json_agent import discover_json_agents
407
409
  from code_puppy.command_line.model_picker_completion import load_model_names
408
410
  import json
409
-
411
+
410
412
  tokens = command.split()
411
-
413
+
412
414
  if len(tokens) != 3:
413
415
  emit_warning("Usage: /pin_model <agent-name> <model-name>")
414
-
416
+
415
417
  # Show available models and JSON agents
416
418
  available_models = load_model_names()
417
419
  json_agents = discover_json_agents()
418
-
420
+
419
421
  emit_info("Available models:")
420
422
  for model in available_models:
421
423
  emit_info(f" [cyan]{model}[/cyan]")
422
-
424
+
423
425
  if json_agents:
424
426
  emit_info("\nAvailable JSON agents:")
425
427
  for agent_name, agent_path in json_agents.items():
426
428
  emit_info(f" [cyan]{agent_name}[/cyan] ({agent_path})")
427
429
  return True
428
-
430
+
429
431
  agent_name = tokens[1].lower()
430
432
  model_name = tokens[2]
431
-
433
+
432
434
  # Check if model exists
433
435
  available_models = load_model_names()
434
436
  if model_name not in available_models:
435
437
  emit_error(f"Model '{model_name}' not found")
436
438
  emit_warning(f"Available models: {', '.join(available_models)}")
437
439
  return True
438
-
440
+
439
441
  # Check that we're modifying a JSON agent (not a built-in Python agent)
440
442
  json_agents = discover_json_agents()
441
443
  if agent_name not in json_agents:
442
444
  emit_error(f"JSON agent '{agent_name}' not found")
443
-
445
+
444
446
  # Show available JSON agents
445
447
  if json_agents:
446
448
  emit_info("Available JSON agents:")
447
449
  for name, path in json_agents.items():
448
450
  emit_info(f" [cyan]{name}[/cyan] ({path})")
449
451
  return True
450
-
452
+
451
453
  agent_file_path = json_agents[agent_name]
452
-
454
+
453
455
  # Load, modify, and save the agent configuration
454
456
  try:
455
457
  with open(agent_file_path, "r", encoding="utf-8") as f:
456
458
  agent_config = json.load(f)
457
-
459
+
458
460
  # Set the model
459
461
  agent_config["model"] = model_name
460
-
462
+
461
463
  # Save the updated configuration
462
464
  with open(agent_file_path, "w", encoding="utf-8") as f:
463
465
  json.dump(agent_config, f, indent=2, ensure_ascii=False)
464
-
466
+
465
467
  emit_success(f"Model '{model_name}' pinned to agent '{agent_name}'")
466
-
468
+
467
469
  # If this is the current agent, reload it to use the new model
468
470
  from code_puppy.agents import get_current_agent_config
469
471
  from code_puppy.agents.runtime_manager import get_runtime_agent_manager
470
-
472
+
471
473
  current_agent = get_current_agent_config()
472
474
  if current_agent.name == agent_name:
473
475
  manager = get_runtime_agent_manager()
474
476
  manager.reload_agent()
475
477
  emit_info(f"Active agent reloaded with pinned model '{model_name}'")
476
-
478
+
477
479
  return True
478
-
480
+
479
481
  except Exception as e:
480
482
  emit_error(f"Failed to pin model to agent '{agent_name}': {e}")
481
483
  return True
@@ -147,9 +147,9 @@ def get_prompt_with_active_model(base: str = ">>> "):
147
147
 
148
148
  # Check if current agent has a pinned model
149
149
  agent_model = None
150
- if current_agent and hasattr(current_agent, 'get_model_name'):
150
+ if current_agent and hasattr(current_agent, "get_model_name"):
151
151
  agent_model = current_agent.get_model_name()
152
-
152
+
153
153
  # Determine which model to display
154
154
  if agent_model and agent_model != global_model:
155
155
  # Show both models when they differ
@@ -492,4 +492,4 @@ def save_command_to_history(command: str):
492
492
  error_msg = (
493
493
  f"❌ An unexpected error occurred while saving command history: {str(e)}"
494
494
  )
495
- direct_console.print(f"[bold red]{error_msg}[/bold red]")
495
+ direct_console.print(f"[bold red]{error_msg}[/bold red]")
@@ -1,15 +1,15 @@
1
1
  import json
2
2
  import queue
3
- from typing import Any, List, Set, Tuple
3
+ from typing import Any, Dict, List, Set, Tuple
4
4
 
5
5
  import pydantic
6
6
  from pydantic_ai.messages import ModelMessage, ModelRequest, TextPart, ToolCallPart
7
7
 
8
8
  from code_puppy.config import (
9
+ get_compaction_strategy,
10
+ get_compaction_threshold,
9
11
  get_model_name,
10
12
  get_protected_token_count,
11
- get_compaction_threshold,
12
- get_compaction_strategy,
13
13
  )
14
14
  from code_puppy.messaging import emit_error, emit_info, emit_warning
15
15
  from code_puppy.model_factory import ModelFactory
@@ -82,7 +82,9 @@ def estimate_tokens_for_message(message: ModelMessage) -> int:
82
82
 
83
83
 
84
84
  def filter_huge_messages(messages: List[ModelMessage]) -> List[ModelMessage]:
85
- filtered = [m for m in messages if estimate_tokens_for_message(m) < 50000]
85
+ # First deduplicate tool returns to clean up any duplicates
86
+ deduplicated = deduplicate_tool_returns(messages)
87
+ filtered = [m for m in deduplicated if estimate_tokens_for_message(m) < 50000]
86
88
  pruned = prune_interrupted_tool_calls(filtered)
87
89
  return pruned
88
90
 
@@ -234,21 +236,100 @@ def get_model_context_length() -> int:
234
236
  return int(context_length)
235
237
 
236
238
 
239
+ def deduplicate_tool_returns(messages: List[ModelMessage]) -> List[ModelMessage]:
240
+ """
241
+ Remove duplicate tool returns while preserving the first occurrence for each tool_call_id.
242
+
243
+ This function identifies tool-return parts that share the same tool_call_id and
244
+ removes duplicates, keeping only the first return for each id. This prevents
245
+ conversation corruption from duplicate tool_result blocks.
246
+ """
247
+ if not messages:
248
+ return messages
249
+
250
+ seen_tool_returns: Set[str] = set()
251
+ deduplicated: List[ModelMessage] = []
252
+ removed_count = 0
253
+
254
+ for msg in messages:
255
+ # Check if this message has any parts we need to filter
256
+ if not hasattr(msg, "parts") or not msg.parts:
257
+ deduplicated.append(msg)
258
+ continue
259
+
260
+ # Filter parts within this message
261
+ filtered_parts = []
262
+ msg_had_duplicates = False
263
+
264
+ for part in msg.parts:
265
+ tool_call_id = getattr(part, "tool_call_id", None)
266
+ part_kind = getattr(part, "part_kind", None)
267
+
268
+ # Check if this is a tool-return part
269
+ if tool_call_id and part_kind in {
270
+ "tool-return",
271
+ "tool-result",
272
+ "tool_result",
273
+ }:
274
+ if tool_call_id in seen_tool_returns:
275
+ # This is a duplicate return, skip it
276
+ msg_had_duplicates = True
277
+ removed_count += 1
278
+ continue
279
+ else:
280
+ # First occurrence of this return, keep it
281
+ seen_tool_returns.add(tool_call_id)
282
+ filtered_parts.append(part)
283
+ else:
284
+ # Not a tool return, always keep
285
+ filtered_parts.append(part)
286
+
287
+ # If we filtered out parts, create a new message with filtered parts
288
+ if msg_had_duplicates and filtered_parts:
289
+ # Create a new message with the same attributes but filtered parts
290
+ new_msg = type(msg)(parts=filtered_parts)
291
+ # Copy over other attributes if they exist
292
+ for attr_name in dir(msg):
293
+ if (
294
+ not attr_name.startswith("_")
295
+ and attr_name != "parts"
296
+ and hasattr(msg, attr_name)
297
+ ):
298
+ try:
299
+ setattr(new_msg, attr_name, getattr(msg, attr_name))
300
+ except (AttributeError, TypeError):
301
+ # Skip attributes that can't be set
302
+ pass
303
+ deduplicated.append(new_msg)
304
+ elif filtered_parts: # No duplicates but has parts
305
+ deduplicated.append(msg)
306
+ # If no parts remain after filtering, drop the entire message
307
+
308
+ if removed_count > 0:
309
+ emit_warning(f"Removed {removed_count} duplicate tool-return part(s)")
310
+
311
+ return deduplicated
312
+
313
+
237
314
  def prune_interrupted_tool_calls(messages: List[ModelMessage]) -> List[ModelMessage]:
238
315
  """
239
316
  Remove any messages that participate in mismatched tool call sequences.
240
317
 
241
318
  A mismatched tool call id is one that appears in a ToolCall (model/tool request)
242
- without a corresponding tool return, or vice versa. We preserve original order
243
- and only drop messages that contain parts referencing mismatched tool_call_ids.
319
+ without a corresponding tool return, or vice versa. We enforce a strict 1:1 ratio
320
+ between tool calls and tool returns. We preserve original order and only drop
321
+ messages that contain parts referencing mismatched tool_call_ids.
244
322
  """
245
323
  if not messages:
246
324
  return messages
247
325
 
248
- tool_call_ids: Set[str] = set()
249
- tool_return_ids: Set[str] = set()
326
+ # First deduplicate tool returns to clean up any duplicate returns
327
+ messages = deduplicate_tool_returns(messages)
250
328
 
251
- # First pass: collect ids for calls vs returns
329
+ tool_call_counts: Dict[str, int] = {}
330
+ tool_return_counts: Dict[str, int] = {}
331
+
332
+ # First pass: count occurrences of each tool_call_id for calls vs returns
252
333
  for msg in messages:
253
334
  for part in getattr(msg, "parts", []) or []:
254
335
  tool_call_id = getattr(part, "tool_call_id", None)
@@ -257,11 +338,25 @@ def prune_interrupted_tool_calls(messages: List[ModelMessage]) -> List[ModelMess
257
338
  # Heuristic: if it's an explicit ToolCallPart or has a tool_name/args,
258
339
  # consider it a call; otherwise it's a return/result.
259
340
  if part.part_kind == "tool-call":
260
- tool_call_ids.add(tool_call_id)
341
+ tool_call_counts[tool_call_id] = (
342
+ tool_call_counts.get(tool_call_id, 0) + 1
343
+ )
261
344
  else:
262
- tool_return_ids.add(tool_call_id)
345
+ tool_return_counts[tool_call_id] = (
346
+ tool_return_counts.get(tool_call_id, 0) + 1
347
+ )
348
+
349
+ # Find mismatched tool_call_ids (not exactly 1:1 ratio)
350
+ all_tool_ids = set(tool_call_counts.keys()) | set(tool_return_counts.keys())
351
+ mismatched: Set[str] = set()
352
+
353
+ for tool_id in all_tool_ids:
354
+ call_count = tool_call_counts.get(tool_id, 0)
355
+ return_count = tool_return_counts.get(tool_id, 0)
356
+ # Enforce strict 1:1 ratio - both must be exactly 1
357
+ if call_count != 1 or return_count != 1:
358
+ mismatched.add(tool_id)
263
359
 
264
- mismatched: Set[str] = tool_call_ids.symmetric_difference(tool_return_ids)
265
360
  if not mismatched:
266
361
  return messages
267
362
 
@@ -287,7 +382,10 @@ def prune_interrupted_tool_calls(messages: List[ModelMessage]) -> List[ModelMess
287
382
 
288
383
 
289
384
  def message_history_processor(messages: List[ModelMessage]) -> List[ModelMessage]:
290
- # First, prune any interrupted/mismatched tool-call conversations
385
+ # First, deduplicate tool returns to clean up any duplicates
386
+ messages = deduplicate_tool_returns(messages)
387
+
388
+ # Then, prune any interrupted/mismatched tool-call conversations
291
389
  total_current_tokens = sum(estimate_tokens_for_message(msg) for msg in messages)
292
390
 
293
391
  model_max = get_model_context_length()
@@ -379,6 +477,8 @@ def truncation(
379
477
  messages: List[ModelMessage], protected_tokens: int
380
478
  ) -> List[ModelMessage]:
381
479
  emit_info("Truncating message history to manage token usage")
480
+ # First deduplicate tool returns to clean up any duplicates
481
+ messages = deduplicate_tool_returns(messages)
382
482
  result = [messages[0]] # Always keep the first message (system prompt)
383
483
  num_tokens = 0
384
484
  stack = queue.LifoQueue()
@@ -401,6 +501,10 @@ def truncation(
401
501
 
402
502
  def message_history_accumulator(messages: List[Any]):
403
503
  _message_history = get_message_history()
504
+
505
+ # Deduplicate tool returns in current history before processing new messages
506
+ _message_history = deduplicate_tool_returns(_message_history)
507
+
404
508
  message_history_hashes = set([hash_message(m) for m in _message_history])
405
509
  for msg in messages:
406
510
  if (
@@ -409,6 +513,12 @@ def message_history_accumulator(messages: List[Any]):
409
513
  ):
410
514
  _message_history.append(msg)
411
515
 
516
+ # Deduplicate tool returns again after adding new messages to ensure no duplicates
517
+ _message_history = deduplicate_tool_returns(_message_history)
518
+
519
+ # Update the message history with deduplicated messages
520
+ set_message_history(_message_history)
521
+
412
522
  # Apply message history trimming using the main processor
413
523
  # This ensures we maintain global state while still managing context limits
414
524
  message_history_processor(_message_history)
@@ -95,9 +95,20 @@ class ModelFactory:
95
95
  config = json.load(f)
96
96
 
97
97
  if pathlib.Path(EXTRA_MODELS_FILE).exists():
98
- with open(EXTRA_MODELS_FILE, "r") as f:
99
- extra_config = json.load(f)
100
- config.update(extra_config)
98
+ try:
99
+ with open(EXTRA_MODELS_FILE, "r") as f:
100
+ extra_config = json.load(f)
101
+ config.update(extra_config)
102
+ except json.JSONDecodeError as e:
103
+ logging.getLogger(__name__).warning(
104
+ f"Failed to load extra models config from {EXTRA_MODELS_FILE}: Invalid JSON - {e}\n"
105
+ f"Please check your extra_models.json file for syntax errors."
106
+ )
107
+ except Exception as e:
108
+ logging.getLogger(__name__).warning(
109
+ f"Failed to load extra models config from {EXTRA_MODELS_FILE}: {e}\n"
110
+ f"The extra models configuration will be ignored."
111
+ )
101
112
  return config
102
113
 
103
114
  @staticmethod
@@ -12,7 +12,6 @@ from pydantic_ai import RunContext
12
12
  from rich.markdown import Markdown
13
13
  from rich.text import Text
14
14
 
15
- from code_puppy.callbacks import on_run_shell_command
16
15
  from code_puppy.messaging import (
17
16
  emit_divider,
18
17
  emit_error,
@@ -20,7 +20,6 @@ import json_repair
20
20
  from pydantic import BaseModel
21
21
  from pydantic_ai import RunContext
22
22
 
23
- from code_puppy.callbacks import on_delete_file, on_edit_file
24
23
  from code_puppy.messaging import emit_error, emit_info, emit_warning
25
24
  from code_puppy.tools.common import _find_best_window, generate_group_id
26
25
 
@@ -567,9 +566,9 @@ def register_edit_file(agent):
567
566
  except Exception as e:
568
567
  return {
569
568
  "success": False,
570
- "path": 'Not retrievable in Payload',
569
+ "path": "Not retrievable in Payload",
571
570
  "message": f"edit_file call failed: {str(e)} - this means the tool failed to parse your inputs. Refer to the following examples: {parse_error_message}",
572
- "changed": False
571
+ "changed": False,
573
572
  }
574
573
 
575
574
  # Call _edit_file which will extract file_path from payload and handle group_id generation
@@ -262,29 +262,35 @@ class ChatView(VerticalScroll):
262
262
  separator = "\n"
263
263
 
264
264
  # Handle content concatenation carefully to preserve Rich objects
265
- if hasattr(last_message.content, "__rich_console__") or hasattr(message.content, "__rich_console__"):
265
+ if hasattr(last_message.content, "__rich_console__") or hasattr(
266
+ message.content, "__rich_console__"
267
+ ):
266
268
  # If either content is a Rich object, convert both to text and concatenate
267
269
  from io import StringIO
268
270
  from rich.console import Console
269
-
271
+
270
272
  # Convert existing content to string
271
273
  if hasattr(last_message.content, "__rich_console__"):
272
274
  string_io = StringIO()
273
- temp_console = Console(file=string_io, width=80, legacy_windows=False, markup=False)
275
+ temp_console = Console(
276
+ file=string_io, width=80, legacy_windows=False, markup=False
277
+ )
274
278
  temp_console.print(last_message.content)
275
279
  existing_content = string_io.getvalue().rstrip("\n")
276
280
  else:
277
281
  existing_content = str(last_message.content)
278
-
282
+
279
283
  # Convert new content to string
280
284
  if hasattr(message.content, "__rich_console__"):
281
285
  string_io = StringIO()
282
- temp_console = Console(file=string_io, width=80, legacy_windows=False, markup=False)
286
+ temp_console = Console(
287
+ file=string_io, width=80, legacy_windows=False, markup=False
288
+ )
283
289
  temp_console.print(message.content)
284
290
  new_content = string_io.getvalue().rstrip("\n")
285
291
  else:
286
292
  new_content = str(message.content)
287
-
293
+
288
294
  # Combine as plain text
289
295
  last_message.content = existing_content + separator + new_content
290
296
  else:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "code-puppy"
7
- version = "0.0.163"
7
+ version = "0.0.165"
8
8
  description = "Code generation agent"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes