code-puppy 0.0.123__py3-none-any.whl → 0.0.125__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.
code_puppy/config.py CHANGED
@@ -9,6 +9,7 @@ MCP_SERVERS_FILE = os.path.join(CONFIG_DIR, "mcp_servers.json")
9
9
  COMMAND_HISTORY_FILE = os.path.join(CONFIG_DIR, "command_history.txt")
10
10
  MODELS_FILE = os.path.join(CONFIG_DIR, "models.json")
11
11
  EXTRA_MODELS_FILE = os.path.join(CONFIG_DIR, "extra_models.json")
12
+ AGENTS_DIR = os.path.join(CONFIG_DIR, "agents")
12
13
 
13
14
  DEFAULT_SECTION = "puppy"
14
15
  REQUIRED_KEYS = ["puppy_name", "owner_name"]
@@ -68,13 +69,33 @@ def get_owner_name():
68
69
  # using get_protected_token_count() and get_summarization_threshold()
69
70
 
70
71
 
72
+ def get_model_context_length() -> int:
73
+ """
74
+ Get the context length for the currently configured model from models.json
75
+ """
76
+ try:
77
+ from code_puppy.model_factory import ModelFactory
78
+
79
+ model_configs = ModelFactory.load_config()
80
+ model_name = get_model_name()
81
+
82
+ # Get context length from model config
83
+ model_config = model_configs.get(model_name, {})
84
+ context_length = model_config.get("context_length", 128000) # Default value
85
+
86
+ return int(context_length)
87
+ except Exception:
88
+ # Fallback to default context length if anything goes wrong
89
+ return 128000
90
+
91
+
71
92
  # --- CONFIG SETTER STARTS HERE ---
72
93
  def get_config_keys():
73
94
  """
74
95
  Returns the list of all config keys currently in puppy.cfg,
75
- plus certain preset expected keys (e.g. "yolo_mode", "model").
96
+ plus certain preset expected keys (e.g. "yolo_mode", "model", "compaction_strategy").
76
97
  """
77
- default_keys = ["yolo_mode", "model"]
98
+ default_keys = ["yolo_mode", "model", "compaction_strategy"]
78
99
  config = configparser.ConfigParser()
79
100
  config.read(CONFIG_FILE)
80
101
  keys = set(config[DEFAULT_SECTION].keys()) if DEFAULT_SECTION in config else set()
@@ -283,6 +304,17 @@ def normalize_command_history():
283
304
  direct_console.print(f"[bold red]{error_msg}[/bold red]")
284
305
 
285
306
 
307
+ def get_user_agents_directory() -> str:
308
+ """Get the user's agents directory path.
309
+
310
+ Returns:
311
+ Path to the user's Code Puppy agents directory.
312
+ """
313
+ # Ensure the agents directory exists
314
+ os.makedirs(AGENTS_DIR, exist_ok=True)
315
+ return AGENTS_DIR
316
+
317
+
286
318
  def initialize_command_history_file():
287
319
  """Create the command history file if it doesn't exist.
288
320
  Handles migration from the old history file location for backward compatibility.
@@ -354,30 +386,56 @@ def get_protected_token_count():
354
386
  This is the number of tokens in recent messages that won't be summarized.
355
387
  Defaults to 50000 if unset or misconfigured.
356
388
  Configurable by 'protected_token_count' key.
389
+ Enforces that protected tokens don't exceed 75% of model context length.
357
390
  """
358
391
  val = get_value("protected_token_count")
359
392
  try:
360
- return max(1000, int(val)) if val else 50000 # Minimum 1000 tokens
393
+ # Get the model context length to enforce the 75% limit
394
+ model_context_length = get_model_context_length()
395
+ max_protected_tokens = int(model_context_length * 0.75)
396
+
397
+ # Parse the configured value
398
+ configured_value = int(val) if val else 50000
399
+
400
+ # Apply constraints: minimum 1000, maximum 75% of context length
401
+ return max(1000, min(configured_value, max_protected_tokens))
361
402
  except (ValueError, TypeError):
