From 2344b660aae066474de61b76ee49ad110584ab3d Mon Sep 17 00:00:00 2001 From: luijait Date: Mon, 16 Jun 2025 14:31:19 +0200 Subject: [PATCH 1/2] Add multi memory cap --- src/cai/repl/commands/memory.py | 310 +++++++++++++++++++++++++++++--- 1 file changed, 288 insertions(+), 22 deletions(-) diff --git a/src/cai/repl/commands/memory.py b/src/cai/repl/commands/memory.py index 67c6bfd6..cf4cb5b2 100644 --- a/src/cai/repl/commands/memory.py +++ b/src/cai/repl/commands/memory.py @@ -43,10 +43,12 @@ MEMORY_DIR = Path.home() / ".cai" / "memory" MEMORY_INDEX_FILE = MEMORY_DIR / "index.json" # Global storage for compacted summaries (deprecated - use file storage) -COMPACTED_SUMMARIES: Dict[str, str] = {} +# Now supports multiple memories per agent +COMPACTED_SUMMARIES: Dict[str, List[str]] = {} # Global storage for memory ID mappings per agent -APPLIED_MEMORY_IDS: Dict[str, str] = {} +# Now supports multiple memory IDs per agent +APPLIED_MEMORY_IDS: Dict[str, List[str]] = {} class MemoryCommand(Command): @@ -69,6 +71,9 @@ class MemoryCommand(Command): self.add_subcommand("merge", "Merge multiple memories into one", self.handle_merge) self.add_subcommand("status", "Show memory status", self.handle_status) self.add_subcommand("compact", "Compact and save agent history", self.handle_compact) + self.add_subcommand("remove", "Remove a specific memory from an agent", self.handle_remove) + self.add_subcommand("clear", "Clear all memories from an agent", self.handle_clear) + self.add_subcommand("list-applied", "Show which memories are applied to an agent", self.handle_list_applied) # Remove local compact_model since we'll use the one from compact command @@ -93,7 +98,18 @@ class MemoryCommand(Command): return self.handle_show(args) # Otherwise show help - console.print("[yellow]Unknown subcommand. Use /memory list, save, apply, show, delete, merge, status, or compact[/yellow]") + console.print("[yellow]Unknown subcommand. Available commands:[/yellow]") + console.print("[dim] • /memory list - List all stored memories[/dim]") + console.print("[dim] • /memory save - Save current agent history as memory[/dim]") + console.print("[dim] • /memory apply - Apply a memory to an agent[/dim]") + console.print("[dim] • /memory show - Show memory content[/dim]") + console.print("[dim] • /memory delete - Delete a stored memory[/dim]") + console.print("[dim] • /memory merge - Merge multiple memories into one[/dim]") + console.print("[dim] • /memory status - Show memory status[/dim]") + console.print("[dim] • /memory compact - Compact and save agent history[/dim]") + console.print("[dim] • /memory remove - Remove a specific memory from an agent[/dim]") + console.print("[dim] • /memory clear - Clear all memories from an agent[/dim]") + console.print("[dim] • /memory list-applied - Show which memories are applied to an agent[/dim]") return True def _ensure_memory_dir(self): @@ -294,8 +310,13 @@ class MemoryCommand(Command): # Show applied memories if APPLIED_MEMORY_IDS: console.print("\n[bold cyan]:brain: Applied Memories[/bold cyan]") - for agent_name, memory_id in APPLIED_MEMORY_IDS.items(): - console.print(f" • {agent_name}: {memory_id}") + for agent_name, memory_ids in APPLIED_MEMORY_IDS.items(): + if isinstance(memory_ids, list): + ids_str = ", ".join(memory_ids) if memory_ids else "None" + console.print(f" • {agent_name}: [{ids_str}]") + else: + # Backward compatibility for single memory ID + console.print(f" • {agent_name}: {memory_ids}") # Show usage hints console.print("\n[dim]Commands:[/dim]") @@ -307,7 +328,11 @@ class MemoryCommand(Command): console.print("[dim] • /memory delete - Delete a memory[/dim]") console.print("[dim] • /memory merge - Merge multiple memories[/dim]") console.print("[dim] • /memory compact - Compact agent history to memory[/dim]") + console.print("[dim] • /memory remove - Remove a specific memory from agent[/dim]") + console.print("[dim] • /memory clear - Clear all memories from agent[/dim]") + console.print("[dim] • /memory list-applied - Show applied memories by agent[/dim]") console.print("[dim]\nNote: You can use memory IDs (e.g., M001) instead of full names[/dim]") + console.print("[dim] Agents now support multiple memories![/dim]") return True @@ -435,8 +460,13 @@ Model: {get_compact_model() or os.environ.get("CAI_MODEL", "gpt-4")} console.print(f"[green]✓ Saved memory: {memory_name} (ID: {memory_id})[/green]") # Automatically apply the memory to the agent's system prompt - COMPACTED_SUMMARIES[agent_name] = summary - APPLIED_MEMORY_IDS[agent_name] = memory_id + if agent_name not in COMPACTED_SUMMARIES: + COMPACTED_SUMMARIES[agent_name] = [] + APPLIED_MEMORY_IDS[agent_name] = [] + + # Clear existing memories and add new one (maintain single memory behavior for save) + COMPACTED_SUMMARIES[agent_name] = [summary] + APPLIED_MEMORY_IDS[agent_name] = [memory_id] console.print(f"[green]✓ Memory {memory_id} automatically applied to {agent_name}'s system prompt[/green]") # Reload the agent with the new memory @@ -548,12 +578,22 @@ Model: {get_compact_model() or os.environ.get("CAI_MODEL", "gpt-4")} success_count = 0 for agent_name in target_agents: try: - # Inject into system prompt (store for later use) - COMPACTED_SUMMARIES[agent_name] = summary + # Initialize lists if not present + if agent_name not in COMPACTED_SUMMARIES: + COMPACTED_SUMMARIES[agent_name] = [] + APPLIED_MEMORY_IDS[agent_name] = [] + + # Check if memory already applied + if memory_id and memory_id in APPLIED_MEMORY_IDS[agent_name]: + console.print(f"[yellow]Memory {memory_id} already applied to {agent_name}[/yellow]") + continue + + # Append memory (supports multiple memories) + COMPACTED_SUMMARIES[agent_name].append(summary) # Store the memory ID for this agent if memory_id: - APPLIED_MEMORY_IDS[agent_name] = memory_id + APPLIED_MEMORY_IDS[agent_name].append(memory_id) console.print(f"[green]✓ Applied memory {memory_id} to {agent_name}[/green]") else: console.print(f"[green]✓ Applied memory '{memory_identifier}' to {agent_name}[/green]") @@ -796,8 +836,14 @@ Model: Merged from {len(memory_identifiers)} memories if apply.lower() == 'y': agent_name = self._get_current_agent_name() if agent_name: - COMPACTED_SUMMARIES[agent_name] = combined_summary - APPLIED_MEMORY_IDS[agent_name] = memory_id + # Initialize lists if not present + if agent_name not in COMPACTED_SUMMARIES: + COMPACTED_SUMMARIES[agent_name] = [] + APPLIED_MEMORY_IDS[agent_name] = [] + + # Append the merged memory + COMPACTED_SUMMARIES[agent_name].append(combined_summary) + APPLIED_MEMORY_IDS[agent_name].append(memory_id) console.print(f"[green]✓ Applied merged memory {memory_id} to {agent_name}[/green]") # Reload the agent with the new memory self._reload_agent_with_memory(agent_name) @@ -820,10 +866,17 @@ Model: Merged from {len(memory_identifiers)} memories # Show applied memories (from COMPACTED_SUMMARIES) if COMPACTED_SUMMARIES: console.print("\n[yellow]Applied Memories:[/yellow]") - for agent_name, summary in COMPACTED_SUMMARIES.items(): - memory_id = APPLIED_MEMORY_IDS.get(agent_name, "Unknown") + for agent_name, summaries in COMPACTED_SUMMARIES.items(): + memory_ids = APPLIED_MEMORY_IDS.get(agent_name, []) display_name = "Global" if agent_name == "__global__" else agent_name - console.print(f" - {display_name}: {len(summary)} chars (ID: {memory_id})") + if isinstance(summaries, list): + total_chars = sum(len(s) for s in summaries) + ids_str = ", ".join(memory_ids) if memory_ids else "Unknown" + console.print(f" - {display_name}: {len(summaries)} memories, {total_chars} chars (IDs: {ids_str})") + else: + # Backward compatibility + memory_id = memory_ids if isinstance(memory_ids, str) else "Unknown" + console.print(f" - {display_name}: {len(summaries)} chars (ID: {memory_id})") else: console.print("\n[yellow]No memories currently applied[/yellow]") @@ -910,8 +963,13 @@ Model: {get_compact_model() or os.environ.get("CAI_MODEL", "gpt-4")} console.print(f"[green]✓ Saved memory: {memory_name} (ID: {memory_id})[/green]") # Automatically apply the memory to the agent's system prompt - COMPACTED_SUMMARIES[agent_name] = summary - APPLIED_MEMORY_IDS[agent_name] = memory_id + if agent_name not in COMPACTED_SUMMARIES: + COMPACTED_SUMMARIES[agent_name] = [] + APPLIED_MEMORY_IDS[agent_name] = [] + + # Clear existing memories and add new one (maintain single memory behavior for compact all) + COMPACTED_SUMMARIES[agent_name] = [summary] + APPLIED_MEMORY_IDS[agent_name] = [memory_id] console.print(f"[green]✓ Memory {memory_id} automatically applied to {agent_name}'s system prompt[/green]") # Clear the agent's history after saving @@ -1008,8 +1066,13 @@ Model: {get_compact_model() or os.environ.get("CAI_MODEL", "gpt-4")} console.print(f"[green]✓ Saved memory: {memory_name} (ID: {memory_id})[/green]") # Automatically apply the memory to the agent's system prompt - COMPACTED_SUMMARIES[agent_name] = summary - APPLIED_MEMORY_IDS[agent_name] = memory_id + if agent_name not in COMPACTED_SUMMARIES: + COMPACTED_SUMMARIES[agent_name] = [] + APPLIED_MEMORY_IDS[agent_name] = [] + + # Clear existing memories and add new one (maintain single memory behavior for compact single) + COMPACTED_SUMMARIES[agent_name] = [summary] + APPLIED_MEMORY_IDS[agent_name] = [memory_id] console.print(f"[green]✓ Memory {memory_id} automatically applied to {agent_name}'s system prompt[/green]") # Ask if user wants to clear history @@ -1377,6 +1440,169 @@ This session is being continued from a previous conversation that ran out of con except Exception as e: console.print(f"[yellow]Warning: Could not reload agent automatically: {e}[/yellow]") console.print("[dim]The memory will be applied on the next agent interaction[/dim]") + + def handle_remove(self, args: Optional[List[str]] = None) -> bool: + """Remove a specific memory from an agent.""" + if not args or len(args) < 2: + console.print("[red]Error: Memory ID and agent name required[/red]") + console.print("Usage: /memory remove ") + return False + + memory_id = args[0].upper() + agent_identifier = " ".join(args[1:]) + agent_name = self._resolve_agent_name(agent_identifier) + + if not agent_name: + console.print(f"[red]Error: Could not resolve agent '{agent_identifier}'[/red]") + return False + + # Check if agent has memories applied + if agent_name not in APPLIED_MEMORY_IDS: + console.print(f"[yellow]Agent '{agent_name}' has no memories applied[/yellow]") + return True + + memory_ids = APPLIED_MEMORY_IDS[agent_name] + summaries = COMPACTED_SUMMARIES.get(agent_name, []) + + # Handle backward compatibility + if isinstance(memory_ids, str): + if memory_ids == memory_id: + del APPLIED_MEMORY_IDS[agent_name] + if agent_name in COMPACTED_SUMMARIES: + del COMPACTED_SUMMARIES[agent_name] + console.print(f"[green]✓ Removed memory {memory_id} from {agent_name}[/green]") + self._reload_agent_with_memory(agent_name) + return True + else: + console.print(f"[yellow]Memory {memory_id} not found for agent '{agent_name}'[/yellow]") + return True + + # Handle list of memories + if memory_id not in memory_ids: + console.print(f"[yellow]Memory {memory_id} not found for agent '{agent_name}'[/yellow]") + return True + + # Find index and remove + idx = memory_ids.index(memory_id) + memory_ids.pop(idx) + if isinstance(summaries, list) and idx < len(summaries): + summaries.pop(idx) + + # Clean up if no memories left + if not memory_ids: + del APPLIED_MEMORY_IDS[agent_name] + if agent_name in COMPACTED_SUMMARIES: + del COMPACTED_SUMMARIES[agent_name] + + console.print(f"[green]✓ Removed memory {memory_id} from {agent_name}[/green]") + self._reload_agent_with_memory(agent_name) + + return True + + def handle_clear(self, args: Optional[List[str]] = None) -> bool: + """Clear all memories from an agent.""" + if not args: + console.print("[red]Error: Agent name required[/red]") + console.print("Usage: /memory clear ") + return False + + agent_identifier = " ".join(args) + agent_name = self._resolve_agent_name(agent_identifier) + + if not agent_name: + console.print(f"[red]Error: Could not resolve agent '{agent_identifier}'[/red]") + return False + + # Check if agent has memories applied + if agent_name not in APPLIED_MEMORY_IDS: + console.print(f"[yellow]Agent '{agent_name}' has no memories applied[/yellow]") + return True + + # Ask for confirmation + memory_ids = APPLIED_MEMORY_IDS.get(agent_name) + count = len(memory_ids) if isinstance(memory_ids, list) else 1 + confirm = console.input(f"Clear {count} memory(ies) from '{agent_name}'? (y/N): ") + + if confirm.lower() == 'y': + del APPLIED_MEMORY_IDS[agent_name] + if agent_name in COMPACTED_SUMMARIES: + del COMPACTED_SUMMARIES[agent_name] + console.print(f"[green]✓ Cleared all memories from {agent_name}[/green]") + self._reload_agent_with_memory(agent_name) + else: + console.print("[dim]Cancelled[/dim]") + + return True + + def handle_list_applied(self, args: Optional[List[str]] = None) -> bool: + """Show which memories are applied to an agent.""" + if not args: + # Show all agents with applied memories + if not APPLIED_MEMORY_IDS: + console.print("[yellow]No memories applied to any agents[/yellow]") + return True + + console.print("[bold cyan]Applied Memories by Agent[/bold cyan]\n") + + for agent_name, memory_ids in APPLIED_MEMORY_IDS.items(): + console.print(f"[green]{agent_name}:[/green]") + + if isinstance(memory_ids, list): + for i, memory_id in enumerate(memory_ids): + # Try to get memory details + index = self._load_index() + memory_file = index.get('mappings', {}).get(memory_id, "Unknown") + console.print(f" {i+1}. {memory_id} - {memory_file}") + else: + # Backward compatibility + index = self._load_index() + memory_file = index.get('mappings', {}).get(memory_ids, "Unknown") + console.print(f" 1. {memory_ids} - {memory_file}") + + console.print() + else: + # Show memories for specific agent + agent_identifier = " ".join(args) + agent_name = self._resolve_agent_name(agent_identifier) + + if not agent_name: + console.print(f"[red]Error: Could not resolve agent '{agent_identifier}'[/red]") + return False + + if agent_name not in APPLIED_MEMORY_IDS: + console.print(f"[yellow]No memories applied to '{agent_name}'[/yellow]") + return True + + memory_ids = APPLIED_MEMORY_IDS[agent_name] + summaries = COMPACTED_SUMMARIES.get(agent_name, []) + + console.print(f"[bold cyan]Memories applied to {agent_name}[/bold cyan]\n") + + if isinstance(memory_ids, list): + for i, memory_id in enumerate(memory_ids): + # Get memory details + index = self._load_index() + memory_file = index.get('mappings', {}).get(memory_id, "Unknown") + + # Show summary preview + summary_preview = "" + if isinstance(summaries, list) and i < len(summaries): + summary_preview = summaries[i][:100] + "..." if len(summaries[i]) > 100 else summaries[i] + + console.print(f"[green]{i+1}. {memory_id}[/green] - {memory_file}") + if summary_preview: + console.print(f" [dim]{summary_preview}[/dim]") + console.print() + else: + # Backward compatibility + index = self._load_index() + memory_file = index.get('mappings', {}).get(memory_ids, "Unknown") + console.print(f"[green]1. {memory_ids}[/green] - {memory_file}") + if isinstance(summaries, str) and summaries: + summary_preview = summaries[:100] + "..." if len(summaries) > 100 else summaries + console.print(f" [dim]{summary_preview}[/dim]") + + return True # Global instance for access from other modules @@ -1390,6 +1616,7 @@ def get_compacted_summary(agent_name: Optional[str] = None) -> Optional[str]: """Get compacted summary for injection into system prompt. This retrieves any applied memory summaries for the agent. + Now supports multiple memories per agent. Args: agent_name: Specific agent name or None for global summary @@ -1399,9 +1626,24 @@ def get_compacted_summary(agent_name: Optional[str] = None) -> Optional[str]: """ # First check for applied memories in COMPACTED_SUMMARIES if agent_name and agent_name in COMPACTED_SUMMARIES: - return COMPACTED_SUMMARIES[agent_name] + summaries = COMPACTED_SUMMARIES[agent_name] + if isinstance(summaries, list) and summaries: + # Concatenate multiple memories with clear separators + memory_ids = APPLIED_MEMORY_IDS.get(agent_name, []) + parts = [] + for i, summary in enumerate(summaries): + memory_id = memory_ids[i] if i < len(memory_ids) else "Unknown" + parts.append(f"Memory {i+1}/{len(summaries)} (ID: {memory_id}):\n{summary}") + return "\n\n---\n\n".join(parts) + elif isinstance(summaries, str): + # Backward compatibility for single memory + return summaries elif "__global__" in COMPACTED_SUMMARIES: - return COMPACTED_SUMMARIES["__global__"] + global_summaries = COMPACTED_SUMMARIES["__global__"] + if isinstance(global_summaries, list) and global_summaries: + return "\n\n---\n\n".join(global_summaries) + elif isinstance(global_summaries, str): + return global_summaries # Optionally, could auto-load the most recent memory for this agent # but for now we require explicit application @@ -1411,10 +1653,34 @@ def get_compacted_summary(agent_name: Optional[str] = None) -> Optional[str]: def get_applied_memory_id(agent_name: str) -> Optional[str]: """Get the ID of the memory currently applied to an agent. + For backward compatibility, returns first memory ID if multiple exist. + Args: agent_name: The agent name to check Returns: Memory ID if applied, None otherwise """ - return APPLIED_MEMORY_IDS.get(agent_name) \ No newline at end of file + memory_ids = APPLIED_MEMORY_IDS.get(agent_name) + if isinstance(memory_ids, list) and memory_ids: + return memory_ids[0] # Return first for backward compatibility + elif isinstance(memory_ids, str): + return memory_ids + return None + + +def get_applied_memory_ids(agent_name: str) -> List[str]: + """Get all memory IDs currently applied to an agent. + + Args: + agent_name: The agent name to check + + Returns: + List of memory IDs if applied, empty list otherwise + """ + memory_ids = APPLIED_MEMORY_IDS.get(agent_name, []) + if isinstance(memory_ids, list): + return memory_ids + elif isinstance(memory_ids, str): + return [memory_ids] # Convert single ID to list + return [] \ No newline at end of file From e43c30f0e585f895883caef67425e7abaca16636 Mon Sep 17 00:00:00 2001 From: luijait Date: Mon, 16 Jun 2025 15:10:22 +0200 Subject: [PATCH 2/2] Improve chronological merge strategy --- src/cai/repl/commands/parallel.py | 115 +++++++++++++++++++++++------- uv.lock | 16 ++++- 2 files changed, 103 insertions(+), 28 deletions(-) diff --git a/src/cai/repl/commands/parallel.py b/src/cai/repl/commands/parallel.py index f0c05bdc..ee734736 100644 --- a/src/cai/repl/commands/parallel.py +++ b/src/cai/repl/commands/parallel.py @@ -951,36 +951,91 @@ class ParallelCommand(Command): def _merge_chronological( self, all_histories: dict[str, list], agents_to_merge: list[str] ) -> list[dict[str, Any]]: - """Merge histories chronologically by timestamp.""" - # Collect all messages with agent source - all_messages = [] - - # Create a global message counter for better chronological ordering - global_idx = 0 - - for agent_idx, agent_name in enumerate(agents_to_merge): + """Merge histories chronologically by interleaving messages based on conversation flow.""" + # Collect all messages with agent source and their indices + agent_messages = {} + for agent_name in agents_to_merge: history = all_histories.get(agent_name, []) - for local_idx, msg in enumerate(history): - # Create a copy of the message to avoid modifying original + agent_messages[agent_name] = [] + for idx, msg in enumerate(history): msg_copy = msg.copy() - # Add metadata about source agent and order msg_copy["_source_agent"] = agent_name - msg_copy["_original_index"] = local_idx - msg_copy["_agent_index"] = agent_idx - # Create a unique timestamp that considers both agent and message order - # This ensures messages from different agents are properly interleaved - if "_timestamp" not in msg_copy: - # Use global index to maintain proper chronological order - msg_copy["_timestamp"] = global_idx - global_idx += 1 - all_messages.append(msg_copy) + msg_copy["_original_index"] = idx + agent_messages[agent_name].append(msg_copy) - # Sort by timestamp to maintain chronological order - all_messages.sort(key=lambda x: x.get("_timestamp", 0)) + # Create indices to track position in each agent's history + indices = {agent: 0 for agent in agents_to_merge} + + # Process messages in an intelligent interleaved fashion + all_messages = [] + + while any(indices[agent] < len(agent_messages[agent]) for agent in agents_to_merge): + # Look for the next user message across all agents + next_user_msgs = [] + for agent in agents_to_merge: + if indices[agent] < len(agent_messages[agent]): + msg = agent_messages[agent][indices[agent]] + if msg.get("role") == "user": + next_user_msgs.append((agent, msg)) + + if next_user_msgs: + # Process the first user message found (they should be similar across agents) + chosen_agent, user_msg = next_user_msgs[0] + all_messages.append(user_msg) + indices[chosen_agent] += 1 + + # Skip duplicate user messages from other agents + for agent, msg in next_user_msgs[1:]: + if msg.get("content") == user_msg.get("content"): + indices[agent] += 1 + + # Now collect all responses to this user message from all agents + responses_collected = True + while responses_collected: + responses_collected = False + + for agent in agents_to_merge: + if indices[agent] < len(agent_messages[agent]): + msg = agent_messages[agent][indices[agent]] + + # Collect assistant responses and tool interactions until next user message + if msg.get("role") in ["assistant", "tool", "system"]: + all_messages.append(msg) + indices[agent] += 1 + responses_collected = True + + # If this is a tool call, look for the corresponding tool response + if msg.get("role") == "assistant" and msg.get("tool_calls"): + tool_call_ids = [tc.get("id") for tc in msg.get("tool_calls", [])] + + # Look ahead for tool responses + temp_idx = indices[agent] + while temp_idx < len(agent_messages[agent]): + next_msg = agent_messages[agent][temp_idx] + if next_msg.get("role") == "tool" and next_msg.get("tool_call_id") in tool_call_ids: + all_messages.append(next_msg) + indices[agent] = temp_idx + 1 + break + elif next_msg.get("role") == "user": + # Stop if we hit another user message + break + temp_idx += 1 + elif msg.get("role") == "user": + # Don't process user messages here - they'll be handled in the next iteration + break + else: + # No more user messages, collect any remaining messages + for agent in agents_to_merge: + if indices[agent] < len(agent_messages[agent]): + msg = agent_messages[agent][indices[agent]] + all_messages.append(msg) + indices[agent] += 1 + break # Process one at a time to maintain some order # Process messages to create the merged history merged = [] seen_tool_calls = {} # Track tool calls by ID to avoid duplicates + seen_messages = set() # Track message signatures to avoid duplicates # Debug: show total messages collected console.print(f"[dim]Total messages collected from all agents: {len(all_messages)}[/dim]") @@ -995,18 +1050,22 @@ class ParallelCommand(Command): for msg in all_messages: should_add = True + msg_sig = self._get_message_signature(msg) - # Check for duplicate messages - if msg.get("role") == "user": + # Check if we've already seen this exact message + if msg_sig and msg_sig in seen_messages: + should_add = False + + # Additional checks for specific message types + if should_add and msg.get("role") == "user": # For user messages, check if the same content was just added - # This helps when both agents received the same user input if ( merged and merged[-1].get("role") == "user" and merged[-1].get("content") == msg.get("content") ): should_add = False - elif msg.get("role") == "assistant" and msg.get("tool_calls"): + elif should_add and msg.get("role") == "assistant" and msg.get("tool_calls"): # For tool calls, track by tool call ID for tool_call in msg.get("tool_calls", []): tool_id = tool_call.get("id") @@ -1015,7 +1074,7 @@ class ParallelCommand(Command): should_add = False break seen_tool_calls[tool_id] = msg.get("_source_agent") - elif msg.get("role") == "tool": + elif should_add and msg.get("role") == "tool": # Tool responses should match their tool calls tool_call_id = msg.get("tool_call_id") if tool_call_id and tool_call_id in seen_tool_calls: @@ -1027,6 +1086,8 @@ class ParallelCommand(Command): # Clean up internal metadata before adding clean_msg = {k: v for k, v in msg.items() if not k.startswith("_")} merged.append(clean_msg) + if msg_sig: + seen_messages.add(msg_sig) return merged diff --git a/uv.lock b/uv.lock index 9e3898ab..a62e3c6f 100644 --- a/uv.lock +++ b/uv.lock @@ -281,7 +281,7 @@ wheels = [ [[package]] name = "cai-framework" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "dnspython" }, @@ -307,6 +307,7 @@ dependencies = [ { name = "paramiko" }, { name = "prompt-toolkit" }, { name = "pydantic" }, + { name = "pypdf2" }, { name = "requests" }, { name = "rich" }, { name = "types-requests" }, @@ -370,6 +371,7 @@ requires-dist = [ { name = "paramiko", specifier = ">=3.5.1" }, { name = "prompt-toolkit", specifier = ">=3.0.39" }, { name = "pydantic", specifier = ">=2.10,<3" }, + { name = "pypdf2" }, { name = "requests", specifier = ">=2.0,<3" }, { name = "rich", specifier = ">=13.9.4" }, { name = "types-requests", specifier = ">=2.0,<3" }, @@ -3180,6 +3182,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, ] +[[package]] +name = "pypdf2" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/bb/18dc3062d37db6c491392007dfd1a7f524bb95886eb956569ac38a23a784/PyPDF2-3.0.1.tar.gz", hash = "sha256:a74408f69ba6271f71b9352ef4ed03dc53a31aa404d29b5d31f53bfecfee1440", size = 227419 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5e/c86a5643653825d3c913719e788e41386bee415c2b87b4f955432f2de6b2/pypdf2-3.0.1-py3-none-any.whl", hash = "sha256:d16e4205cfee272fbdc0568b68d82be796540b1537508cef59388f839c191928", size = 232572 }, +] + [[package]] name = "pytest" version = "8.3.5"