code-puppy 0.0.97__py3-none-any.whl → 0.0.118__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 +2 -5
- code_puppy/__main__.py +10 -0
- code_puppy/agent.py +125 -40
- code_puppy/agent_prompts.py +30 -24
- code_puppy/callbacks.py +152 -0
- code_puppy/command_line/command_handler.py +359 -0
- code_puppy/command_line/load_context_completion.py +59 -0
- code_puppy/command_line/model_picker_completion.py +14 -21
- code_puppy/command_line/motd.py +44 -28
- code_puppy/command_line/prompt_toolkit_completion.py +42 -23
- code_puppy/config.py +266 -26
- code_puppy/http_utils.py +122 -0
- code_puppy/main.py +570 -383
- code_puppy/message_history_processor.py +195 -104
- code_puppy/messaging/__init__.py +46 -0
- code_puppy/messaging/message_queue.py +288 -0
- code_puppy/messaging/queue_console.py +293 -0
- code_puppy/messaging/renderers.py +305 -0
- code_puppy/messaging/spinner/__init__.py +55 -0
- code_puppy/messaging/spinner/console_spinner.py +200 -0
- code_puppy/messaging/spinner/spinner_base.py +66 -0
- code_puppy/messaging/spinner/textual_spinner.py +97 -0
- code_puppy/model_factory.py +73 -105
- code_puppy/plugins/__init__.py +32 -0
- code_puppy/reopenable_async_client.py +225 -0
- code_puppy/state_management.py +60 -21
- code_puppy/summarization_agent.py +56 -35
- code_puppy/token_utils.py +7 -9
- code_puppy/tools/__init__.py +1 -4
- code_puppy/tools/command_runner.py +187 -32
- code_puppy/tools/common.py +44 -35
- code_puppy/tools/file_modifications.py +335 -118
- code_puppy/tools/file_operations.py +368 -95
- code_puppy/tools/token_check.py +27 -11
- code_puppy/tools/tools_content.py +53 -0
- code_puppy/tui/__init__.py +10 -0
- code_puppy/tui/app.py +1050 -0
- code_puppy/tui/components/__init__.py +21 -0
- code_puppy/tui/components/chat_view.py +512 -0
- code_puppy/tui/components/command_history_modal.py +218 -0
- code_puppy/tui/components/copy_button.py +139 -0
- code_puppy/tui/components/custom_widgets.py +58 -0
- code_puppy/tui/components/input_area.py +167 -0
- code_puppy/tui/components/sidebar.py +309 -0
- code_puppy/tui/components/status_bar.py +182 -0
- code_puppy/tui/messages.py +27 -0
- code_puppy/tui/models/__init__.py +8 -0
- code_puppy/tui/models/chat_message.py +25 -0
- code_puppy/tui/models/command_history.py +89 -0
- code_puppy/tui/models/enums.py +24 -0
- code_puppy/tui/screens/__init__.py +13 -0
- code_puppy/tui/screens/help.py +130 -0
- code_puppy/tui/screens/settings.py +256 -0
- code_puppy/tui/screens/tools.py +74 -0
- code_puppy/tui/tests/__init__.py +1 -0
- code_puppy/tui/tests/test_chat_message.py +28 -0
- code_puppy/tui/tests/test_chat_view.py +88 -0
- code_puppy/tui/tests/test_command_history.py +89 -0
- code_puppy/tui/tests/test_copy_button.py +191 -0
- code_puppy/tui/tests/test_custom_widgets.py +27 -0
- code_puppy/tui/tests/test_disclaimer.py +27 -0
- code_puppy/tui/tests/test_enums.py +15 -0
- code_puppy/tui/tests/test_file_browser.py +60 -0
- code_puppy/tui/tests/test_help.py +38 -0
- code_puppy/tui/tests/test_history_file_reader.py +107 -0
- code_puppy/tui/tests/test_input_area.py +33 -0
- code_puppy/tui/tests/test_settings.py +44 -0
- code_puppy/tui/tests/test_sidebar.py +33 -0
- code_puppy/tui/tests/test_sidebar_history.py +153 -0
- code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
- code_puppy/tui/tests/test_status_bar.py +54 -0
- code_puppy/tui/tests/test_timestamped_history.py +52 -0
- code_puppy/tui/tests/test_tools.py +82 -0
- code_puppy/version_checker.py +26 -3
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
- code_puppy-0.0.118.dist-info/RECORD +86 -0
- code_puppy-0.0.97.dist-info/RECORD +0 -32
- {code_puppy-0.0.97.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,41 +1,67 @@
|
|
|
1
|
-
import
|
|
2
|
-
from
|
|
1
|
+
import asyncio
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
from typing import List
|
|
3
4
|
|
|
4
5
|
from pydantic_ai import Agent
|
|
5
6
|
|
|
7
|
+
from code_puppy.config import get_model_name
|
|
6
8
|
from code_puppy.model_factory import ModelFactory
|
|
7
|
-
from code_puppy.tools.common import console
|
|
8
9
|
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
# If not set, uses the default file in the package directory.
|
|
12
|
-
# - MODEL_NAME: The model to use for code generation. Defaults to "gpt-4o".
|
|
13
|
-
# Must match a key in the models.json configuration.
|
|
10
|
+
# Keep a module-level agent reference to avoid rebuilding per call
|
|
11
|
+
_summarization_agent = None
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
# Safe sync runner for async agent.run calls
|
|
14
|
+
# Avoids "event loop is already running" by offloading to a separate thread loop when needed
|
|
15
|
+
_thread_pool: ThreadPoolExecutor | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ensure_thread_pool():
|
|
19
|
+
global _thread_pool
|
|
20
|
+
if _thread_pool is None:
|
|
21
|
+
_thread_pool = ThreadPoolExecutor(
|
|
22
|
+
max_workers=1, thread_name_prefix="summarizer-loop"
|
|
23
|
+
)
|
|
24
|
+
return _thread_pool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _run_agent_async(agent: Agent, prompt: str, message_history: List):
|
|
28
|
+
return await agent.run(prompt, message_history=message_history)
|
|
16
29
|
|
|
17
|
-
|
|
18
|
-
|
|
30
|
+
|
|
31
|
+
def run_summarization_sync(prompt: str, message_history: List) -> List:
|
|
32
|
+
agent = get_summarization_agent()
|
|
33
|
+
try:
|
|
34
|
+
# Try to detect if we're already in an event loop
|
|
35
|
+
asyncio.get_running_loop()
|
|
36
|
+
|
|
37
|
+
# We're in an event loop: offload to a dedicated thread with its own loop
|
|
38
|
+
def _worker(prompt_: str):
|
|
39
|
+
return asyncio.run(
|
|
40
|
+
_run_agent_async(agent, prompt_, message_history=message_history)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
pool = _ensure_thread_pool()
|
|
44
|
+
result = pool.submit(_worker, prompt).result()
|
|
45
|
+
except RuntimeError:
|
|
46
|
+
# No running loop, safe to run directly
|
|
47
|
+
result = asyncio.run(
|
|
48
|
+
_run_agent_async(agent, prompt, message_history=message_history)
|
|
49
|
+
)
|
|
50
|
+
return result.new_messages()
|
|
19
51
|
|
|
20
52
|
|
|
21
53
|
def reload_summarization_agent():
|
|
22
54
|
"""Create a specialized agent for summarizing messages when context limit is reached."""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
models_path = (
|
|
29
|
-
Path(MODELS_JSON_PATH)
|
|
30
|
-
if MODELS_JSON_PATH
|
|
31
|
-
else Path(__file__).parent / "models.json"
|
|
32
|
-
)
|
|
33
|
-
model = ModelFactory.get_model(model_name, ModelFactory.load_config(models_path))
|
|
55
|
+
models_config = ModelFactory.load_config()
|
|
56
|
+
model_name = "gemini-2.5-pro"
|
|
57
|
+
if model_name not in models_config:
|
|
58
|
+
model_name = get_model_name()
|
|
59
|
+
model = ModelFactory.get_model(model_name, models_config)
|
|
34
60
|
|
|
35
61
|
# Specialized instructions for summarization
|
|
36
|
-
instructions = """You are a message summarization expert. Your task is to summarize conversation messages
|
|
37
|
-
while preserving important context and information. The summaries should be concise but capture the essential
|
|
38
|
-
content and intent of the original messages. This is to help manage token usage in a conversation history
|
|
62
|
+
instructions = """You are a message summarization expert. Your task is to summarize conversation messages
|
|
63
|
+
while preserving important context and information. The summaries should be concise but capture the essential
|
|
64
|
+
content and intent of the original messages. This is to help manage token usage in a conversation history
|
|
39
65
|
while maintaining context for the AI to continue the conversation effectively.
|
|
40
66
|
|
|
41
67
|
When summarizing:
|
|
@@ -51,20 +77,15 @@ When summarizing:
|
|
|
51
77
|
output_type=str,
|
|
52
78
|
retries=1, # Fewer retries for summarization
|
|
53
79
|
)
|
|
54
|
-
|
|
55
|
-
_LAST_MODEL_NAME = model_name
|
|
56
|
-
return _summarization_agent
|
|
80
|
+
return agent
|
|
57
81
|
|
|
58
82
|
|
|
59
|
-
def get_summarization_agent(force_reload=
|
|
83
|
+
def get_summarization_agent(force_reload=True):
|
|
60
84
|
"""
|
|
61
85
|
Retrieve the summarization agent with the currently set MODEL_NAME.
|
|
62
86
|
Forces a reload if the model has changed, or if force_reload is passed.
|
|
63
87
|
"""
|
|
64
|
-
global _summarization_agent
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
model_name = get_model_name()
|
|
68
|
-
if _summarization_agent is None or _LAST_MODEL_NAME != model_name or force_reload:
|
|
69
|
-
return reload_summarization_agent()
|
|
88
|
+
global _summarization_agent
|
|
89
|
+
if force_reload or _summarization_agent is None:
|
|
90
|
+
_summarization_agent = reload_summarization_agent()
|
|
70
91
|
return _summarization_agent
|
code_puppy/token_utils.py
CHANGED
|
@@ -4,14 +4,12 @@ import pydantic
|
|
|
4
4
|
from pydantic_ai.messages import ModelMessage
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
def
|
|
7
|
+
def estimate_token_count(text: str) -> int:
|
|
8
8
|
"""
|
|
9
|
-
|
|
10
|
-
This
|
|
9
|
+
Simple token estimation using len(message) - 4.
|
|
10
|
+
This replaces tiktoken with a much simpler approach.
|
|
11
11
|
"""
|
|
12
|
-
|
|
13
|
-
return 0
|
|
14
|
-
return max(1, len(text) // 4)
|
|
12
|
+
return max(1, len(text) - 4)
|
|
15
13
|
|
|
16
14
|
|
|
17
15
|
def stringify_message_part(part) -> str:
|
|
@@ -56,14 +54,14 @@ def stringify_message_part(part) -> str:
|
|
|
56
54
|
|
|
57
55
|
def estimate_tokens_for_message(message: ModelMessage) -> int:
|
|
58
56
|
"""
|
|
59
|
-
Estimate the number of tokens in a message using
|
|
60
|
-
|
|
57
|
+
Estimate the number of tokens in a message using len(message) - 4.
|
|
58
|
+
Simple and fast replacement for tiktoken.
|
|
61
59
|
"""
|
|
62
60
|
total_tokens = 0
|
|
63
61
|
|
|
64
62
|
for part in message.parts:
|
|
65
63
|
part_str = stringify_message_part(part)
|
|
66
64
|
if part_str:
|
|
67
|
-
total_tokens +=
|
|
65
|
+
total_tokens += estimate_token_count(part_str)
|
|
68
66
|
|
|
69
67
|
return max(1, total_tokens)
|
code_puppy/tools/__init__.py
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
from code_puppy.tools.command_runner import
|
|
2
|
-
register_command_runner_tools, kill_all_running_shell_processes
|
|
3
|
-
)
|
|
1
|
+
from code_puppy.tools.command_runner import register_command_runner_tools
|
|
4
2
|
from code_puppy.tools.file_modifications import register_file_modifications_tools
|
|
5
3
|
from code_puppy.tools.file_operations import register_file_operations_tools
|
|
6
4
|
|
|
7
5
|
|
|
8
6
|
def register_all_tools(agent):
|
|
9
7
|
"""Register all available tools to the provided agent."""
|
|
10
|
-
|
|
11
8
|
register_file_operations_tools(agent)
|
|
12
9
|
register_file_modifications_tools(agent)
|
|
13
10
|
register_command_runner_tools(agent)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import signal
|
|
3
3
|
import subprocess
|
|
4
|
+
import sys
|
|
4
5
|
import threading
|
|
5
6
|
import time
|
|
6
7
|
import traceback
|
|
7
|
-
import sys
|
|
8
8
|
from typing import Set
|
|
9
9
|
|
|
10
10
|
from pydantic import BaseModel
|
|
@@ -12,7 +12,15 @@ 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.
|
|
15
|
+
from code_puppy.messaging import (
|
|
16
|
+
emit_divider,
|
|
17
|
+
emit_error,
|
|
18
|
+
emit_info,
|
|
19
|
+
emit_system_message,
|
|
20
|
+
emit_warning,
|
|
21
|
+
)
|
|
22
|
+
from code_puppy.state_management import is_tui_mode
|
|
23
|
+
from code_puppy.tools.common import generate_group_id
|
|
16
24
|
|
|
17
25
|
_AWAITING_USER_INPUT = False
|
|
18
26
|
|
|
@@ -91,7 +99,7 @@ def _kill_process_group(proc: subprocess.Popen) -> None:
|
|
|
91
99
|
except Exception:
|
|
92
100
|
pass
|
|
93
101
|
except Exception as e:
|
|
94
|
-
|
|
102
|
+
emit_error(f"Kill process error: {e}")
|
|
95
103
|
|
|
96
104
|
|
|
97
105
|
def kill_all_running_shell_processes() -> int:
|
|
@@ -114,6 +122,38 @@ def kill_all_running_shell_processes() -> int:
|
|
|
114
122
|
return count
|
|
115
123
|
|
|
116
124
|
|
|
125
|
+
# Function to check if user input is awaited
|
|
126
|
+
def is_awaiting_user_input():
|
|
127
|
+
"""Check if command_runner is waiting for user input."""
|
|
128
|
+
global _AWAITING_USER_INPUT
|
|
129
|
+
return _AWAITING_USER_INPUT
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Function to set user input flag
|
|
133
|
+
def set_awaiting_user_input(awaiting=True):
|
|
134
|
+
"""Set the flag indicating if user input is awaited."""
|
|
135
|
+
global _AWAITING_USER_INPUT
|
|
136
|
+
_AWAITING_USER_INPUT = awaiting
|
|
137
|
+
|
|
138
|
+
# When we're setting this flag, also pause/resume all active spinners
|
|
139
|
+
if awaiting:
|
|
140
|
+
# Pause all active spinners (imported here to avoid circular imports)
|
|
141
|
+
try:
|
|
142
|
+
from code_puppy.messaging.spinner import pause_all_spinners
|
|
143
|
+
|
|
144
|
+
pause_all_spinners()
|
|
145
|
+
except ImportError:
|
|
146
|
+
pass # Spinner functionality not available
|
|
147
|
+
else:
|
|
148
|
+
# Resume all active spinners
|
|
149
|
+
try:
|
|
150
|
+
from code_puppy.messaging.spinner import resume_all_spinners
|
|
151
|
+
|
|
152
|
+
resume_all_spinners()
|
|
153
|
+
except ImportError:
|
|
154
|
+
pass # Spinner functionality not available
|
|
155
|
+
|
|
156
|
+
|
|
117
157
|
class ShellCommandOutput(BaseModel):
|
|
118
158
|
success: bool
|
|
119
159
|
command: str | None
|
|
@@ -127,7 +167,10 @@ class ShellCommandOutput(BaseModel):
|
|
|
127
167
|
|
|
128
168
|
|
|
129
169
|
def run_shell_command_streaming(
|
|
130
|
-
process: subprocess.Popen,
|
|
170
|
+
process: subprocess.Popen,
|
|
171
|
+
timeout: int = 60,
|
|
172
|
+
command: str = "",
|
|
173
|
+
group_id: str = None,
|
|
131
174
|
):
|
|
132
175
|
start_time = time.time()
|
|
133
176
|
last_output_time = [start_time]
|
|
@@ -146,7 +189,7 @@ def run_shell_command_streaming(
|
|
|
146
189
|
if line:
|
|
147
190
|
line = line.rstrip("\n\r")
|
|
148
191
|
stdout_lines.append(line)
|
|
149
|
-
|
|
192
|
+
emit_system_message(line, message_group=group_id)
|
|
150
193
|
last_output_time[0] = time.time()
|
|
151
194
|
except Exception:
|
|
152
195
|
pass
|
|
@@ -157,7 +200,7 @@ def run_shell_command_streaming(
|
|
|
157
200
|
if line:
|
|
158
201
|
line = line.rstrip("\n\r")
|
|
159
202
|
stderr_lines.append(line)
|
|
160
|
-
|
|
203
|
+
emit_system_message(line, message_group=group_id)
|
|
161
204
|
last_output_time[0] = time.time()
|
|
162
205
|
except Exception:
|
|
163
206
|
pass
|
|
@@ -188,19 +231,21 @@ def run_shell_command_streaming(
|
|
|
188
231
|
if stdout_thread and stdout_thread.is_alive():
|
|
189
232
|
stdout_thread.join(timeout=3)
|
|
190
233
|
if stdout_thread.is_alive():
|
|
191
|
-
|
|
192
|
-
f"stdout reader thread failed to terminate after {timeout_type}
|
|
234
|
+
emit_warning(
|
|
235
|
+
f"stdout reader thread failed to terminate after {timeout_type} timeout",
|
|
236
|
+
message_group=group_id,
|
|
193
237
|
)
|
|
194
238
|
|
|
195
239
|
if stderr_thread and stderr_thread.is_alive():
|
|
196
240
|
stderr_thread.join(timeout=3)
|
|
197
241
|
if stderr_thread.is_alive():
|
|
198
|
-
|
|
199
|
-
f"stderr reader thread failed to terminate after {timeout_type}
|
|
242
|
+
emit_warning(
|
|
243
|
+
f"stderr reader thread failed to terminate after {timeout_type} timeout",
|
|
244
|
+
message_group=group_id,
|
|
200
245
|
)
|
|
201
246
|
|
|
202
247
|
except Exception as e:
|
|
203
|
-
|
|
248
|
+
emit_warning(f"Error during process cleanup: {e}", message_group=group_id)
|
|
204
249
|
|
|
205
250
|
execution_time = time.time() - start_time
|
|
206
251
|
return ShellCommandOutput(
|
|
@@ -231,7 +276,7 @@ def run_shell_command_streaming(
|
|
|
231
276
|
error_msg.append(
|
|
232
277
|
"Process killed: inactivity timeout reached", style="bold red"
|
|
233
278
|
)
|
|
234
|
-
|
|
279
|
+
emit_error(error_msg, message_group=group_id)
|
|
235
280
|
return cleanup_process_and_threads("absolute")
|
|
236
281
|
|
|
237
282
|
if current_time - last_output_time[0] > timeout:
|
|
@@ -239,7 +284,7 @@ def run_shell_command_streaming(
|
|
|
239
284
|
error_msg.append(
|
|
240
285
|
"Process killed: inactivity timeout reached", style="bold red"
|
|
241
286
|
)
|
|
242
|
-
|
|
287
|
+
emit_error(error_msg, message_group=group_id)
|
|
243
288
|
return cleanup_process_and_threads("inactivity")
|
|
244
289
|
|
|
245
290
|
time.sleep(0.1)
|
|
@@ -265,10 +310,10 @@ def run_shell_command_streaming(
|
|
|
265
310
|
_unregister_process(process)
|
|
266
311
|
|
|
267
312
|
if exit_code != 0:
|
|
268
|
-
|
|
269
|
-
f"Command failed with exit code {exit_code}",
|
|
313
|
+
emit_error(
|
|
314
|
+
f"Command failed with exit code {exit_code}", message_group=group_id
|
|
270
315
|
)
|
|
271
|
-
|
|
316
|
+
emit_info(f"Took {execution_time:.2f}s", message_group=group_id)
|
|
272
317
|
time.sleep(1)
|
|
273
318
|
return ShellCommandOutput(
|
|
274
319
|
success=False,
|
|
@@ -296,7 +341,7 @@ def run_shell_command_streaming(
|
|
|
296
341
|
return ShellCommandOutput(
|
|
297
342
|
success=False,
|
|
298
343
|
command=command,
|
|
299
|
-
error=f"Error
|
|
344
|
+
error=f"Error during streaming execution: {str(e)}",
|
|
300
345
|
stdout="\n".join(stdout_lines[-1000:]),
|
|
301
346
|
stderr="\n".join(stderr_lines[-1000:]),
|
|
302
347
|
exit_code=-1,
|
|
@@ -308,14 +353,21 @@ def run_shell_command(
|
|
|
308
353
|
context: RunContext, command: str, cwd: str = None, timeout: int = 60
|
|
309
354
|
) -> ShellCommandOutput:
|
|
310
355
|
command_displayed = False
|
|
356
|
+
|
|
357
|
+
# Generate unique group_id for this command execution
|
|
358
|
+
group_id = generate_group_id("shell_command", command)
|
|
359
|
+
|
|
311
360
|
if not command or not command.strip():
|
|
312
|
-
|
|
361
|
+
emit_error("Command cannot be empty", message_group=group_id)
|
|
313
362
|
return ShellCommandOutput(
|
|
314
363
|
**{"success": False, "error": "Command cannot be empty"}
|
|
315
364
|
)
|
|
316
|
-
|
|
317
|
-
|
|
365
|
+
|
|
366
|
+
emit_info(
|
|
367
|
+
f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] 📂 [bold green]$ {command}[/bold green]",
|
|
368
|
+
message_group=group_id,
|
|
318
369
|
)
|
|
370
|
+
|
|
319
371
|
from code_puppy.config import get_yolo_mode
|
|
320
372
|
|
|
321
373
|
yolo_mode = get_yolo_mode()
|
|
@@ -335,7 +387,11 @@ def run_shell_command(
|
|
|
335
387
|
command_displayed = True
|
|
336
388
|
|
|
337
389
|
if cwd:
|
|
338
|
-
|
|
390
|
+
emit_info(f"[dim] Working directory: {cwd} [/dim]", message_group=group_id)
|
|
391
|
+
|
|
392
|
+
# Set the flag to indicate we're awaiting user input
|
|
393
|
+
set_awaiting_user_input(True)
|
|
394
|
+
|
|
339
395
|
time.sleep(0.2)
|
|
340
396
|
sys.stdout.write("Are you sure you want to run this command? (y(es)/n(o))\n")
|
|
341
397
|
sys.stdout.flush()
|
|
@@ -344,9 +400,11 @@ def run_shell_command(
|
|
|
344
400
|
user_input = input()
|
|
345
401
|
confirmed = user_input.strip().lower() in {"yes", "y"}
|
|
346
402
|
except (KeyboardInterrupt, EOFError):
|
|
347
|
-
|
|
403
|
+
emit_warning("\n Cancelled by user")
|
|
348
404
|
confirmed = False
|
|
349
405
|
finally:
|
|
406
|
+
# Clear the flag regardless of the outcome
|
|
407
|
+
set_awaiting_user_input(False)
|
|
350
408
|
if confirmation_lock_acquired:
|
|
351
409
|
_CONFIRMATION_LOCK.release()
|
|
352
410
|
|
|
@@ -357,6 +415,7 @@ def run_shell_command(
|
|
|
357
415
|
return result
|
|
358
416
|
else:
|
|
359
417
|
start_time = time.time()
|
|
418
|
+
|
|
360
419
|
try:
|
|
361
420
|
creationflags = 0
|
|
362
421
|
preexec_fn = None
|
|
@@ -367,6 +426,7 @@ def run_shell_command(
|
|
|
367
426
|
creationflags = 0
|
|
368
427
|
else:
|
|
369
428
|
preexec_fn = os.setsid if hasattr(os, "setsid") else None
|
|
429
|
+
|
|
370
430
|
process = subprocess.Popen(
|
|
371
431
|
command,
|
|
372
432
|
shell=True,
|
|
@@ -382,13 +442,13 @@ def run_shell_command(
|
|
|
382
442
|
_register_process(process)
|
|
383
443
|
try:
|
|
384
444
|
return run_shell_command_streaming(
|
|
385
|
-
process, timeout=timeout, command=command
|
|
445
|
+
process, timeout=timeout, command=command, group_id=group_id
|
|
386
446
|
)
|
|
387
447
|
finally:
|
|
388
448
|
# Ensure unregistration in case streaming returned early or raised
|
|
389
449
|
_unregister_process(process)
|
|
390
450
|
except Exception as e:
|
|
391
|
-
|
|
451
|
+
emit_error(traceback.format_exc(), message_group=group_id)
|
|
392
452
|
if "stdout" not in locals():
|
|
393
453
|
stdout = None
|
|
394
454
|
if "stderr" not in locals():
|
|
@@ -411,25 +471,120 @@ class ReasoningOutput(BaseModel):
|
|
|
411
471
|
def share_your_reasoning(
|
|
412
472
|
context: RunContext, reasoning: str, next_steps: str | None = None
|
|
413
473
|
) -> ReasoningOutput:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
474
|
+
# Generate unique group_id for this reasoning session
|
|
475
|
+
group_id = generate_group_id(
|
|
476
|
+
"agent_reasoning", reasoning[:50]
|
|
477
|
+
) # Use first 50 chars for context
|
|
478
|
+
|
|
479
|
+
if not is_tui_mode():
|
|
480
|
+
emit_divider(message_group=group_id)
|
|
481
|
+
emit_info(
|
|
482
|
+
"\n[bold white on purple] AGENT REASONING [/bold white on purple]",
|
|
483
|
+
message_group=group_id,
|
|
484
|
+
)
|
|
485
|
+
emit_info("[bold cyan]Current reasoning:[/bold cyan]", message_group=group_id)
|
|
486
|
+
emit_system_message(Markdown(reasoning), message_group=group_id)
|
|
417
487
|
if next_steps is not None and next_steps.strip():
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
488
|
+
emit_info(
|
|
489
|
+
"\n[bold cyan]Planned next steps:[/bold cyan]", message_group=group_id
|
|
490
|
+
)
|
|
491
|
+
emit_system_message(Markdown(next_steps), message_group=group_id)
|
|
492
|
+
emit_info("[dim]" + "-" * 60 + "[/dim]\n", message_group=group_id)
|
|
421
493
|
return ReasoningOutput(**{"success": True})
|
|
422
494
|
|
|
423
495
|
|
|
424
496
|
def register_command_runner_tools(agent):
|
|
425
497
|
@agent.tool
|
|
426
498
|
def agent_run_shell_command(
|
|
427
|
-
context: RunContext, command: str, cwd: str = None, timeout: int = 60
|
|
499
|
+
context: RunContext, command: str = "", cwd: str = None, timeout: int = 60
|
|
428
500
|
) -> ShellCommandOutput:
|
|
501
|
+
"""Execute a shell command with comprehensive monitoring and safety features.
|
|
502
|
+
|
|
503
|
+
This tool provides robust shell command execution with streaming output,
|
|
504
|
+
timeout handling, user confirmation (when not in yolo mode), and proper
|
|
505
|
+
process lifecycle management. Commands are executed in a controlled
|
|
506
|
+
environment with cross-platform process group handling.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
510
|
+
command (str): The shell command to execute. Cannot be empty or whitespace-only.
|
|
511
|
+
cwd (str, optional): Working directory for command execution. If None,
|
|
512
|
+
uses the current working directory. Defaults to None.
|
|
513
|
+
timeout (int, optional): Inactivity timeout in seconds. If no output is
|
|
514
|
+
produced for this duration, the process will be terminated.
|
|
515
|
+
Defaults to 60 seconds.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
ShellCommandOutput: A structured response containing:
|
|
519
|
+
- success (bool): True if command executed successfully (exit code 0)
|
|
520
|
+
- command (str | None): The executed command string
|
|
521
|
+
- error (str | None): Error message if execution failed
|
|
522
|
+
- stdout (str | None): Standard output from the command (last 1000 lines)
|
|
523
|
+
- stderr (str | None): Standard error from the command (last 1000 lines)
|
|
524
|
+
- exit_code (int | None): Process exit code
|
|
525
|
+
- execution_time (float | None): Total execution time in seconds
|
|
526
|
+
- timeout (bool | None): True if command was terminated due to timeout
|
|
527
|
+
- user_interrupted (bool | None): True if user killed the process
|
|
528
|
+
|
|
529
|
+
Note:
|
|
530
|
+
- In interactive mode (not yolo), user confirmation is required before execution
|
|
531
|
+
- Commands have an absolute timeout of 270 seconds regardless of activity
|
|
532
|
+
- Process groups are properly managed for clean termination
|
|
533
|
+
- Output is streamed in real-time and displayed to the user
|
|
534
|
+
- Large output is truncated to the last 1000 lines for memory efficiency
|
|
535
|
+
|
|
536
|
+
Examples:
|
|
537
|
+
>>> result = agent_run_shell_command(ctx, "ls -la", cwd="/tmp", timeout=30)
|
|
538
|
+
>>> if result.success:
|
|
539
|
+
... print(f"Command completed in {result.execution_time:.2f}s")
|
|
540
|
+
... print(result.stdout)
|
|
541
|
+
|
|
542
|
+
Warning:
|
|
543
|
+
This tool can execute arbitrary shell commands. Exercise caution when
|
|
544
|
+
running untrusted commands, especially those that modify system state.
|
|
545
|
+
"""
|
|
429
546
|
return run_shell_command(context, command, cwd, timeout)
|
|
430
547
|
|
|
431
548
|
@agent.tool
|
|
432
549
|
def agent_share_your_reasoning(
|
|
433
|
-
context: RunContext, reasoning: str, next_steps: str | None = None
|
|
550
|
+
context: RunContext, reasoning: str = "", next_steps: str | None = None
|
|
434
551
|
) -> ReasoningOutput:
|
|
552
|
+
"""Share the agent's current reasoning and planned next steps with the user.
|
|
553
|
+
|
|
554
|
+
This tool provides transparency into the agent's decision-making process
|
|
555
|
+
by displaying the current reasoning and upcoming actions in a formatted,
|
|
556
|
+
user-friendly manner. It's essential for building trust and understanding
|
|
557
|
+
between the agent and user.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
561
|
+
reasoning (str): The agent's current thought process, analysis, or
|
|
562
|
+
reasoning for the current situation. This should be clear,
|
|
563
|
+
comprehensive, and explain the 'why' behind decisions.
|
|
564
|
+
next_steps (str | None, optional): Planned upcoming actions or steps
|
|
565
|
+
the agent intends to take. Can be None if no specific next steps
|
|
566
|
+
are determined. Defaults to None.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
ReasoningOutput: A simple response object containing:
|
|
570
|
+
- success (bool): Always True, indicating the reasoning was shared
|
|
571
|
+
|
|
572
|
+
Note:
|
|
573
|
+
- Reasoning is displayed with Markdown formatting for better readability
|
|
574
|
+
- Next steps are only shown if provided and non-empty
|
|
575
|
+
- Output is visually separated with dividers in TUI mode
|
|
576
|
+
- This tool should be called before major actions to explain intent
|
|
577
|
+
|
|
578
|
+
Examples:
|
|
579
|
+
>>> reasoning = "I need to analyze the codebase structure before making changes"
|
|
580
|
+
>>> next_steps = "First, I'll list the directory contents, then read key files"
|
|
581
|
+
>>> result = agent_share_your_reasoning(ctx, reasoning, next_steps)
|
|
582
|
+
|
|
583
|
+
Best Practice:
|
|
584
|
+
Use this tool frequently to maintain transparency. Call it:
|
|
585
|
+
- Before starting complex operations
|
|
586
|
+
- When changing strategy or approach
|
|
587
|
+
- To explain why certain decisions are being made
|
|
588
|
+
- When encountering unexpected situations
|
|
589
|
+
"""
|
|
435
590
|
return share_your_reasoning(context, reasoning, next_steps)
|
code_puppy/tools/common.py
CHANGED
|
@@ -1,43 +1,27 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import fnmatch
|
|
3
|
-
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
4
6
|
from typing import Optional, Tuple
|
|
7
|
+
|
|
5
8
|
from rapidfuzz.distance import JaroWinkler
|
|
6
9
|
from rich.console import Console
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
|
|
12
|
-
console = Console(no_color=NO_COLOR)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def get_model_context_length() -> int:
|
|
16
|
-
"""
|
|
17
|
-
Get the context length for the currently configured model from models.json
|
|
18
|
-
"""
|
|
19
|
-
# Import locally to avoid circular imports
|
|
20
|
-
from code_puppy.model_factory import ModelFactory
|
|
21
|
-
from code_puppy.config import get_model_name
|
|
22
|
-
import os
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
|
|
25
|
-
# Load model configuration
|
|
26
|
-
models_path = os.environ.get("MODELS_JSON_PATH")
|
|
27
|
-
if not models_path:
|
|
28
|
-
models_path = Path(__file__).parent.parent / "models.json"
|
|
29
|
-
else:
|
|
30
|
-
models_path = Path(models_path)
|
|
31
|
-
|
|
32
|
-
model_configs = ModelFactory.load_config(str(models_path))
|
|
33
|
-
model_name = get_model_name()
|
|
34
|
-
|
|
35
|
-
# Get context length from model config
|
|
36
|
-
model_config = model_configs.get(model_name, {})
|
|
37
|
-
context_length = model_config.get("context_length", 128000) # Default value
|
|
11
|
+
# Import our queue-based console system
|
|
12
|
+
try:
|
|
13
|
+
from code_puppy.messaging import get_queue_console
|
|
38
14
|
|
|
39
|
-
#
|
|
40
|
-
|
|
15
|
+
# Use queue console by default, but allow fallback
|
|
16
|
+
NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
|
|
17
|
+
_rich_console = Console(no_color=NO_COLOR)
|
|
18
|
+
console = get_queue_console()
|
|
19
|
+
# Set the fallback console for compatibility
|
|
20
|
+
console.fallback_console = _rich_console
|
|
21
|
+
except ImportError:
|
|
22
|
+
# Fallback to regular Rich console if messaging system not available
|
|
23
|
+
NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
|
|
24
|
+
console = Console(no_color=NO_COLOR)
|
|
41
25
|
|
|
42
26
|
|
|
43
27
|
# -------------------
|
|
@@ -77,7 +61,7 @@ IGNORE_PATTERNS = [
|
|
|
77
61
|
"**/.parcel-cache/**",
|
|
78
62
|
"**/.vite/**",
|
|
79
63
|
"**/storybook-static/**",
|
|
80
|
-
"**/*.tsbuildinfo
|
|
64
|
+
"**/*.tsbuildinfo/**",
|
|
81
65
|
# Python
|
|
82
66
|
"**/__pycache__/**",
|
|
83
67
|
"**/__pycache__",
|
|
@@ -104,6 +88,7 @@ IGNORE_PATTERNS = [
|
|
|
104
88
|
"**/*.egg-info/**",
|
|
105
89
|
"**/dist/**",
|
|
106
90
|
"**/wheels/**",
|
|
91
|
+
"**/pytest-reports/**",
|
|
107
92
|
# Java (Maven, Gradle, SBT)
|
|
108
93
|
"**/target/**",
|
|
109
94
|
"**/target",
|
|
@@ -384,3 +369,27 @@ def _find_best_window(
|
|
|
384
369
|
console.log(f"Best window: {best_window}")
|
|
385
370
|
console.log(f"Best score: {best_score}")
|
|
386
371
|
return best_span, best_score
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def generate_group_id(tool_name: str, extra_context: str = "") -> str:
|
|
375
|
+
"""Generate a unique group_id for tool output grouping.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
tool_name: Name of the tool (e.g., 'list_files', 'edit_file')
|
|
379
|
+
extra_context: Optional extra context to make group_id more unique
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
A string in format: tool_name_hash
|
|
383
|
+
"""
|
|
384
|
+
# Create a unique identifier using timestamp, context, and a random component
|
|
385
|
+
import random
|
|
386
|
+
|
|
387
|
+
timestamp = str(int(time.time() * 1000000)) # microseconds for more uniqueness
|
|
388
|
+
random_component = random.randint(1000, 9999) # Add randomness
|
|
389
|
+
context_string = f"{tool_name}_{timestamp}_{random_component}_{extra_context}"
|
|
390
|
+
|
|
391
|
+
# Generate a short hash
|
|
392
|
+
hash_obj = hashlib.md5(context_string.encode())
|
|
393
|
+
short_hash = hash_obj.hexdigest()[:8]
|
|
394
|
+
|
|
395
|
+
return f"{tool_name}_{short_hash}"
|