362
- return 50000
403
+ # If parsing fails, return a reasonable default that respects the 75% limit
404
+ model_context_length = get_model_context_length()
405
+ max_protected_tokens = int(model_context_length * 0.75)
406
+ return min(50000, max_protected_tokens)
363
407
 
364
408
 
365
- def get_summarization_threshold():
409
+ def get_compaction_threshold():
366
410
  """
367
- Returns the user-configured summarization threshold as a float between 0.0 and 1.0.
368
- This is the proportion of model context that triggers summarization.
411
+ Returns the user-configured compaction threshold as a float between 0.0 and 1.0.
412
+ This is the proportion of model context that triggers compaction.
369
413
  Defaults to 0.85 (85%) if unset or misconfigured.
370
- Configurable by 'summarization_threshold' key.
414
+ Configurable by 'compaction_threshold' key.
371
415
  """
372
- val = get_value("summarization_threshold")
416
+ val = get_value("compaction_threshold")
373
417
  try:
374
418
  threshold = float(val) if val else 0.85
375
419
  # Clamp between reasonable bounds
376
- return max(0.1, min(0.95, threshold))
420
+ return max(0.8, min(0.95, threshold))
377
421
  except (ValueError, TypeError):
378
422
  return 0.85
379
423
 
380
424
 
425
+ def get_compaction_strategy() -> str:
426
+ """
427
+ Returns the user-configured compaction strategy.
428
+ Options are 'summarization' or 'truncation'.
429
+ Defaults to 'summarization' if not set or misconfigured.
430
+ Configurable by 'compaction_strategy' key.
431
+ """
432
+ val = get_value("compaction_strategy")
433
+ if val and val.lower() in ["summarization", "truncation"]:
434
+ return val.lower()
435
+ # Default to summarization
436
+ return "summarization"
437
+
438
+
381
439
  def save_command_to_history(command: str):
