code-puppy 0.0.164__py3-none-any.whl → 0.0.165__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.
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.164
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
@@ -5,7 +5,7 @@ code_puppy/callbacks.py,sha256=6wYB6K_fGSCkKKEFaYOYkJT45WaV5W_NhUIzcvVH_nU,5060
5
5
  code_puppy/config.py,sha256=J8XU0iOPtfzrkDD49b4TdrdHoQmW2kaP-25PPbGGdKU,16386
6
6
  code_puppy/http_utils.py,sha256=BAvt4hed7fVMXglA7eS9gOb08h2YTuOyai6VmQq09fg,3432
7
7
  code_puppy/main.py,sha256=tYLfhUjPTJ-4S1r-pr-jSbn6kIU1iYvt2Z8lxI7zDFY,22220
8
- code_puppy/message_history_processor.py,sha256=aV-vcRcOQJPZPlrokB4CaLMxEU3Y4nDiabb9Ov_sJeU,15933
8
+ code_puppy/message_history_processor.py,sha256=ZlEMGMGiBdlP_xBmipfPtD9zjp0bt1SbRhlHn89nlPE,20375
9
9
  code_puppy/model_factory.py,sha256=z9vQbcGllgMwU0On8rPvzYxkygW2Uyd3NJmRzbKv-is,13759
10
10
  code_puppy/models.json,sha256=iXmLZGflnQcu2DRh4WUlgAhoXdvoxUc7KBhB8YxawXM,3088
11
11
  code_puppy/reopenable_async_client.py,sha256=4UJRaMp5np8cbef9F0zKQ7TPKOfyf5U-Kv-0zYUWDho,8274
@@ -104,9 +104,9 @@ code_puppy/tui/screens/help.py,sha256=eJuPaOOCp7ZSUlecearqsuX6caxWv7NQszUh0tZJjB
104
104
  code_puppy/tui/screens/mcp_install_wizard.py,sha256=xqwN5omltMkfxWZwXj3D2PbXbtrxUi1dT0XT77oxOKk,27685
105
105
  code_puppy/tui/screens/settings.py,sha256=GMpv-qa08rorAE9mj3AjmqjZFPhmeJ_GWd-DBHG6iAA,10671
106
106
  code_puppy/tui/screens/tools.py,sha256=3pr2Xkpa9Js6Yhf1A3_wQVRzFOui-KDB82LwrsdBtyk,1715
107
- code_puppy-0.0.164.data/data/code_puppy/models.json,sha256=iXmLZGflnQcu2DRh4WUlgAhoXdvoxUc7KBhB8YxawXM,3088
108
- code_puppy-0.0.164.dist-info/METADATA,sha256=3mQaqHuxj9zoChbe-Q5477PK05cpaOCpxUEdjh0lZwA,19567
109
- code_puppy-0.0.164.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
110
- code_puppy-0.0.164.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
111
- code_puppy-0.0.164.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
112
- code_puppy-0.0.164.dist-info/RECORD,,
107
+ code_puppy-0.0.165.data/data/code_puppy/models.json,sha256=iXmLZGflnQcu2DRh4WUlgAhoXdvoxUc7KBhB8YxawXM,3088
108
+ code_puppy-0.0.165.dist-info/METADATA,sha256=IHbmXg1BoiexNSYBnNpjLyNCmPVYopMvIKwMZfaiCVk,19567
109
+ code_puppy-0.0.165.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
110
+ code_puppy-0.0.165.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
111
+ code_puppy-0.0.165.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
112
+ code_puppy-0.0.165.dist-info/RECORD,,