code-puppy 0.0.173__py3-none-any.whl → 0.0.174__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.
Files changed (29) hide show
  1. code_puppy/agent.py +11 -11
  2. code_puppy/agents/__init__.py +4 -6
  3. code_puppy/agents/agent_manager.py +15 -187
  4. code_puppy/agents/base_agent.py +470 -63
  5. code_puppy/command_line/command_handler.py +40 -41
  6. code_puppy/command_line/mcp/start_all_command.py +3 -6
  7. code_puppy/command_line/mcp/start_command.py +0 -5
  8. code_puppy/command_line/mcp/stop_all_command.py +3 -6
  9. code_puppy/command_line/mcp/stop_command.py +2 -6
  10. code_puppy/command_line/model_picker_completion.py +2 -2
  11. code_puppy/command_line/prompt_toolkit_completion.py +2 -2
  12. code_puppy/config.py +2 -2
  13. code_puppy/main.py +12 -49
  14. code_puppy/summarization_agent.py +2 -2
  15. code_puppy/tools/agent_tools.py +5 -4
  16. code_puppy/tools/browser/vqa_agent.py +1 -3
  17. code_puppy/tui/app.py +48 -77
  18. code_puppy/tui/screens/settings.py +2 -2
  19. {code_puppy-0.0.173.dist-info → code_puppy-0.0.174.dist-info}/METADATA +2 -2
  20. {code_puppy-0.0.173.dist-info → code_puppy-0.0.174.dist-info}/RECORD +24 -29
  21. code_puppy/agents/agent_orchestrator.json +0 -26
  22. code_puppy/agents/runtime_manager.py +0 -272
  23. code_puppy/command_line/meta_command_handler.py +0 -153
  24. code_puppy/message_history_processor.py +0 -408
  25. code_puppy/state_management.py +0 -58
  26. {code_puppy-0.0.173.data → code_puppy-0.0.174.data}/data/code_puppy/models.json +0 -0
  27. {code_puppy-0.0.173.dist-info → code_puppy-0.0.174.dist-info}/WHEEL +0 -0
  28. {code_puppy-0.0.173.dist-info → code_puppy-0.0.174.dist-info}/entry_points.txt +0 -0
  29. {code_puppy-0.0.173.dist-info → code_puppy-0.0.174.dist-info}/licenses/LICENSE +0 -0
@@ -1,10 +1,16 @@
1
1
  """Base agent configuration class for defining agent properties."""
2
+ import math
3
+
4
+ import mcp
5
+ import signal
6
+
7
+ import asyncio
2
8
 
3
9
  import json
4
- import queue
5
10
  import uuid
6
11
  from abc import ABC, abstractmethod
7
- from typing import Any, Dict, List, Optional, Set, Tuple
12
+ from pydantic_ai import UsageLimitExceeded
13
+ from typing import Any, Dict, List, Optional, Set, Tuple, Union
8
14
 
9
15
  import pydantic