382
440
  """Save a command to the history file with an ISO format timestamp.
383
441
 
code_puppy/main.py CHANGED
@@ -321,12 +321,14 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
321
321
  initial_command, usage_limits=get_custom_usage_limits()
322
322
  )
323
323
  finally:
324
- set_message_history(prune_interrupted_tool_calls(get_message_history()))
324
+ set_message_history(
325
+ prune_interrupted_tool_calls(get_message_history())
326
+ )
325
327
 
326
328
  agent_response = response.output
327
329
 
328
330
  emit_system_message(
329
- f"\n[bold purple]AGENT RESPONSE: [/bold purple]\n{agent_response.output_message}"
331
+ f"\n[bold purple]AGENT RESPONSE: [/bold purple]\n{agent_response}"
330
332
  )
331
333
  new_msgs = response.all_messages()
332
334
  message_history_accumulator(new_msgs)
@@ -371,8 +373,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
371
373
 
372
374
  while True:
373
375
  from code_puppy.messaging import emit_info
376
+ from code_puppy.agents.agent_manager import get_current_agent_config
377
+
378
+ # Get the custom prompt from the current agent, or use default
379
+ current_agent = get_current_agent_config()
380
+ user_prompt = current_agent.get_user_prompt() or "Enter your coding task:"
374
381
 
375
- emit_info("[bold blue]Enter your coding task:[/bold blue]")
382
+ emit_info(f"[bold blue]{user_prompt}[/bold blue]")
376
383
 
377
384
  try:
378
385
  # Use prompt_toolkit for enhanced input with path completion
@@ -466,7 +473,9 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
466
473
  usage_limits=get_custom_usage_limits(),
467
474
  )
468
475
  finally:
469
- set_message_history(prune_interrupted_tool_calls(get_message_history()))
476
+ set_message_history(
477
+ prune_interrupted_tool_calls(get_message_history())
478
+ )
470
479
 
471
480
  # Create the task
472
481
  agent_task = asyncio.create_task(run_agent_task())
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import queue
2
3
  from typing import Any, List, Set, Tuple
3
4
 
4
5
  import pydantic
@@ -7,7 +8,8 @@ from pydantic_ai.messages import ModelMessage, ModelRequest, TextPart, ToolCallP
7
8
  from code_puppy.config import (
8
9
  get_model_name,
9
10
  get_protected_token_count,
10
- get_summarization_threshold,
11
+ get_compaction_threshold,
12
+ get_compaction_strategy,
11
13
  )
12
14
  from code_puppy.messaging import emit_error, emit_info, emit_warning
13
15
  from code_puppy.model_factory import ModelFactory
@@ -87,6 +89,12 @@ def estimate_tokens_for_message(message: ModelMessage) -> int:
87
89
  return max(1, total_tokens)
88
90
 
89
91
 
92
+ def filter_huge_messages(messages: List[ModelMessage]) -> List[ModelMessage]:
93
+ filtered = [m for m in messages if estimate_tokens_for_message(m) < 50000]
94
+ pruned = prune_interrupted_tool_calls(filtered)
95
+ return pruned
96
+
97
+
90
98
  def split_messages_for_protected_summarization(
91
99
  messages: List[ModelMessage],
92
100
  ) -> Tuple[List[ModelMessage], List[ModelMessage]]:
@@ -306,7 +314,8 @@ def message_history_processor(messages: List[ModelMessage]) -> List[ModelMessage
306
314
  status_bar.update_token_info(
307
315
  total_current_tokens, model_max, proportion_used
308
316
  )
309
- except Exception:
317
+ except Exception as e:
318
+ emit_error(e)
310
319
  # Fallback to chat message if status bar update fails
311
320
  emit_info(
312
321
  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",
@@ -323,12 +332,26 @@ def message_history_processor(messages: List[ModelMessage]) -> List[ModelMessage
323
332
  emit_info(
324
333
  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"
325
334
  )
335
+ # Get the configured compaction threshold
336
+ compaction_threshold = get_compaction_threshold()
337
+
338
+ # Get the configured compaction strategy
339
+ compaction_strategy = get_compaction_strategy()
340
+
341
+ if proportion_used > compaction_threshold:
342
+ if compaction_strategy == "truncation":
343
+ # Use truncation instead of summarization
344
+ protected_tokens = get_protected_token_count()
345
+ result_messages = truncation(
346
+ filter_huge_messages(messages), protected_tokens
347
+ )
348
+ summarized_messages = [] # No summarization in truncation mode
349
+ else:
350
+ # Default to summarization
351
+ result_messages, summarized_messages = summarize_messages(
352
+ filter_huge_messages(messages)
353
+ )
326
354
 
327
- # Get the configured summarization threshold
328
- summarization_threshold = get_summarization_threshold()
329
-
330
- if proportion_used > summarization_threshold:
331
- result_messages, summarized_messages = summarize_messages(messages)
332
355
  final_token_count = sum(
333
356
  estimate_tokens_for_message(msg) for msg in result_messages
334
357
  )
@@ -360,6 +383,30 @@ def message_history_processor(messages: List[ModelMessage]) -> List[ModelMessage
360
383
  return messages
361
384
 
362
385
 
386
+ def truncation(
387
+ messages: List[ModelMessage], protected_tokens: int
388
+ ) -> List[ModelMessage]:
389
+ emit_info("Truncating message history to manage token usage")
390
+ result = [messages[0]] # Always keep the first message (system prompt)
391
+ num_tokens = 0
392
+ stack = queue.LifoQueue()
393
+
394
+ # Put messages in reverse order (most recent first) into the stack
395
+ # but break when we exceed protected_tokens
396
+ for idx, msg in enumerate(reversed(messages[1:])): # Skip the first message
397
+ num_tokens += estimate_tokens_for_message(msg)
398
+ if num_tokens > protected_tokens:
399
+ break
400
+ stack.put(msg)
401
+
402
+ # Pop messages from stack to get them in chronological order
403
+ while not stack.empty():
404
+ result.append(stack.get())
405
+
406
+ result = prune_interrupted_tool_calls(result)
407
+ return result
408
+
409
+
363
410
  def message_history_accumulator(messages: List[Any]):
364
411
  _message_history = get_message_history()
365
412
  message_history_hashes = set([hash_message(m) for m in _message_history])
@@ -1,10 +1,63 @@
1
- from code_puppy.tools.command_runner import register_command_runner_tools
2
- from code_puppy.tools.file_modifications import register_file_modifications_tools
3
- from code_puppy.tools.file_operations import register_file_operations_tools
1
+ from code_puppy.messaging import emit_warning
2
+ from code_puppy.tools.command_runner import (
3
+ register_agent_run_shell_command,
4
+ register_agent_share_your_reasoning,
5
+ )
6
+ from code_puppy.tools.file_modifications import register_edit_file, register_delete_file
7
+ from code_puppy.tools.file_operations import (
8
+ register_list_files,
9
+ register_read_file,
10
+ register_grep,
11
+ )
12
+
13
+
14
+ # Map of tool names to their individual registration functions
15
+ TOOL_REGISTRY = {
16
+ # File Operations
17
+ "list_files": register_list_files,
18
+ "read_file": register_read_file,
19
+ "grep": register_grep,
20
+ # File Modifications
21
+ "edit_file": register_edit_file,
22
+ "delete_file": register_delete_file,
23
+ # Command Runner
24
+ "agent_run_shell_command": register_agent_run_shell_command,
25
+ "agent_share_your_reasoning": register_agent_share_your_reasoning,
26
+ }
27
+
28
+
29
+ def register_tools_for_agent(agent, tool_names: list[str]):
30
+ """Register specific tools for an agent based on tool names.
31
+
32
+ Args:
33
+ agent: The agent to register tools to.
34
+ tool_names: List of tool names to register.
35
+ """
36
+ for tool_name in tool_names:
37
+ if tool_name not in TOOL_REGISTRY:
38
+ # Skip unknown tools with a warning instead of failing
39
+ emit_warning(f"Warning: Unknown tool '{tool_name}' requested, skipping...")
40
+ continue
41
+
42
+ # Register the individual tool
43
+ register_func = TOOL_REGISTRY[tool_name]
44
+ register_func(agent)
4
45
 
5
46
 
6
47
  def register_all_tools(agent):
7
- """Register all available tools to the provided agent."""
8
- register_file_operations_tools(agent)
9
- register_file_modifications_tools(agent)
10
- register_command_runner_tools(agent)
48
+ """Register all available tools to the provided agent.
49
+
50
+ Args:
51
+ agent: The agent to register tools to.
52
+ """
53
+ all_tools = list(TOOL_REGISTRY.keys())
54
+ register_tools_for_agent(agent, all_tools)
55
+
56
+
57
+ def get_available_tool_names() -> list[str]:
58
+ """Get list of all available tool names.
59
+
60
+ Returns:
61
+ List of all tool names that can be registered.
62
+ """
63
+ return list(TOOL_REGISTRY.keys())
@@ -12,6 +12,7 @@ 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
15
16
  from code_puppy.messaging import (
16
17
  emit_divider,
17
18
  emit_error,
@@ -543,7 +544,8 @@ def register_command_runner_tools(agent):
543
544
  This tool can execute arbitrary shell commands. Exercise caution when
544
545
  running untrusted commands, especially those that modify system state.
545
546
  """
