code-puppy 0.0.287__py3-none-any.whl → 0.0.323__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/__init__.py +3 -1
- code_puppy/agents/agent_code_puppy.py +5 -4
- code_puppy/agents/agent_creator_agent.py +22 -18
- code_puppy/agents/agent_manager.py +2 -2
- code_puppy/agents/base_agent.py +496 -102
- code_puppy/callbacks.py +8 -0
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/cli_runner.py +795 -0
- code_puppy/command_line/add_model_menu.py +19 -16
- code_puppy/command_line/attachments.py +10 -5
- code_puppy/command_line/autosave_menu.py +269 -41
- code_puppy/command_line/colors_menu.py +515 -0
- code_puppy/command_line/command_handler.py +10 -24
- code_puppy/command_line/config_commands.py +106 -25
- code_puppy/command_line/core_commands.py +32 -20
- code_puppy/command_line/mcp/add_command.py +3 -16
- code_puppy/command_line/mcp/base.py +0 -3
- code_puppy/command_line/mcp/catalog_server_installer.py +15 -15
- code_puppy/command_line/mcp/custom_server_form.py +66 -5
- code_puppy/command_line/mcp/custom_server_installer.py +17 -17
- code_puppy/command_line/mcp/edit_command.py +15 -22
- code_puppy/command_line/mcp/handler.py +7 -2
- code_puppy/command_line/mcp/help_command.py +2 -2
- code_puppy/command_line/mcp/install_command.py +10 -14
- code_puppy/command_line/mcp/install_menu.py +2 -6
- code_puppy/command_line/mcp/list_command.py +2 -2
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +7 -2
- code_puppy/command_line/mcp/search_command.py +16 -10
- code_puppy/command_line/mcp/start_all_command.py +16 -6
- code_puppy/command_line/mcp/start_command.py +12 -10
- code_puppy/command_line/mcp/status_command.py +4 -5
- code_puppy/command_line/mcp/stop_all_command.py +5 -1
- code_puppy/command_line/mcp/stop_command.py +6 -4
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/wizard_utils.py +20 -16
- code_puppy/command_line/model_settings_menu.py +53 -7
- code_puppy/command_line/motd.py +1 -1
- code_puppy/command_line/pin_command_completion.py +82 -7
- code_puppy/command_line/prompt_toolkit_completion.py +32 -9
- code_puppy/command_line/session_commands.py +11 -4
- code_puppy/config.py +217 -53
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/keymap.py +126 -0
- code_puppy/main.py +5 -745
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/blocking_startup.py +63 -36
- code_puppy/mcp_/captured_stdio_server.py +1 -1
- code_puppy/mcp_/config_wizard.py +4 -4
- code_puppy/mcp_/dashboard.py +15 -6
- code_puppy/mcp_/managed_server.py +25 -5
- code_puppy/mcp_/manager.py +65 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +6 -6
- code_puppy/messaging/__init__.py +184 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +3 -3
- code_puppy/messaging/messages.py +470 -0
- code_puppy/messaging/renderers.py +43 -141
- code_puppy/messaging/rich_renderer.py +900 -0
- code_puppy/messaging/spinner/console_spinner.py +39 -2
- code_puppy/model_factory.py +292 -53
- code_puppy/model_utils.py +57 -48
- code_puppy/models.json +19 -5
- code_puppy/plugins/__init__.py +152 -10
- code_puppy/plugins/chatgpt_oauth/config.py +20 -12
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +30 -13
- code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
- code_puppy/plugins/claude_code_oauth/config.py +15 -11
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
- code_puppy/plugins/claude_code_oauth/utils.py +6 -1
- code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
- code_puppy/plugins/oauth_puppy_html.py +3 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -134
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +77 -3
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/session_storage.py +2 -1
- code_puppy/status_display.py +7 -5
- code_puppy/terminal_utils.py +126 -0
- code_puppy/tools/agent_tools.py +131 -70
- code_puppy/tools/browser/browser_control.py +10 -14
- code_puppy/tools/browser/browser_interactions.py +20 -28
- code_puppy/tools/browser/browser_locators.py +27 -29
- code_puppy/tools/browser/browser_navigation.py +9 -9
- code_puppy/tools/browser/browser_screenshot.py +12 -14
- code_puppy/tools/browser/browser_scripts.py +17 -29
- code_puppy/tools/browser/browser_workflows.py +24 -25
- code_puppy/tools/browser/camoufox_manager.py +22 -26
- code_puppy/tools/command_runner.py +410 -88
- code_puppy/tools/common.py +51 -38
- code_puppy/tools/file_modifications.py +98 -24
- code_puppy/tools/file_operations.py +113 -202
- code_puppy/version_checker.py +28 -13
- {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/METADATA +3 -8
- code_puppy-0.0.323.dist-info/RECORD +168 -0
- code_puppy/tui_state.py +0 -55
- code_puppy-0.0.287.dist-info/RECORD +0 -153
- {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Monkey patches for pydantic-ai.
|
|
2
|
+
|
|
3
|
+
This module contains all monkey patches needed to customize pydantic-ai behavior.
|
|
4
|
+
These patches MUST be applied before any other pydantic-ai imports to work correctly.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from code_puppy.pydantic_patches import apply_all_patches
|
|
8
|
+
apply_all_patches()
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_code_puppy_version() -> str:
|
|
15
|
+
"""Get the current code-puppy version."""
|
|
16
|
+
try:
|
|
17
|
+
return importlib.metadata.version("code-puppy")
|
|
18
|
+
except Exception:
|
|
19
|
+
return "0.0.0-dev"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def patch_user_agent() -> None:
|
|
23
|
+
"""Patch pydantic-ai's User-Agent to use Code-Puppy's version.
|
|
24
|
+
|
|
25
|
+
pydantic-ai sets its own User-Agent ('pydantic-ai/x.x.x') via a @cache-decorated
|
|
26
|
+
function. We replace it with a dynamic function that returns:
|
|
27
|
+
- 'KimiCLI/0.63' for Kimi models
|
|
28
|
+
- 'Code-Puppy/{version}' for all other models
|
|
29
|
+
|
|
30
|
+
This MUST be called before any pydantic-ai models are created.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
import pydantic_ai.models as pydantic_models
|
|
34
|
+
|
|
35
|
+
version = _get_code_puppy_version()
|
|
36
|
+
|
|
37
|
+
# Clear cache if already called
|
|
38
|
+
if hasattr(pydantic_models.get_user_agent, "cache_clear"):
|
|
39
|
+
pydantic_models.get_user_agent.cache_clear()
|
|
40
|
+
|
|
41
|
+
def _get_dynamic_user_agent() -> str:
|
|
42
|
+
"""Return User-Agent based on current model selection."""
|
|
43
|
+
try:
|
|
44
|
+
from code_puppy.config import get_global_model_name
|
|
45
|
+
|
|
46
|
+
model_name = get_global_model_name()
|
|
47
|
+
if model_name and "kimi" in model_name.lower():
|
|
48
|
+
return "KimiCLI/0.63"
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
return f"Code-Puppy/{version}"
|
|
52
|
+
|
|
53
|
+
pydantic_models.get_user_agent = _get_dynamic_user_agent
|
|
54
|
+
except Exception:
|
|
55
|
+
pass # Don't crash on patch failure
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def patch_message_history_cleaning() -> None:
|
|
59
|
+
"""Disable overly strict message history cleaning in pydantic-ai."""
|
|
60
|
+
try:
|
|
61
|
+
from pydantic_ai import _agent_graph
|
|
62
|
+
|
|
63
|
+
_agent_graph._clean_message_history = lambda messages: messages
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def patch_process_message_history() -> None:
|
|
69
|
+
"""Patch _process_message_history to skip strict ModelRequest validation.
|
|
70
|
+
|
|
71
|
+
Pydantic AI added a validation that history must end with ModelRequest,
|
|
72
|
+
but this breaks valid conversation flows. We patch it to skip that validation.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
from pydantic_ai import _agent_graph
|
|
76
|
+
|
|
77
|
+
async def _patched_process_message_history(messages, processors, run_context):
|
|
78
|
+
"""Patched version that doesn't enforce ModelRequest at end."""
|
|
79
|
+
from pydantic_ai._agent_graph import (
|
|
80
|
+
_HistoryProcessorAsync,
|
|
81
|
+
_HistoryProcessorSync,
|
|
82
|
+
_HistoryProcessorSyncWithCtx,
|
|
83
|
+
cast,
|
|
84
|
+
exceptions,
|
|
85
|
+
is_async_callable,
|
|
86
|
+
is_takes_ctx,
|
|
87
|
+
run_in_executor,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
for processor in processors:
|
|
91
|
+
takes_ctx = is_takes_ctx(processor)
|
|
92
|
+
|
|
93
|
+
if is_async_callable(processor):
|
|
94
|
+
if takes_ctx:
|
|
95
|
+
messages = await processor(run_context, messages)
|
|
96
|
+
else:
|
|
97
|
+
async_processor = cast(_HistoryProcessorAsync, processor)
|
|
98
|
+
messages = await async_processor(messages)
|
|
99
|
+
else:
|
|
100
|
+
if takes_ctx:
|
|
101
|
+
sync_processor_with_ctx = cast(
|
|
102
|
+
_HistoryProcessorSyncWithCtx, processor
|
|
103
|
+
)
|
|
104
|
+
messages = await run_in_executor(
|
|
105
|
+
sync_processor_with_ctx, run_context, messages
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
sync_processor = cast(_HistoryProcessorSync, processor)
|
|
109
|
+
messages = await run_in_executor(sync_processor, messages)
|
|
110
|
+
|
|
111
|
+
if len(messages) == 0:
|
|
112
|
+
raise exceptions.UserError("Processed history cannot be empty.")
|
|
113
|
+
|
|
114
|
+
# NOTE: We intentionally skip the "must end with ModelRequest" validation
|
|
115
|
+
# that was added in newer Pydantic AI versions.
|
|
116
|
+
|
|
117
|
+
return messages
|
|
118
|
+
|
|
119
|
+
_agent_graph._process_message_history = _patched_process_message_history
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def apply_all_patches() -> None:
|
|
125
|
+
"""Apply all pydantic-ai monkey patches.
|
|
126
|
+
|
|
127
|
+
Call this at the very top of main.py, before any other imports.
|
|
128
|
+
"""
|
|
129
|
+
patch_user_agent()
|
|
130
|
+
patch_message_history_cleaning()
|
|
131
|
+
patch_process_message_history()
|
code_puppy/session_storage.py
CHANGED
|
@@ -146,6 +146,7 @@ async def restore_autosave_interactively(base_dir: Path) -> None:
|
|
|
146
146
|
|
|
147
147
|
# Import locally to avoid pulling the messaging layer into storage modules
|
|
148
148
|
from datetime import datetime
|
|
149
|
+
|
|
149
150
|
from prompt_toolkit.formatted_text import FormattedText
|
|
150
151
|
|
|
151
152
|
from code_puppy.agents.agent_manager import get_current_agent
|
|
@@ -186,7 +187,7 @@ async def restore_autosave_interactively(base_dir: Path) -> None:
|
|
|
186
187
|
start = page * PAGE_SIZE
|
|
187
188
|
end = min(start + PAGE_SIZE, total)
|
|
188
189
|
page_entries = entries[start:end]
|
|
189
|
-
emit_system_message("
|
|
190
|
+
emit_system_message("Autosave Sessions Available:")
|
|
190
191
|
for idx, (name, timestamp, message_count) in enumerate(page_entries, start=1):
|
|
191
192
|
timestamp_display = timestamp or "unknown time"
|
|
192
193
|
message_display = (
|
code_puppy/status_display.py
CHANGED
|
@@ -7,6 +7,8 @@ from rich.panel import Panel
|
|
|
7
7
|
from rich.spinner import Spinner
|
|
8
8
|
from rich.text import Text
|
|
9
9
|
|
|
10
|
+
from code_puppy.messaging import emit_info
|
|
11
|
+
|
|
10
12
|
# Global variable to track current token per second rate
|
|
11
13
|
CURRENT_TOKEN_RATE = 0.0
|
|
12
14
|
|
|
@@ -185,7 +187,7 @@ class StatusDisplay:
|
|
|
185
187
|
async def _update_display(self) -> None:
|
|
186
188
|
"""Update the display continuously while active using Rich Live display"""
|
|
187
189
|
# Add a newline to ensure we're below the blue bar
|
|
188
|
-
|
|
190
|
+
emit_info("")
|
|
189
191
|
|
|
190
192
|
# Create a Live display that will update in-place
|
|
191
193
|
with Live(
|
|
@@ -221,8 +223,8 @@ class StatusDisplay:
|
|
|
221
223
|
# Print final stats
|
|
222
224
|
elapsed = time.time() - self.start_time if self.start_time else 0
|
|
223
225
|
avg_rate = self.token_count / elapsed if elapsed > 0 else 0
|
|
224
|
-
|
|
225
|
-
f"
|
|
226
|
+
emit_info(
|
|
227
|
+
f"Completed: {self.token_count} tokens in {elapsed:.1f}s ({avg_rate:.1f} t/s avg)"
|
|
226
228
|
)
|
|
227
229
|
|
|
228
230
|
# Reset state
|
|
@@ -240,6 +242,6 @@ class StatusDisplay:
|
|
|
240
242
|
# This is for testing purposes
|
|
241
243
|
elapsed = time.time() - self.start_time if self.start_time else 0
|
|
242
244
|
avg_rate = self.token_count / elapsed if elapsed > 0 else 0
|
|
243
|
-
|
|
244
|
-
f"
|
|
245
|
+
emit_info(
|
|
246
|
+
f"Completed: {self.token_count} tokens in {elapsed:.1f}s ({avg_rate:.1f} t/s avg)"
|
|
245
247
|
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Terminal utilities for cross-platform terminal state management.
|
|
2
|
+
|
|
3
|
+
Handles Windows console mode resets and Unix terminal sanity restoration.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def reset_windows_terminal_ansi() -> None:
|
|
12
|
+
"""Reset ANSI formatting on Windows stdout/stderr.
|
|
13
|
+
|
|
14
|
+
This is a lightweight reset that just clears ANSI escape sequences.
|
|
15
|
+
Use this for quick resets after output operations.
|
|
16
|
+
"""
|
|
17
|
+
if platform.system() != "Windows":
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
sys.stdout.write("\x1b[0m") # Reset ANSI formatting
|
|
22
|
+
sys.stdout.flush()
|
|
23
|
+
sys.stderr.write("\x1b[0m")
|
|
24
|
+
sys.stderr.flush()
|
|
25
|
+
except Exception:
|
|
26
|
+
pass # Silently ignore errors - best effort reset
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def reset_windows_console_mode() -> None:
|
|
30
|
+
"""Full Windows console mode reset using ctypes.
|
|
31
|
+
|
|
32
|
+
This resets both stdout and stdin console modes to restore proper
|
|
33
|
+
terminal behavior after interrupts (Ctrl+C, Ctrl+D). Without this,
|
|
34
|
+
the terminal can become unresponsive (can't type characters).
|
|
35
|
+
"""
|
|
36
|
+
if platform.system() != "Windows":
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import ctypes
|
|
41
|
+
|
|
42
|
+
kernel32 = ctypes.windll.kernel32
|
|
43
|
+
|
|
44
|
+
# Reset stdout
|
|
45
|
+
STD_OUTPUT_HANDLE = -11
|
|
46
|
+
handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
|
|
47
|
+
|
|
48
|
+
# Enable virtual terminal processing and line input
|
|
49
|
+
mode = ctypes.c_ulong()
|
|
50
|
+
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
|
51
|
+
|
|
52
|
+
# Console mode flags for stdout
|
|
53
|
+
ENABLE_PROCESSED_OUTPUT = 0x0001
|
|
54
|
+
ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002
|
|
55
|
+
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
|
56
|
+
|
|
57
|
+
new_mode = (
|
|
58
|
+
mode.value
|
|
59
|
+
| ENABLE_PROCESSED_OUTPUT
|
|
60
|
+
| ENABLE_WRAP_AT_EOL_OUTPUT
|
|
61
|
+
| ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
62
|
+
)
|
|
63
|
+
kernel32.SetConsoleMode(handle, new_mode)
|
|
64
|
+
|
|
65
|
+
# Reset stdin
|
|
66
|
+
STD_INPUT_HANDLE = -10
|
|
67
|
+
stdin_handle = kernel32.GetStdHandle(STD_INPUT_HANDLE)
|
|
68
|
+
|
|
69
|
+
# Console mode flags for stdin
|
|
70
|
+
ENABLE_LINE_INPUT = 0x0002
|
|
71
|
+
ENABLE_ECHO_INPUT = 0x0004
|
|
72
|
+
ENABLE_PROCESSED_INPUT = 0x0001
|
|
73
|
+
|
|
74
|
+
stdin_mode = ctypes.c_ulong()
|
|
75
|
+
kernel32.GetConsoleMode(stdin_handle, ctypes.byref(stdin_mode))
|
|
76
|
+
|
|
77
|
+
new_stdin_mode = (
|
|
78
|
+
stdin_mode.value
|
|
79
|
+
| ENABLE_LINE_INPUT
|
|
80
|
+
| ENABLE_ECHO_INPUT
|
|
81
|
+
| ENABLE_PROCESSED_INPUT
|
|
82
|
+
)
|
|
83
|
+
kernel32.SetConsoleMode(stdin_handle, new_stdin_mode)
|
|
84
|
+
|
|
85
|
+
except Exception:
|
|
86
|
+
pass # Silently ignore errors - best effort reset
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def reset_windows_terminal_full() -> None:
|
|
90
|
+
"""Perform a full Windows terminal reset (ANSI + console mode).
|
|
91
|
+
|
|
92
|
+
Combines both ANSI reset and console mode reset for complete
|
|
93
|
+
terminal state restoration after interrupts.
|
|
94
|
+
"""
|
|
95
|
+
if platform.system() != "Windows":
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
reset_windows_terminal_ansi()
|
|
99
|
+
reset_windows_console_mode()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def reset_unix_terminal() -> None:
|
|
103
|
+
"""Reset Unix/Linux/macOS terminal to sane state.
|
|
104
|
+
|
|
105
|
+
Uses the `reset` command to restore terminal sanity.
|
|
106
|
+
Silently fails if the command isn't available.
|
|
107
|
+
"""
|
|
108
|
+
if platform.system() == "Windows":
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
subprocess.run(["reset"], check=True, capture_output=True)
|
|
113
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
114
|
+
pass # Silently fail if reset command isn't available
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def reset_terminal() -> None:
|
|
118
|
+
"""Cross-platform terminal reset.
|
|
119
|
+
|
|
120
|
+
Automatically detects the platform and performs the appropriate
|
|
121
|
+
terminal reset operation.
|
|
122
|
+
"""
|
|
123
|
+
if platform.system() == "Windows":
|
|
124
|
+
reset_windows_terminal_full()
|
|
125
|
+
else:
|
|
126
|
+
reset_unix_terminal()
|
code_puppy/tools/agent_tools.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# agent_tools.py
|
|
2
2
|
import asyncio
|
|
3
|
+
import hashlib
|
|
3
4
|
import itertools
|
|
4
5
|
import json
|
|
5
6
|
import pickle
|
|
@@ -17,19 +18,22 @@ from pydantic_ai import Agent, RunContext, UsageLimits
|
|
|
17
18
|
from pydantic_ai.messages import ModelMessage
|
|
18
19
|
|
|
19
20
|
from code_puppy.config import (
|
|
21
|
+
DATA_DIR,
|
|
20
22
|
get_message_limit,
|
|
21
23
|
get_use_dbos,
|
|
22
24
|
)
|
|
23
25
|
from code_puppy.messaging import (
|
|
24
|
-
|
|
26
|
+
SubAgentInvocationMessage,
|
|
27
|
+
SubAgentResponseMessage,
|
|
25
28
|
emit_error,
|
|
26
29
|
emit_info,
|
|
27
|
-
|
|
30
|
+
get_message_bus,
|
|
31
|
+
get_session_context,
|
|
32
|
+
set_session_context,
|
|
28
33
|
)
|
|
29
34
|
from code_puppy.model_factory import ModelFactory, make_model_settings
|
|
30
35
|
from code_puppy.tools.common import generate_group_id
|
|
31
36
|
|
|
32
|
-
_temp_agent_count = 0
|
|
33
37
|
# Set to track active subagent invocation tasks
|
|
34
38
|
_active_subagent_tasks: Set[asyncio.Task] = set()
|
|
35
39
|
|
|
@@ -55,6 +59,16 @@ def _generate_dbos_workflow_id(base_id: str) -> str:
|
|
|
55
59
|
return f"{base_id}-wf-{counter}"
|
|
56
60
|
|
|
57
61
|
|
|
62
|
+
def _generate_session_hash_suffix() -> str:
|
|
63
|
+
"""Generate a short SHA1 hash suffix based on current timestamp for uniqueness.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A 6-character hex string, e.g., "a3f2b1"
|
|
67
|
+
"""
|
|
68
|
+
timestamp = str(datetime.now().timestamp())
|
|
69
|
+
return hashlib.sha1(timestamp.encode()).hexdigest()[:6]
|
|
70
|
+
|
|
71
|
+
|
|
58
72
|
# Regex pattern for kebab-case session IDs
|
|
59
73
|
SESSION_ID_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
60
74
|
SESSION_ID_MAX_LENGTH = 128
|
|
@@ -100,10 +114,10 @@ def _get_subagent_sessions_dir() -> Path:
|
|
|
100
114
|
"""Get the directory for storing subagent session data.
|
|
101
115
|
|
|
102
116
|
Returns:
|
|
103
|
-
Path to
|
|
117
|
+
Path to XDG data directory/subagent_sessions/
|
|
104
118
|
"""
|
|
105
|
-
sessions_dir = Path
|
|
106
|
-
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
sessions_dir = Path(DATA_DIR) / "subagent_sessions"
|
|
120
|
+
sessions_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
107
121
|
return sessions_dir
|
|
108
122
|
|
|
109
123
|
|
|
@@ -194,6 +208,7 @@ class AgentInfo(BaseModel):
|
|
|
194
208
|
|
|
195
209
|
name: str
|
|
196
210
|
display_name: str
|
|
211
|
+
description: str
|
|
197
212
|
|
|
198
213
|
|
|
199
214
|
class ListAgentsOutput(BaseModel):
|
|
@@ -208,6 +223,7 @@ class AgentInvokeOutput(BaseModel):
|
|
|
208
223
|
|
|
209
224
|
response: str | None
|
|
210
225
|
agent_name: str
|
|
226
|
+
session_id: str | None = None
|
|
211
227
|
error: str | None = None
|
|
212
228
|
|
|
213
229
|
|
|
@@ -228,38 +244,50 @@ def register_list_agents(agent):
|
|
|
228
244
|
# Generate a group ID for this tool execution
|
|
229
245
|
group_id = generate_group_id("list_agents")
|
|
230
246
|
|
|
247
|
+
from rich.text import Text
|
|
248
|
+
|
|
249
|
+
from code_puppy.config import get_banner_color
|
|
250
|
+
|
|
251
|
+
list_agents_color = get_banner_color("list_agents")
|
|
231
252
|
emit_info(
|
|
232
|
-
|
|
253
|
+
Text.from_markup(
|
|
254
|
+
f"\n[bold white on {list_agents_color}] LIST AGENTS [/bold white on {list_agents_color}]"
|
|
255
|
+
),
|
|
233
256
|
message_group=group_id,
|
|
234
257
|
)
|
|
235
|
-
emit_divider(message_group=group_id)
|
|
236
258
|
|
|
237
259
|
try:
|
|
238
|
-
from code_puppy.agents import get_available_agents
|
|
260
|
+
from code_puppy.agents import get_agent_descriptions, get_available_agents
|
|
239
261
|
|
|
240
|
-
# Get available agents from the agent manager
|
|
262
|
+
# Get available agents and their descriptions from the agent manager
|
|
241
263
|
agents_dict = get_available_agents()
|
|
264
|
+
descriptions_dict = get_agent_descriptions()
|
|
242
265
|
|
|
243
266
|
# Convert to list of AgentInfo objects
|
|
244
267
|
agents = [
|
|
245
|
-
AgentInfo(
|
|
268
|
+
AgentInfo(
|
|
269
|
+
name=name,
|
|
270
|
+
display_name=display_name,
|
|
271
|
+
description=descriptions_dict.get(name, "No description available"),
|
|
272
|
+
)
|
|
246
273
|
for name, display_name in agents_dict.items()
|
|
247
274
|
]
|
|
248
275
|
|
|
249
|
-
#
|
|
276
|
+
# Accumulate output into a single string and emit once
|
|
277
|
+
# Use Text.from_markup() to pass a Rich object that won't be escaped
|
|
278
|
+
lines = []
|
|
250
279
|
for agent_item in agents:
|
|
251
|
-
|
|
252
|
-
f"- [bold]{agent_item.name}[/bold]: {agent_item.display_name}"
|
|
253
|
-
|
|
280
|
+
lines.append(
|
|
281
|
+
f"- [bold]{agent_item.name}[/bold]: {agent_item.display_name}\n"
|
|
282
|
+
f" [dim]{agent_item.description}[/dim]"
|
|
254
283
|
)
|
|
284
|
+
emit_info(Text.from_markup("\n".join(lines)), message_group=group_id)
|
|
255
285
|
|
|
256
|
-
emit_divider(message_group=group_id)
|
|
257
286
|
return ListAgentsOutput(agents=agents)
|
|
258
287
|
|
|
259
288
|
except Exception as e:
|
|
260
289
|
error_msg = f"Error listing agents: {str(e)}"
|
|
261
290
|
emit_error(error_msg, message_group=group_id)
|
|
262
|
-
emit_divider(message_group=group_id)
|
|
263
291
|
return ListAgentsOutput(agents=[], error=error_msg)
|
|
264
292
|
|
|
265
293
|
return list_agents
|
|
@@ -285,21 +313,25 @@ def register_invoke_agent(agent):
|
|
|
285
313
|
|
|
286
314
|
**Session ID Format:**
|
|
287
315
|
- Must be kebab-case (lowercase letters, numbers, hyphens only)
|
|
288
|
-
- Should be human-readable
|
|
289
|
-
-
|
|
316
|
+
- Should be human-readable: e.g., "implement-oauth", "review-auth"
|
|
317
|
+
- For NEW sessions, a SHA1 hash suffix is automatically appended for uniqueness
|
|
318
|
+
- To CONTINUE a session, use the full session_id (with hash) from the previous invocation
|
|
290
319
|
- If None (default), auto-generates like "agent-name-session-1"
|
|
291
320
|
|
|
292
321
|
**When to use session_id:**
|
|
293
|
-
- **
|
|
294
|
-
|
|
295
|
-
- **
|
|
296
|
-
unique IDs for each invocation
|
|
322
|
+
- **NEW SESSION**: Provide a base name like "review-auth" - we'll append a unique hash
|
|
323
|
+
- **CONTINUE SESSION**: Use the full session_id from output (e.g., "review-auth-a3f2b1")
|
|
324
|
+
- **ONE-OFF TASKS**: Leave as None (auto-generate)
|
|
297
325
|
|
|
298
326
|
**Most common pattern:** Leave session_id as None (auto-generate) unless you
|
|
299
327
|
specifically need conversational memory.
|
|
300
328
|
|
|
301
329
|
Returns:
|
|
302
|
-
AgentInvokeOutput:
|
|
330
|
+
AgentInvokeOutput: Contains:
|
|
331
|
+
- response (str | None): The agent's response to the prompt
|
|
332
|
+
- agent_name (str): Name of the invoked agent
|
|
333
|
+
- session_id (str | None): The full session ID (with hash suffix) - USE THIS to continue the conversation!
|
|
334
|
+
- error (str | None): Error message if invocation failed
|
|
303
335
|
|
|
304
336
|
Examples:
|
|
305
337
|
# COMMON CASE: One-off invocation, no memory needed (auto-generate session)
|
|
@@ -307,46 +339,43 @@ def register_invoke_agent(agent):
|
|
|
307
339
|
"qa-expert",
|
|
308
340
|
"Review this function: def add(a, b): return a + b"
|
|
309
341
|
)
|
|
342
|
+
# result.session_id will be something like "qa-expert-session-a3f2b1"
|
|
310
343
|
|
|
311
|
-
# MULTI-TURN: Start a conversation with
|
|
344
|
+
# MULTI-TURN: Start a NEW conversation with a base session ID
|
|
345
|
+
# A hash suffix is auto-appended: "review-add-function" -> "review-add-function-a3f2b1"
|
|
312
346
|
result1 = invoke_agent(
|
|
313
347
|
"qa-expert",
|
|
314
348
|
"Review this function: def add(a, b): return a + b",
|
|
315
|
-
session_id="review-add-function
|
|
349
|
+
session_id="review-add-function"
|
|
316
350
|
)
|
|
351
|
+
# result1.session_id contains the full ID like "review-add-function-a3f2b1"
|
|
317
352
|
|
|
318
|
-
# Continue the SAME conversation
|
|
353
|
+
# Continue the SAME conversation using session_id from the previous result
|
|
319
354
|
result2 = invoke_agent(
|
|
320
355
|
"qa-expert",
|
|
321
356
|
"Can you suggest edge cases for that function?",
|
|
322
|
-
session_id=
|
|
357
|
+
session_id=result1.session_id # Use the session_id from previous output!
|
|
323
358
|
)
|
|
324
359
|
|
|
325
|
-
# Multiple INDEPENDENT reviews (
|
|
360
|
+
# Multiple INDEPENDENT reviews (each gets unique hash suffix)
|
|
326
361
|
auth_review = invoke_agent(
|
|
327
362
|
"code-reviewer",
|
|
328
363
|
"Review my authentication code",
|
|
329
|
-
session_id="auth-review
|
|
364
|
+
session_id="auth-review" # -> "auth-review-<hash1>"
|
|
330
365
|
)
|
|
366
|
+
# auth_review.session_id contains the full ID to continue this review
|
|
331
367
|
|
|
332
368
|
payment_review = invoke_agent(
|
|
333
369
|
"code-reviewer",
|
|
334
370
|
"Review my payment processing code",
|
|
335
|
-
session_id="payment-review
|
|
371
|
+
session_id="payment-review" # -> "payment-review-<hash2>"
|
|
336
372
|
)
|
|
373
|
+
# payment_review.session_id contains a different full ID
|
|
337
374
|
"""
|
|
338
|
-
global _temp_agent_count
|
|
339
|
-
|
|
340
375
|
from code_puppy.agents.agent_manager import load_agent
|
|
341
376
|
|
|
342
|
-
#
|
|
343
|
-
if session_id is None:
|
|
344
|
-
# Create a new session ID in kebab-case format
|
|
345
|
-
# Example: "qa-expert-session-1", "code-reviewer-session-2"
|
|
346
|
-
_temp_agent_count += 1
|
|
347
|
-
session_id = f"{agent_name}-session-{_temp_agent_count}"
|
|
348
|
-
else:
|
|
349
|
-
# Validate user-provided session_id
|
|
377
|
+
# Validate user-provided session_id if given
|
|
378
|
+
if session_id is not None:
|
|
350
379
|
try:
|
|
351
380
|
_validate_session_id(session_id)
|
|
352
381
|
except ValueError as e:
|
|
@@ -360,28 +389,44 @@ def register_invoke_agent(agent):
|
|
|
360
389
|
# Generate a group ID for this tool execution
|
|
361
390
|
group_id = generate_group_id("invoke_agent", agent_name)
|
|
362
391
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
# Retrieve existing message history from filesystem for this session, if any
|
|
371
|
-
message_history = _load_session_history(session_id)
|
|
372
|
-
is_new_session = len(message_history) == 0
|
|
373
|
-
|
|
374
|
-
if message_history:
|
|
375
|
-
emit_system_message(
|
|
376
|
-
f"Continuing conversation from session {session_id} ({len(message_history)} messages)",
|
|
377
|
-
message_group=group_id,
|
|
378
|
-
)
|
|
392
|
+
# Check if this is an existing session or a new one
|
|
393
|
+
# For user-provided session_id, check if it exists
|
|
394
|
+
# For None, we'll generate a new one below
|
|
395
|
+
if session_id is not None:
|
|
396
|
+
message_history = _load_session_history(session_id)
|
|
397
|
+
is_new_session = len(message_history) == 0
|
|
379
398
|
else:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
399
|
+
message_history = []
|
|
400
|
+
is_new_session = True
|
|
401
|
+
|
|
402
|
+
# Generate or finalize session_id
|
|
403
|
+
if session_id is None:
|
|
404
|
+
# Auto-generate a session ID with hash suffix for uniqueness
|
|
405
|
+
# Example: "qa-expert-session-a3f2b1"
|
|
406
|
+
hash_suffix = _generate_session_hash_suffix()
|
|
407
|
+
session_id = f"{agent_name}-session-{hash_suffix}"
|
|
408
|
+
elif is_new_session:
|
|
409
|
+
# User provided a base name for a NEW session - append hash suffix
|
|
410
|
+
# Example: "review-auth" -> "review-auth-a3f2b1"
|
|
411
|
+
hash_suffix = _generate_session_hash_suffix()
|
|
412
|
+
session_id = f"{session_id}-{hash_suffix}"
|
|
413
|
+
# else: continuing existing session, use session_id as-is
|
|
414
|
+
|
|
415
|
+
# Emit structured invocation message via MessageBus
|
|
416
|
+
bus = get_message_bus()
|
|
417
|
+
bus.emit(
|
|
418
|
+
SubAgentInvocationMessage(
|
|
419
|
+
agent_name=agent_name,
|
|
420
|
+
session_id=session_id,
|
|
421
|
+
prompt=prompt,
|
|
422
|
+
is_new_session=is_new_session,
|
|
423
|
+
message_count=len(message_history),
|
|
383
424
|
)
|
|
384
|
-
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Save current session context and set the new one for this sub-agent
|
|
428
|
+
previous_session_id = get_session_context()
|
|
429
|
+
set_session_context(session_id)
|
|
385
430
|
|
|
386
431
|
try:
|
|
387
432
|
# Load the specified agent config
|
|
@@ -400,6 +445,11 @@ def register_invoke_agent(agent):
|
|
|
400
445
|
# Create a temporary agent instance to avoid interfering with current agent state
|
|
401
446
|
instructions = agent_config.get_system_prompt()
|
|
402
447
|
|
|
448
|
+
# Add AGENTS.md content to subagents
|
|
449
|
+
puppy_rules = agent_config.load_puppy_rules()
|
|
450
|
+
if puppy_rules:
|
|
451
|
+
instructions += f"\n\n{puppy_rules}"
|
|
452
|
+
|
|
403
453
|
# Apply prompt additions (like file permission handling) to temporary agents
|
|
404
454
|
from code_puppy import callbacks
|
|
405
455
|
from code_puppy.model_utils import prepare_prompt_for_model
|
|
@@ -418,7 +468,7 @@ def register_invoke_agent(agent):
|
|
|
418
468
|
instructions = prepared.instructions
|
|
419
469
|
prompt = prepared.user_prompt
|
|
420
470
|
|
|
421
|
-
subagent_name = f"temp-invoke-agent-{
|
|
471
|
+
subagent_name = f"temp-invoke-agent-{session_id}"
|
|
422
472
|
model_settings = make_model_settings(model_name)
|
|
423
473
|
|
|
424
474
|
temp_agent = Agent(
|
|
@@ -490,21 +540,32 @@ def register_invoke_agent(agent):
|
|
|
490
540
|
initial_prompt=prompt if is_new_session else None,
|
|
491
541
|
)
|
|
492
542
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
543
|
+
# Emit structured response message via MessageBus
|
|
544
|
+
bus.emit(
|
|
545
|
+
SubAgentResponseMessage(
|
|
546
|
+
agent_name=agent_name,
|
|
547
|
+
session_id=session_id,
|
|
548
|
+
response=response,
|
|
549
|
+
message_count=len(updated_history),
|
|
550
|
+
)
|
|
497
551
|
)
|
|
498
|
-
emit_divider(message_group=group_id)
|
|
499
552
|
|
|
500
|
-
return AgentInvokeOutput(
|
|
553
|
+
return AgentInvokeOutput(
|
|
554
|
+
response=response, agent_name=agent_name, session_id=session_id
|
|
555
|
+
)
|
|
501
556
|
|
|
502
557
|
except Exception:
|
|
503
558
|
error_msg = f"Error invoking agent '{agent_name}': {traceback.format_exc()}"
|
|
504
559
|
emit_error(error_msg, message_group=group_id)
|
|
505
|
-
emit_divider(message_group=group_id)
|
|
506
560
|
return AgentInvokeOutput(
|
|
507
|
-
response=None,
|
|
561
|
+
response=None,
|
|
562
|
+
agent_name=agent_name,
|
|
563
|
+
session_id=session_id,
|
|
564
|
+
error=error_msg,
|
|
508
565
|
)
|
|
509
566
|
|
|
567
|
+
finally:
|
|
568
|
+
# Restore the previous session context
|
|
569
|
+
set_session_context(previous_session_id)
|
|
570
|
+
|
|
510
571
|
return invoke_agent
|