10
16
  from pydantic_ai.messages import (
@@ -17,6 +23,27 @@ from pydantic_ai.messages import (
17
23
  ToolReturnPart,
18
24
  )
19
25
 
26
+ from pydantic_ai.settings import ModelSettings
27
+ from pydantic_ai.models.openai import OpenAIModelSettings
28
+ from pydantic_ai import Agent as PydanticAgent
29
+
30
+ # Consolidated relative imports
31
+ from code_puppy.config import (
32
+ get_agent_pinned_model,
33
+ get_compaction_strategy,
34
+ get_compaction_threshold,
35
+ get_message_limit,
36
+ get_global_model_name,
37
+ get_protected_token_count,
38
+ get_value,
39
+ load_mcp_server_configs,
40
+ )
41
+ from code_puppy.messaging import emit_info, emit_error, emit_warning, emit_system_message
42
+ from code_puppy.model_factory import ModelFactory
43
+ from code_puppy.summarization_agent import run_summarization_sync
44
+ from code_puppy.mcp_ import ServerConfig, get_mcp_manager
45
+ from code_puppy.tools.common import console
46
+
20
47
 
21
48
  class BaseAgent(ABC):
22
49
  """Base class for all agent configurations."""
@@ -25,6 +52,11 @@ class BaseAgent(ABC):
25
52
  self.id = str(uuid.uuid4())
26
53
  self._message_history: List[Any] = []
27
54
  self._compacted_message_hashes: Set[str] = set()
55
+ # Agent construction cache
56
+ self._code_generation_agent = None
57
+ self._last_model_name: Optional[str] = None
58
+ # Puppy rules loaded lazily
59
+ self._puppy_rules: Optional[str] = None
28
60
 
29
61
  @property
30
62
  @abstractmethod
@@ -134,8 +166,10 @@ class BaseAgent(ABC):
134
166
  Returns:
135
167
  Model name to use for this agent, or None to use global default.
136
168
  """
137
- from ..config import get_agent_pinned_model
138
- return get_agent_pinned_model(self.name)
169
+ pinned = get_agent_pinned_model(self.name)
170
+ if pinned == "" or pinned is None:
171
+ return get_global_model_name()
172
+ return pinned
139
173
 
140
174
  # Message history processing methods (moved from state_management.py and message_history_processor.py)
141
175
  def _stringify_part(self, part: Any) -> str:
@@ -226,6 +260,14 @@ class BaseAgent(ABC):
226
260
 
227
261
  return result
228
262
 
263
+ def estimate_token_count(self, text: str) -> int:
264
+ """
265
+ Simple token estimation using len(message) - 4.
266
+ This replaces tiktoken with a much simpler approach.
267
+ """
268
+ return max(1, math.floor((len(text) / 4)))
269
+
270
+
229
271
  def estimate_tokens_for_message(self, message: ModelMessage) -> int:
230
272
  """
231
273
  Estimate the number of tokens in a message using len(message) - 4.
@@ -236,9 +278,9 @@ class BaseAgent(ABC):
236
278
  for part in message.parts:
237
279
  part_str = self.stringify_message_part(part)
238
280
  if part_str:
239
- total_tokens += len(part_str)
281
+ total_tokens += self.estimate_token_count(part_str)
240
282
 
241
- return int(max(1, total_tokens) / 4)
283
+ return max(1, total_tokens)
242
284
 
243
285
  def _is_tool_call_part(self, part: Any) -> bool:
244
286
  if isinstance(part, (ToolCallPart, ToolCallPartDelta)):
@@ -270,15 +312,9 @@ class BaseAgent(ABC):
270
312
  return bool(has_content or has_content_delta)
271
313
 
272
314
  def filter_huge_messages(self, messages: List[ModelMessage]) -> List[ModelMessage]:
273
- if not messages:
274
- return []
275
-
276
- # Never drop the system prompt, even if it is extremely large.
277
- system_message, *rest = messages
278
- filtered_rest = [
279
- m for m in rest if self.estimate_tokens_for_message(m) < 50000
280
- ]
281
- return [system_message] + filtered_rest
315
+ filtered = [m for m in messages if self.estimate_tokens_for_message(m) < 50000]
316
+ pruned = self.prune_interrupted_tool_calls(filtered)
317
+ return pruned
282
318
 
283
319
  def split_messages_for_protected_summarization(
284
320
  self,
@@ -305,7 +341,6 @@ class BaseAgent(ABC):
305
341
  return [], messages
306
342
 
307
343
  # Get the configured protected token count
308
- from ..config import get_protected_token_count
309
344
  protected_tokens_limit = get_protected_token_count()
310
345
 
311
346
  # Calculate tokens for messages from most recent backwards (excluding system message)
@@ -335,7 +370,6 @@ class BaseAgent(ABC):
335
370
  messages_to_summarize = messages[1:protected_start_idx]
336
371
 
337
372
  # Emit info messages
338
- from ..messaging import emit_info
339
373
  emit_info(
340
374
  f"🔒 Protecting {len(protected_messages)} recent messages ({protected_token_count} tokens, limit: {protected_tokens_limit})"
341
375
  )
@@ -388,13 +422,11 @@ class BaseAgent(ABC):
388
422
  )
389
423
 
390
424
  try:
391
- from ..summarization_agent import run_summarization_sync
392
425
  new_messages = run_summarization_sync(
393
426
  instructions, message_history=messages_to_summarize
394
427
  )
395
428
 
396
429
  if not isinstance(new_messages, list):
397
- from ..messaging import emit_warning
398
430
  emit_warning(
399
431
  "Summarization agent returned non-list output; wrapping into message request"
400
432
  )
@@ -409,50 +441,15 @@ class BaseAgent(ABC):
409
441
 
410
442
  return self.prune_interrupted_tool_calls(compacted), messages_to_summarize
411
443
  except Exception as e:
412
- from ..messaging import emit_error
413
444
  emit_error(f"Summarization failed during compaction: {e}")
414
445
  return messages, [] # Return original messages on failure
415
446
 
416
- def summarize_message(self, message: ModelMessage) -> ModelMessage:
417
- try:
418
- # If the message looks like a system/instructions message, skip summarization
419
- instructions = getattr(message, "instructions", None)
420
- if instructions:
421
- return message
422
- # If any part is a tool call, skip summarization
423
- for part in message.parts:
424
- if isinstance(part, ToolCallPart) or getattr(part, "tool_name", None):
425
- return message
426
- # Build prompt from textual content parts
427
- content_bits: List[str] = []
428
- for part in message.parts:
429
- s = self.stringify_message_part(part)
430
- if s:
431
- content_bits.append(s)
432
- if not content_bits:
433
- return message
434
- prompt = "Please summarize the following user message:\n" + "\n".join(
435
- content_bits
436
- )
437
-
438
- from ..summarization_agent import run_summarization_sync
439
- output_text = run_summarization_sync(prompt)
440
- summarized = ModelRequest([TextPart(output_text)])
441
- return summarized
442
- except Exception as e:
443
- from ..messaging import emit_error
444
- emit_error(f"Summarization failed: {e}")
445
- return message
446
-
447
447
  def get_model_context_length(self) -> int:
448
448
  """
449
449
  Get the context length for the currently configured model from models.json
450
450
  """
451
- from ..config import get_model_name
452
- from ..model_factory import ModelFactory
453
-
454
451
  model_configs = ModelFactory.load_config()
455
- model_name = get_model_name()
452
+ model_name = get_global_model_name()
456
453
 
457
454
  # Get context length from model config
458
455
  model_config = model_configs.get(model_name, {})
@@ -480,10 +477,11 @@ class BaseAgent(ABC):
480
477
  tool_call_id = getattr(part, "tool_call_id", None)
481
478
  if not tool_call_id:
482
479
  continue
483
-
484
- if self._is_tool_call_part(part) and not self._is_tool_return_part(part):
480
+ # Heuristic: if it's an explicit ToolCallPart or has a tool_name/args,
481
+ # consider it a call; otherwise it's a return/result.
482
+ if part.part_kind == "tool-call":
485
483
  tool_call_ids.add(tool_call_id)
486
- elif self._is_tool_return_part(part):
484
+ else:
487
485
  tool_return_ids.add(tool_call_id)
488
486
 
489
487
  mismatched: Set[str] = tool_call_ids.symmetric_difference(tool_return_ids)
@@ -504,9 +502,418 @@ class BaseAgent(ABC):
504
502
  continue
505
503
  pruned.append(msg)
506
504
 
507
- if dropped_count:
508
- from ..messaging import emit_warning
509
- emit_warning(
510
- f"Pruned {dropped_count} message(s) with mismatched tool_call_id pairs"
505
+ def message_history_processor(self, messages: List[ModelMessage]) -> List[ModelMessage]:
506
+ # First, prune any interrupted/mismatched tool-call conversations
507
+ total_current_tokens = sum(self.estimate_tokens_for_message(msg) for msg in messages)
508
+
509
+ model_max = self.get_model_context_length()
510
+
511
+ proportion_used = total_current_tokens / model_max
512
+
513
+ # Check if we're in TUI mode and can update the status bar
514
+ from code_puppy.tui_state import get_tui_app_instance, is_tui_mode
515
+
516
+ if is_tui_mode():
517
+ tui_app = get_tui_app_instance()
518
+ if tui_app:
519
+ try:
520
+ # Update the status bar instead of emitting a chat message
521
+ status_bar = tui_app.query_one("StatusBar")
522
+ status_bar.update_token_info(
523
+ total_current_tokens, model_max, proportion_used
524
+ )
525
+ except Exception as e:
526
+ emit_error(e)
527
+ # Fallback to chat message if status bar update fails
528
+ emit_info(
529
+ f"\n[bold white on blue] Tokens in context: {total_current_tokens}, total model capacity: {model_max}, proportion used: {proportion_used:.2f} [/bold white on blue] \n",
530
+ message_group="token_context_status",
531
+ )
532
+ else:
533
+ # Fallback if no TUI app instance
534
+ emit_info(
535
+ f"\n[bold white on blue] Tokens in context: {total_current_tokens}, total model capacity: {model_max}, proportion used: {proportion_used:.2f} [/bold white on blue] \n",
536
+ message_group="token_context_status",
537
+ )
538
+ else:
539
+ # Non-TUI mode - emit to console as before
540
+ emit_info(
541
+ f"\n[bold white on blue] Tokens in context: {total_current_tokens}, total model capacity: {model_max}, proportion used: {proportion_used:.2f} [/bold white on blue] \n"
511
542
  )
512
- return pruned
543
+ # Get the configured compaction threshold
544
+ compaction_threshold = get_compaction_threshold()
545
+
546
+ # Get the configured compaction strategy
547
+ compaction_strategy = get_compaction_strategy()
548
+
549
+ if proportion_used > compaction_threshold:
550
+ if compaction_strategy == "truncation":
551
+ # Use truncation instead of summarization
552
+ protected_tokens = get_protected_token_count()
553
+ result_messages = self.truncation(
554
+ self.filter_huge_messages(messages), protected_tokens
555
+ )
556
+ summarized_messages = [] # No summarization in truncation mode
557
+ else:
558
+ # Default to summarization
559
+ result_messages, summarized_messages = self.summarize_messages(
560
+ self.filter_huge_messages(messages)
561
+ )
562
+
563
+ final_token_count = sum(
564
+ self.estimate_tokens_for_message(msg) for msg in result_messages
565
+ )
566
+ # Update status bar with final token count if in TUI mode
567
+ if is_tui_mode():
568
+ tui_app = get_tui_app_instance()
569
+ if tui_app:
570
+ try:
571
+ status_bar = tui_app.query_one("StatusBar")
572
+ status_bar.update_token_info(
573
+ final_token_count, model_max, final_token_count / model_max
574
+ )
575
+ except Exception:
576
+ emit_info(
577
+ f"Final token count after processing: {final_token_count}",
578
+ message_group="token_context_status",
579
+ )
580
+ else:
581
+ emit_info(
582
+ f"Final token count after processing: {final_token_count}",
583
+ message_group="token_context_status",
584
+ )
585
+ else:
586
+ emit_info(f"Final token count after processing: {final_token_count}")
587
+ self.set_message_history(result_messages)
588
+ for m in summarized_messages:
589
+ self.add_compacted_message_hash(self.hash_message(m))
590
+ return result_messages
591
+ return messages
592
+
593
+ def truncation(self, messages: List[ModelMessage], protected_tokens: int) -> List[ModelMessage]:
594
+ """
595
+ Truncate message history to manage token usage.
596
+
597
+ Args:
598
+ messages: List of messages to truncate
599
+ protected_tokens: Number of tokens to protect
600
+
601
+ Returns:
602
+ Truncated list of messages
603
+ """
604
+ import queue
605
+
606
+ emit_info("Truncating message history to manage token usage")
607
+ result = [messages[0]] # Always keep the first message (system prompt)
608
+ num_tokens = 0
609
+ stack = queue.LifoQueue()
610
+
611
+ # Put messages in reverse order (most recent first) into the stack
612
+ # but break when we exceed protected_tokens
613
+ for idx, msg in enumerate(reversed(messages[1:])): # Skip the first message
614
+ num_tokens += self.estimate_tokens_for_message(msg)
615
+ if num_tokens > protected_tokens:
616
+ break
617
+ stack.put(msg)
618
+
619
+ # Pop messages from stack to get them in chronological order
620
+ while not stack.empty():
621
+ result.append(stack.get())
622
+
623
+ result = self.prune_interrupted_tool_calls(result)
624
+ return result
625
+
626
+ def run_summarization_sync(
627
+ self,
628
+ instructions: str,
629
+ message_history: List[ModelMessage],
630
+ ) -> Union[List[ModelMessage], str]:
631
+ """
632
+ Run summarization synchronously using the configured summarization agent.
633
+ This is exposed as a method so it can be overridden by subclasses if needed.
634
+
635
+ Args:
636
+ instructions: Instructions for the summarization agent
637
+ message_history: List of messages to summarize
638
+
639
+ Returns:
640
+ Summarized messages or text
641
+ """
642
+ return run_summarization_sync(instructions, message_history)
643
+
644
+ # ===== Agent wiring formerly in code_puppy/agent.py =====
645
+ def load_puppy_rules(self) -> Optional[str]:
646
+ """Load AGENT(S).md if present and cache the contents."""
647
+ if self._puppy_rules is not None:
648
+ return self._puppy_rules
649
+ from pathlib import Path
650
+ possible_paths = ["AGENTS.md", "AGENT.md", "agents.md", "agent.md"]
651
+ for path_str in possible_paths:
652
+ puppy_rules_path = Path(path_str)
653
+ if puppy_rules_path.exists():
654
+ with open(puppy_rules_path, "r") as f:
655
+ self._puppy_rules = f.read()
656
+ break
657
+ return self._puppy_rules
658
+
659
+ def load_mcp_servers(self, extra_headers: Optional[Dict[str, str]] = None):
660
+ """Load MCP servers through the manager and return pydantic-ai compatible servers."""
661
+
662
+
663
+ mcp_disabled = get_value("disable_mcp_servers")
664
+ if mcp_disabled and str(mcp_disabled).lower() in ("1", "true", "yes", "on"):
665
+ emit_system_message("[dim]MCP servers disabled via config[/dim]")
666
+ return []
667
+
668
+ manager = get_mcp_manager()
669
+ configs = load_mcp_server_configs()
670
+ if not configs:
671
+ existing_servers = manager.list_servers()
672
+ if not existing_servers:
673
+ emit_system_message("[dim]No MCP servers configured[/dim]")
674
+ return []
675
+ else:
676
+ for name, conf in configs.items():
677
+ try:
678
+ server_config = ServerConfig(
679
+ id=conf.get("id", f"{name}_{hash(name)}"),
680
+ name=name,
681
+ type=conf.get("type", "sse"),
682
+ enabled=conf.get("enabled", True),
683
+ config=conf,
684
+ )
685
+ existing = manager.get_server_by_name(name)
686
+ if not existing:
687
+ manager.register_server(server_config)
688
+ emit_system_message(f"[dim]Registered MCP server: {name}[/dim]")
689
+ else:
690
+ if existing.config != server_config.config:
691
+ manager.update_server(existing.id, server_config)
692
+ emit_system_message(f"[dim]Updated MCP server: {name}[/dim]")
693
+ except Exception as e:
694
+ emit_error(f"Failed to register MCP server '{name}': {str(e)}")
695
+ continue
696
+
697
+ servers = manager.get_servers_for_agent()
698
+ if servers:
699
+ emit_system_message(
700
+ f"[green]Successfully loaded {len(servers)} MCP server(s)[/green]"
701
+ )
702
+ else:
703
+ emit_system_message(
704
+ "[yellow]No MCP servers available (check if servers are enabled)[/yellow]"
705
+ )
706
+ return servers
707
+
708
+ def reload_mcp_servers(self):
709
+ """Reload MCP servers and return updated servers."""
710
+ self.load_mcp_servers()
711
+ manager = get_mcp_manager()
712
+ return manager.get_servers_for_agent()
713
+
714
+ def reload_code_generation_agent(self, message_group: Optional[str] = None):
715
+ """Force-reload the pydantic-ai Agent based on current config and model."""
716
+ from code_puppy.tools import register_tools_for_agent
717
+ if message_group is None:
718
+ message_group = str(uuid.uuid4())
719
+
720
+ model_name = self.get_model_name()
721
+
722
+ emit_info(
723
+ f"[bold cyan]Loading Model: {model_name}[/bold cyan]",
724
+ message_group=message_group,
725
+ )
726
+ models_config = ModelFactory.load_config()
727
+ model = ModelFactory.get_model(model_name, models_config)
728
+
729
+ emit_info(
730
+ f"[bold magenta]Loading Agent: {self.name}[/bold magenta]",
731
+ message_group=message_group,
732
+ )
733
+
734
+ instructions = self.get_system_prompt()
735
+ puppy_rules = self.load_puppy_rules()
736
+ if puppy_rules:
737
+ instructions += f"\n{puppy_rules}"
738
+
739
+ mcp_servers = self.load_mcp_servers()
740
+
741
+ model_settings_dict: Dict[str, Any] = {"seed": 42}
742
+ output_tokens = max(
743
+ 2048,
744
+ min(int(0.05 * self.get_model_context_length()) - 1024, 16384),
745
+ )
746
+ console.print(f"Max output tokens per message: {output_tokens}")
747
+ model_settings_dict["max_tokens"] = output_tokens
748
+
749
+ model_settings: ModelSettings = ModelSettings(**model_settings_dict)
750
+ if "gpt-5" in model_name:
751
+ model_settings_dict["openai_reasoning_effort"] = "off"
752
+ model_settings_dict["extra_body"] = {"verbosity": "low"}
753
+ model_settings = OpenAIModelSettings(**model_settings_dict)
754
+
755
+ p_agent = PydanticAgent(
756
+ model=model,
757
+ instructions=instructions,
758
+ output_type=str,
759
+ retries=3,
760
+ mcp_servers=mcp_servers,
761
+ history_processors=[self.message_history_accumulator],
762
+ model_settings=model_settings,
763
+ )
764
+
765
+ agent_tools = self.get_available_tools()
766
+ register_tools_for_agent(p_agent, agent_tools)
767
+
768
+ self._code_generation_agent = p_agent
769
+ self._last_model_name = model_name
770
+ # expose for run_with_mcp
771
+ self.pydantic_agent = p_agent
772
+ return self._code_generation_agent
773
+
774
+
775
+ def message_history_accumulator(self, messages: List[Any]):
776
+ _message_history = self.get_message_history()
777
+ message_history_hashes = set([self.hash_message(m) for m in _message_history])
778
+ for msg in messages:
779
+ if (
780
+ self.hash_message(msg) not in message_history_hashes
781
+ and self.hash_message(msg) not in self.get_compacted_message_hashes()
782
+ ):
783
+ _message_history.append(msg)
784
+
785
+ # Apply message history trimming using the main processor
786
+ # This ensures we maintain global state while still managing context limits
787
+ self.message_history_processor(_message_history)
788
+ return self.get_message_history()
789
+
790
+
791
+ async def run_with_mcp(
792
+ self, prompt: str, usage_limits = None, **kwargs
793
+ ) -> Any:
794
+ """
795
+ Run the agent with MCP servers and full cancellation support.
796
+
797
+ This method ensures we're always using the current agent instance
798
+ and handles Ctrl+C interruption properly by creating a cancellable task.
799
+
800
+ Args:
801
+ prompt: The user prompt to process
802
+ usage_limits: Optional usage limits for the agent
803
+ **kwargs: Additional arguments to pass to agent.run (e.g., message_history)
804
+
805
+ Returns:
806
+ The agent's response
807
+
808
+ Raises:
809
+ asyncio.CancelledError: When execution is cancelled by user
810
+ """
811
+ group_id = str(uuid.uuid4())
812
+ pydantic_agent = self.reload_code_generation_agent()
813
+
814
+ async def run_agent_task():
815
+ try:
816
+ result_ = await pydantic_agent.run(prompt, message_history=self.get_message_history(), usage_limits=usage_limits, **kwargs)
817
+ self.set_message_history(
818
+ self.prune_interrupted_tool_calls(self.get_message_history())
819
+ )
820
+ return result_
821
+ except* UsageLimitExceeded as ule:
822
+ emit_info(f"Usage limit exceeded: {str(ule)}", group_id=group_id)
823
+ emit_info(
824
+ "The agent has reached its usage limit. You can ask it to continue by saying 'please continue' or similar.",
825
+ group_id=group_id,
826
+ )
827
+ except* mcp.shared.exceptions.McpError as mcp_error:
828
+ emit_info(f"MCP server error: {str(mcp_error)}", group_id=group_id)
829
+ emit_info(f"{str(mcp_error)}", group_id=group_id)
830
+ emit_info(
831
+ "Try disabling any malfunctioning MCP servers", group_id=group_id
832
+ )
833
+ except* asyncio.exceptions.CancelledError:
834
+ emit_info("Cancelled")
835
+ except* InterruptedError as ie:
836
+ emit_info(f"Interrupted: {str(ie)}")
837
+ except* Exception as other_error:
838
+ # Filter out CancelledError and UsageLimitExceeded from the exception group - let it propagate
839
+ remaining_exceptions = []
840
+
841
+ def collect_non_cancelled_exceptions(exc):
842
+ if isinstance(exc, ExceptionGroup):
843
+ for sub_exc in exc.exceptions:
844
+ collect_non_cancelled_exceptions(sub_exc)
845
+ elif not isinstance(
846
+ exc, (asyncio.CancelledError, UsageLimitExceeded)
847
+ ):
848
+ remaining_exceptions.append(exc)
849
+ emit_info(f"Unexpected error: {str(exc)}", group_id=group_id)
850
+ emit_info(f"{str(exc.args)}", group_id=group_id)
851
+
852
+ collect_non_cancelled_exceptions(other_error)
853
+
854
+ # If there are CancelledError exceptions in the group, re-raise them
855
+ cancelled_exceptions = []
856
+
857
+ def collect_cancelled_exceptions(exc):
858
+ if isinstance(exc, ExceptionGroup):
859
+ for sub_exc in exc.exceptions:
860
+ collect_cancelled_exceptions(sub_exc)
861
+ elif isinstance(exc, asyncio.CancelledError):
862
+ cancelled_exceptions.append(exc)
863
+
864
+ collect_cancelled_exceptions(other_error)
865
+
866
+ if cancelled_exceptions:
867
+ # Re-raise the first CancelledError to propagate cancellation
868
+ raise cancelled_exceptions[0]
869
+
870
+ # Create the task FIRST
871
+ agent_task = asyncio.create_task(run_agent_task())
872
+
873
+ # Import shell process killer
874
+ from code_puppy.tools.command_runner import kill_all_running_shell_processes
875
+
876
+ # Ensure the interrupt handler only acts once per task
877
+ def keyboard_interrupt_handler(sig, frame):
878
+ """Signal handler for Ctrl+C - replicating exact original logic"""
879
+
880
+ # First, nuke any running shell processes triggered by tools
881
+ try:
882
+ killed = kill_all_running_shell_processes()
883
+ if killed:
884
+ emit_info(f"Cancelled {killed} running shell process(es).")
885
+ else:
886
+ # Only cancel the agent task if no shell processes were killed
887
+ if not agent_task.done():
888
+ agent_task.cancel()
889
+ except Exception as e:
890
+ emit_info(f"Shell kill error: {e}")
891
+ # If shell kill failed, still try to cancel the agent task
892
+ if not agent_task.done():
893
+ agent_task.cancel()
894
+ # Don't call the original handler
895
+ # This prevents the application from exiting
896
+
897
+ try:
898
+ # Save original handler and set our custom one AFTER task is created
899
+ original_handler = signal.signal(signal.SIGINT, keyboard_interrupt_handler)
900
+
901
+ # Wait for the task to complete or be cancelled
902
+ result = await agent_task
903
+ return result
904
+ except asyncio.CancelledError:
905
+ # Task was cancelled by our handler
906
+ raise
907
+ except KeyboardInterrupt:
908
+ # Handle direct keyboard interrupt during await
909
+ if not agent_task.done():
910
+ agent_task.cancel()
911
+ try:
912
+ await agent_task
913
+ except asyncio.CancelledError:
914
+ pass
915
+ raise asyncio.CancelledError()
916
+ finally:
917
+ # Restore original signal handler
918
+ if original_handler:
919
+ signal.signal(signal.SIGINT, original_handler)