546
- return run_shell_command(context, command, cwd, timeout)
547
+ result = run_shell_command(context, command, cwd, timeout)
548
+ on_run_shell_command(result)
547
549
 
548
550
  @agent.tool
549
551
  def agent_share_your_reasoning(
@@ -588,3 +590,100 @@ def register_command_runner_tools(agent):
588
590
  - When encountering unexpected situations
589
591
  """
590
592
  return share_your_reasoning(context, reasoning, next_steps)
593
+
594
+
595
+ def register_agent_run_shell_command(agent):
596
+ """Register only the agent_run_shell_command tool."""
597
+
598
+ @agent.tool(strict=False)
599
+ def agent_run_shell_command(
600
+ context: RunContext, command: str = "", cwd: str = None, timeout: int = 60
601
+ ) -> ShellCommandOutput:
602
+ """Execute a shell command with comprehensive monitoring and safety features.
603
+
604
+ This tool provides robust shell command execution with streaming output,
605
+ timeout handling, user confirmation (when not in yolo mode), and proper
606
+ process lifecycle management. Commands are executed in a controlled
607
+ environment with cross-platform process group handling.
608
+
609
+ Args:
610
+ command: The shell command to execute. Cannot be empty or whitespace-only.
611
+ cwd: Working directory for command execution. If None,
612
+ uses the current working directory. Defaults to None.
613
+ timeout: Inactivity timeout in seconds. If no output is
614
+ produced for this duration, the process will be terminated.
615
+ Defaults to 60 seconds.
616
+
617
+ Returns:
618
+ ShellCommandOutput: A structured response containing:
619
+ - success (bool): True if command executed successfully (exit code 0)
620
+ - command (str | None): The executed command string
621
+ - error (str | None): Error message if execution failed
622
+ - stdout (str | None): Standard output from the command (last 1000 lines)
623
+ - stderr (str | None): Standard error from the command (last 1000 lines)
624
+ - exit_code (int | None): Process exit code
625
+ - execution_time (float | None): Total execution time in seconds
626
+ - timeout (bool | None): True if command was terminated due to timeout
627
+ - user_interrupted (bool | None): True if user killed the process
628
+
629
+ Examples:
630
+ >>> # Basic command execution
631
+ >>> result = agent_run_shell_command(ctx, "ls -la")
632
+ >>> print(result.stdout)
633
+
634
+ >>> # Command with working directory
635
+ >>> result = agent_run_shell_command(ctx, "npm test", "/path/to/project")
636
+ >>> if result.success:
637
+ ... print("Tests passed!")
638
+
639
+ >>> # Command with custom timeout
640
+ >>> result = agent_run_shell_command(ctx, "long_running_command", timeout=300)
641
+ >>> if result.timeout:
642
+ ... print("Command timed out")
643
+
644
+ Warning:
645
+ This tool can execute arbitrary shell commands. Exercise caution when
646
+ running untrusted commands, especially those that modify system state.
647
+ """
648
+ return run_shell_command(context, command, cwd, timeout)
649
+
650
+
651
+ def register_agent_share_your_reasoning(agent):
652
+ """Register only the agent_share_your_reasoning tool."""
653
+
654
+ @agent.tool(strict=False)
655
+ def agent_share_your_reasoning(
656
+ context: RunContext, reasoning: str = "", next_steps: str | None = None
657
+ ) -> ReasoningOutput:
658
+ """Share the agent's current reasoning and planned next steps with the user.
659
+
660
+ This tool provides transparency into the agent's decision-making process
661
+ by displaying the current reasoning and upcoming actions in a formatted,
662
+ user-friendly manner. It's essential for building trust and understanding
663
+ between the agent and user.
664
+
665
+ Args:
666
+ reasoning: The agent's current thought process, analysis, or
667
+ reasoning for the current situation. This should be clear,
668
+ comprehensive, and explain the 'why' behind decisions.
669
+ next_steps: Planned upcoming actions or steps
670
+ the agent intends to take. Can be None if no specific next steps
671
+ are determined. Defaults to None.
672
+
673
+ Returns:
674
+ ReasoningOutput: A simple response object containing:
675
+ - success (bool): Always True, indicating the reasoning was shared
676
+
677
+ Examples:
678
+ >>> reasoning = "I need to analyze the codebase structure first"
679
+ >>> next_steps = "First, I'll list the directory contents, then read key files"
680
+ >>> result = agent_share_your_reasoning(ctx, reasoning, next_steps)
681
+
682
+ Best Practice:
683
+ Use this tool frequently to maintain transparency. Call it:
684
+ - Before starting complex operations
685
+ - When changing strategy or approach
686
+ - To explain why certain decisions are being made
687
+ - When encountering unexpected situations
688
+ """
689
+ return share_your_reasoning(context, reasoning, next_steps)