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
code_puppy/agents/base_agent.py
CHANGED
|
@@ -4,10 +4,23 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import math
|
|
6
6
|
import signal
|
|
7
|
+
import sys
|
|
7
8
|
import threading
|
|
8
9
|
import uuid
|
|
9
10
|
from abc import ABC, abstractmethod
|
|
10
|
-
from
|
|
11
|
+
from collections.abc import AsyncIterable
|
|
12
|
+
from typing import (
|
|
13
|
+
Any,
|
|
14
|
+
Callable,
|
|
15
|
+
Dict,
|
|
16
|
+
List,
|
|
17
|
+
Optional,
|
|
18
|
+
Sequence,
|
|
19
|
+
Set,
|
|
20
|
+
Tuple,
|
|
21
|
+
Type,
|
|
22
|
+
Union,
|
|
23
|
+
)
|
|
11
24
|
|
|
12
25
|
import mcp
|
|
13
26
|
import pydantic
|
|
@@ -18,6 +31,7 @@ from pydantic_ai import (
|
|
|
18
31
|
BinaryContent,
|
|
19
32
|
DocumentUrl,
|
|
20
33
|
ImageUrl,
|
|
34
|
+
PartEndEvent,
|
|
21
35
|
RunContext,
|
|
22
36
|
UsageLimitExceeded,
|
|
23
37
|
UsageLimits,
|
|
@@ -33,6 +47,7 @@ from pydantic_ai.messages import (
|
|
|
33
47
|
ToolReturn,
|
|
34
48
|
ToolReturnPart,
|
|
35
49
|
)
|
|
50
|
+
from rich.text import Text
|
|
36
51
|
|
|
37
52
|
# Consolidated relative imports
|
|
38
53
|
from code_puppy.config import (
|
|
@@ -44,9 +59,10 @@ from code_puppy.config import (
|
|
|
44
59
|
get_protected_token_count,
|
|
45
60
|
get_use_dbos,
|
|
46
61
|
get_value,
|
|
47
|
-
load_mcp_server_configs,
|
|
48
62
|
)
|
|
49
|
-
from code_puppy.
|
|
63
|
+
from code_puppy.error_logging import log_error
|
|
64
|
+
from code_puppy.keymap import cancel_agent_uses_signal, get_cancel_agent_char_code
|
|
65
|
+
from code_puppy.mcp_ import get_mcp_manager
|
|
50
66
|
from code_puppy.messaging import (
|
|
51
67
|
emit_error,
|
|
52
68
|
emit_info,
|
|
@@ -85,6 +101,9 @@ class BaseAgent(ABC):
|
|
|
85
101
|
# Cache for MCP tool definitions (for token estimation)
|
|
86
102
|
# This is populated after the first successful run when MCP tools are retrieved
|
|
87
103
|
self._mcp_tool_definitions_cache: List[Dict[str, Any]] = []
|
|
104
|
+
# Shared console for streaming output - should be set by cli_runner
|
|
105
|
+
# to avoid conflicts between spinner's Live display and response streaming
|
|
106
|
+
self._console: Optional[Any] = None
|
|
88
107
|
|
|
89
108
|
@property
|
|
90
109
|
@abstractmethod
|
|
@@ -362,8 +381,10 @@ class BaseAgent(ABC):
|
|
|
362
381
|
# fixed instructions. For other models, count the full system prompt.
|
|
363
382
|
try:
|
|
364
383
|
from code_puppy.model_utils import (
|
|
365
|
-
|
|
384
|
+
get_chatgpt_codex_instructions,
|
|
366
385
|
get_claude_code_instructions,
|
|
386
|
+
is_chatgpt_codex_model,
|
|
387
|
+
is_claude_code_model,
|
|
367
388
|
)
|
|
368
389
|
|
|
369
390
|
model_name = (
|
|
@@ -374,6 +395,11 @@ class BaseAgent(ABC):
|
|
|
374
395
|
# The full system prompt is already in the message history
|
|
375
396
|
instructions = get_claude_code_instructions()
|
|
376
397
|
total_tokens += self.estimate_token_count(instructions)
|
|
398
|
+
elif is_chatgpt_codex_model(model_name):
|
|
399
|
+
# For ChatGPT Codex models, only count the short fixed instructions
|
|
400
|
+
# The full system prompt is already in the message history
|
|
401
|
+
instructions = get_chatgpt_codex_instructions()
|
|
402
|
+
total_tokens += self.estimate_token_count(instructions)
|
|
377
403
|
else:
|
|
378
404
|
# For other models, count the full system prompt
|
|
379
405
|
system_prompt = self.get_system_prompt()
|
|
@@ -827,30 +853,11 @@ class BaseAgent(ABC):
|
|
|
827
853
|
total_current_tokens = message_tokens + context_overhead
|
|
828
854
|
proportion_used = total_current_tokens / model_max
|
|
829
855
|
|
|
830
|
-
# Check if we're in TUI mode and can update the status bar
|
|
831
|
-
from code_puppy.tui_state import get_tui_app_instance, is_tui_mode
|
|
832
|
-
|
|
833
856
|
context_summary = SpinnerBase.format_context_info(
|
|
834
857
|
total_current_tokens, model_max, proportion_used
|
|
835
858
|
)
|
|
836
859
|
update_spinner_context(context_summary)
|
|
837
860
|
|
|
838
|
-
if is_tui_mode():
|
|
839
|
-
tui_app = get_tui_app_instance()
|
|
840
|
-
if tui_app:
|
|
841
|
-
try:
|
|
842
|
-
# Update the status bar instead of emitting a chat message
|
|
843
|
-
status_bar = tui_app.query_one("StatusBar")
|
|
844
|
-
status_bar.update_token_info(
|
|
845
|
-
total_current_tokens, model_max, proportion_used
|
|
846
|
-
)
|
|
847
|
-
except Exception as e:
|
|
848
|
-
emit_error(e)
|
|
849
|
-
else:
|
|
850
|
-
emit_info(
|
|
851
|
-
f"Final token count after processing: {total_current_tokens}",
|
|
852
|
-
message_group="token_context_status",
|
|
853
|
-
)
|
|
854
861
|
# Get the configured compaction threshold
|
|
855
862
|
compaction_threshold = get_compaction_threshold()
|
|
856
863
|
|
|
@@ -889,30 +896,12 @@ class BaseAgent(ABC):
|
|
|
889
896
|
final_token_count = sum(
|
|
890
897
|
self.estimate_tokens_for_message(msg) for msg in result_messages
|
|
891
898
|
)
|
|
892
|
-
# Update
|
|
899
|
+
# Update spinner with final token count
|
|
893
900
|
final_summary = SpinnerBase.format_context_info(
|
|
894
901
|
final_token_count, model_max, final_token_count / model_max
|
|
895
902
|
)
|
|
896
903
|
update_spinner_context(final_summary)
|
|
897
904
|
|
|
898
|
-
if is_tui_mode():
|
|
899
|
-
tui_app = get_tui_app_instance()
|
|
900
|
-
if tui_app:
|
|
901
|
-
try:
|
|
902
|
-
status_bar = tui_app.query_one("StatusBar")
|
|
903
|
-
status_bar.update_token_info(
|
|
904
|
-
final_token_count, model_max, final_token_count / model_max
|
|
905
|
-
)
|
|
906
|
-
except Exception:
|
|
907
|
-
emit_info(
|
|
908
|
-
f"Final token count after processing: {final_token_count}",
|
|
909
|
-
message_group="token_context_status",
|
|
910
|
-
)
|
|
911
|
-
else:
|
|
912
|
-
emit_info(
|
|
913
|
-
f"Final token count after processing: {final_token_count}",
|
|
914
|
-
message_group="token_context_status",
|
|
915
|
-
)
|
|
916
905
|
self.set_message_history(result_messages)
|
|
917
906
|
for m in summarized_messages:
|
|
918
907
|
self.add_compacted_message_hash(self.hash_message(m))
|
|
@@ -974,60 +963,71 @@ class BaseAgent(ABC):
|
|
|
974
963
|
|
|
975
964
|
# ===== Agent wiring formerly in code_puppy/agent.py =====
|
|
976
965
|
def load_puppy_rules(self) -> Optional[str]:
|
|
977
|
-
"""Load AGENT(S).md
|
|
966
|
+
"""Load AGENT(S).md from both global config and project directory.
|
|
967
|
+
|
|
968
|
+
Checks for AGENTS.md/AGENT.md/agents.md/agent.md in this order:
|
|
969
|
+
1. Global config directory (~/.code_puppy/ or XDG config)
|
|
970
|
+
2. Current working directory (project-specific)
|
|
971
|
+
|
|
972
|
+
If both exist, they are combined with global rules first, then project rules.
|
|
973
|
+
This allows project-specific rules to override or extend global rules.
|
|
974
|
+
"""
|
|
978
975
|
if self._puppy_rules is not None:
|
|
979
976
|
return self._puppy_rules
|
|
980
977
|
from pathlib import Path
|
|
981
978
|
|
|
982
979
|
possible_paths = ["AGENTS.md", "AGENT.md", "agents.md", "agent.md"]
|
|
980
|
+
|
|
981
|
+
# Load global rules from CONFIG_DIR
|
|
982
|
+
global_rules = None
|
|
983
|
+
from code_puppy.config import CONFIG_DIR
|
|
984
|
+
|
|
983
985
|
for path_str in possible_paths:
|
|
984
|
-
|
|
985
|
-
if
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
986
|
+
global_path = Path(CONFIG_DIR) / path_str
|
|
987
|
+
if global_path.exists():
|
|
988
|
+
global_rules = global_path.read_text(encoding="utf-8-sig")
|
|
989
|
+
break
|
|
990
|
+
|
|
991
|
+
# Load project-local rules from current working directory
|
|
992
|
+
project_rules = None
|
|
993
|
+
for path_str in possible_paths:
|
|
994
|
+
project_path = Path(path_str)
|
|
995
|
+
if project_path.exists():
|
|
996
|
+
project_rules = project_path.read_text(encoding="utf-8-sig")
|
|
997
|
+
break
|
|
998
|
+
|
|
999
|
+
# Combine global and project rules
|
|
1000
|
+
# Global rules come first, project rules second (allowing project to override)
|
|
1001
|
+
rules = [r for r in [global_rules, project_rules] if r]
|
|
1002
|
+
self._puppy_rules = "\n\n".join(rules) if rules else None
|
|
989
1003
|
return self._puppy_rules
|
|
990
1004
|
|
|
991
1005
|
def load_mcp_servers(self, extra_headers: Optional[Dict[str, str]] = None):
|
|
992
|
-
"""Load MCP servers through the manager and return pydantic-ai compatible servers.
|
|
1006
|
+
"""Load MCP servers through the manager and return pydantic-ai compatible servers.
|
|
1007
|
+
|
|
1008
|
+
Note: The manager automatically syncs from mcp_servers.json during initialization,
|
|
1009
|
+
so we don't need to sync here. Use reload_mcp_servers() to force a re-sync.
|
|
1010
|
+
"""
|
|
993
1011
|
|
|
994
1012
|
mcp_disabled = get_value("disable_mcp_servers")
|
|
995
1013
|
if mcp_disabled and str(mcp_disabled).lower() in ("1", "true", "yes", "on"):
|
|
996
1014
|
return []
|
|
997
1015
|
|
|
998
1016
|
manager = get_mcp_manager()
|
|
999
|
-
configs = load_mcp_server_configs()
|
|
1000
|
-
if not configs:
|
|
1001
|
-
existing_servers = manager.list_servers()
|
|
1002
|
-
if not existing_servers:
|
|
1003
|
-
return []
|
|
1004
|
-
else:
|
|
1005
|
-
for name, conf in configs.items():
|
|
1006
|
-
try:
|
|
1007
|
-
server_config = ServerConfig(
|
|
1008
|
-
id=conf.get("id", f"{name}_{hash(name)}"),
|
|
1009
|
-
name=name,
|
|
1010
|
-
type=conf.get("type", "sse"),
|
|
1011
|
-
enabled=conf.get("enabled", True),
|
|
1012
|
-
config=conf,
|
|
1013
|
-
)
|
|
1014
|
-
existing = manager.get_server_by_name(name)
|
|
1015
|
-
if not existing:
|
|
1016
|
-
manager.register_server(server_config)
|
|
1017
|
-
else:
|
|
1018
|
-
if existing.config != server_config.config:
|
|
1019
|
-
manager.update_server(existing.id, server_config)
|
|
1020
|
-
except Exception:
|
|
1021
|
-
continue
|
|
1022
|
-
|
|
1023
1017
|
return manager.get_servers_for_agent()
|
|
1024
1018
|
|
|
1025
1019
|
def reload_mcp_servers(self):
|
|
1026
|
-
"""Reload MCP servers and return updated servers.
|
|
1020
|
+
"""Reload MCP servers and return updated servers.
|
|
1021
|
+
|
|
1022
|
+
Forces a re-sync from mcp_servers.json to pick up any configuration changes.
|
|
1023
|
+
"""
|
|
1027
1024
|
# Clear the MCP tool cache when servers are reloaded
|
|
1028
1025
|
self._mcp_tool_definitions_cache = []
|
|
1029
|
-
|
|
1026
|
+
|
|
1027
|
+
# Force re-sync from mcp_servers.json
|
|
1030
1028
|
manager = get_mcp_manager()
|
|
1029
|
+
manager.sync_from_config()
|
|
1030
|
+
|
|
1031
1031
|
return manager.get_servers_for_agent()
|
|
1032
1032
|
|
|
1033
1033
|
def _load_model_with_fallback(
|
|
@@ -1049,8 +1049,8 @@ class BaseAgent(ABC):
|
|
|
1049
1049
|
)
|
|
1050
1050
|
emit_warning(
|
|
1051
1051
|
(
|
|
1052
|
-
f"
|
|
1053
|
-
f"Available models: {available_str}
|
|
1052
|
+
f"Model '{requested_model_name}' not found. "
|
|
1053
|
+
f"Available models: {available_str}"
|
|
1054
1054
|
),
|
|
1055
1055
|
message_group=message_group,
|
|
1056
1056
|
)
|
|
@@ -1070,7 +1070,7 @@ class BaseAgent(ABC):
|
|
|
1070
1070
|
try:
|
|
1071
1071
|
model = ModelFactory.get_model(candidate, models_config)
|
|
1072
1072
|
emit_info(
|
|
1073
|
-
f"
|
|
1073
|
+
f"Using fallback model: {candidate}",
|
|
1074
1074
|
message_group=message_group,
|
|
1075
1075
|
)
|
|
1076
1076
|
return model, candidate
|
|
@@ -1082,7 +1082,7 @@ class BaseAgent(ABC):
|
|
|
1082
1082
|
"a valid model with `config set`."
|
|
1083
1083
|
)
|
|
1084
1084
|
emit_error(
|
|
1085
|
-
|
|
1085
|
+
friendly_message,
|
|
1086
1086
|
message_group=message_group,
|
|
1087
1087
|
)
|
|
1088
1088
|
raise ValueError(friendly_message) from exc
|
|
@@ -1110,13 +1110,7 @@ class BaseAgent(ABC):
|
|
|
1110
1110
|
|
|
1111
1111
|
mcp_servers = self.load_mcp_servers()
|
|
1112
1112
|
|
|
1113
|
-
|
|
1114
|
-
2048,
|
|
1115
|
-
min(int(0.05 * self.get_model_context_length()) - 1024, 16384),
|
|
1116
|
-
)
|
|
1117
|
-
model_settings = make_model_settings(
|
|
1118
|
-
resolved_model_name, max_tokens=output_tokens
|
|
1119
|
-
)
|
|
1113
|
+
model_settings = make_model_settings(resolved_model_name)
|
|
1120
1114
|
|
|
1121
1115
|
# Handle claude-code models: swap instructions (prompt prepending happens in run_with_mcp)
|
|
1122
1116
|
from code_puppy.model_utils import prepare_prompt_for_model
|
|
@@ -1189,7 +1183,9 @@ class BaseAgent(ABC):
|
|
|
1189
1183
|
|
|
1190
1184
|
if len(filtered_mcp_servers) != len(mcp_servers):
|
|
1191
1185
|
emit_info(
|
|
1192
|
-
|
|
1186
|
+
Text.from_markup(
|
|
1187
|
+
f"[dim]Filtered {len(mcp_servers) - len(filtered_mcp_servers)} conflicting MCP tools[/dim]"
|
|
1188
|
+
)
|
|
1193
1189
|
)
|
|
1194
1190
|
|
|
1195
1191
|
self._last_model_name = resolved_model_name
|
|
@@ -1246,6 +1242,74 @@ class BaseAgent(ABC):
|
|
|
1246
1242
|
self._mcp_servers = mcp_servers
|
|
1247
1243
|
return self._code_generation_agent
|
|
1248
1244
|
|
|
1245
|
+
def _create_agent_with_output_type(self, output_type: Type[Any]) -> PydanticAgent:
|
|
1246
|
+
"""Create a temporary agent configured with a custom output_type.
|
|
1247
|
+
|
|
1248
|
+
This is used when structured output is requested via run_with_mcp.
|
|
1249
|
+
The agent is created fresh with the same configuration as the main agent
|
|
1250
|
+
but with the specified output_type instead of str.
|
|
1251
|
+
|
|
1252
|
+
Args:
|
|
1253
|
+
output_type: The Pydantic model or type for structured output.
|
|
1254
|
+
|
|
1255
|
+
Returns:
|
|
1256
|
+
A configured PydanticAgent (or DBOSAgent wrapper) with the custom output_type.
|
|
1257
|
+
"""
|
|
1258
|
+
from code_puppy.model_utils import prepare_prompt_for_model
|
|
1259
|
+
from code_puppy.tools import register_tools_for_agent
|
|
1260
|
+
|
|
1261
|
+
model_name = self.get_model_name()
|
|
1262
|
+
models_config = ModelFactory.load_config()
|
|
1263
|
+
model, resolved_model_name = self._load_model_with_fallback(
|
|
1264
|
+
model_name, models_config, str(uuid.uuid4())
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
instructions = self.get_system_prompt()
|
|
1268
|
+
puppy_rules = self.load_puppy_rules()
|
|
1269
|
+
if puppy_rules:
|
|
1270
|
+
instructions += f"\n{puppy_rules}"
|
|
1271
|
+
|
|
1272
|
+
mcp_servers = getattr(self, "_mcp_servers", []) or []
|
|
1273
|
+
model_settings = make_model_settings(resolved_model_name)
|
|
1274
|
+
|
|
1275
|
+
prepared = prepare_prompt_for_model(
|
|
1276
|
+
model_name, instructions, "", prepend_system_to_user=False
|
|
1277
|
+
)
|
|
1278
|
+
instructions = prepared.instructions
|
|
1279
|
+
|
|
1280
|
+
global _reload_count
|
|
1281
|
+
_reload_count += 1
|
|
1282
|
+
|
|
1283
|
+
if get_use_dbos():
|
|
1284
|
+
temp_agent = PydanticAgent(
|
|
1285
|
+
model=model,
|
|
1286
|
+
instructions=instructions,
|
|
1287
|
+
output_type=output_type,
|
|
1288
|
+
retries=3,
|
|
1289
|
+
toolsets=[],
|
|
1290
|
+
history_processors=[self.message_history_accumulator],
|
|
1291
|
+
model_settings=model_settings,
|
|
1292
|
+
)
|
|
1293
|
+
agent_tools = self.get_available_tools()
|
|
1294
|
+
register_tools_for_agent(temp_agent, agent_tools)
|
|
1295
|
+
dbos_agent = DBOSAgent(
|
|
1296
|
+
temp_agent, name=f"{self.name}-structured-{_reload_count}"
|
|
1297
|
+
)
|
|
1298
|
+
return dbos_agent
|
|
1299
|
+
else:
|
|
1300
|
+
temp_agent = PydanticAgent(
|
|
1301
|
+
model=model,
|
|
1302
|
+
instructions=instructions,
|
|
1303
|
+
output_type=output_type,
|
|
1304
|
+
retries=3,
|
|
1305
|
+
toolsets=mcp_servers,
|
|
1306
|
+
history_processors=[self.message_history_accumulator],
|
|
1307
|
+
model_settings=model_settings,
|
|
1308
|
+
)
|
|
1309
|
+
agent_tools = self.get_available_tools()
|
|
1310
|
+
register_tools_for_agent(temp_agent, agent_tools)
|
|
1311
|
+
return temp_agent
|
|
1312
|
+
|
|
1249
1313
|
# It's okay to decorate it with DBOS.step even if not using DBOS; the decorator is a no-op in that case.
|
|
1250
1314
|
@DBOS.step()
|
|
1251
1315
|
def message_history_accumulator(self, ctx: RunContext, messages: List[Any]):
|
|
@@ -1271,12 +1335,204 @@ class BaseAgent(ABC):
|
|
|
1271
1335
|
self.set_message_history(result_messages_filtered_empty_thinking)
|
|
1272
1336
|
return self.get_message_history()
|
|
1273
1337
|
|
|
1338
|
+
async def _event_stream_handler(
|
|
1339
|
+
self, ctx: RunContext, events: AsyncIterable[Any]
|
|
1340
|
+
) -> None:
|
|
1341
|
+
"""Handle streaming events from the agent run.
|
|
1342
|
+
|
|
1343
|
+
This method processes streaming events and emits TextPart and ThinkingPart
|
|
1344
|
+
content with styled banners as they stream in.
|
|
1345
|
+
|
|
1346
|
+
Args:
|
|
1347
|
+
ctx: The run context.
|
|
1348
|
+
events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
|
|
1349
|
+
"""
|
|
1350
|
+
from pydantic_ai import PartDeltaEvent, PartStartEvent
|
|
1351
|
+
from pydantic_ai.messages import TextPartDelta, ThinkingPartDelta
|
|
1352
|
+
from rich.console import Console
|
|
1353
|
+
from rich.markdown import Markdown
|
|
1354
|
+
from rich.markup import escape
|
|
1355
|
+
|
|
1356
|
+
from code_puppy.messaging.spinner import pause_all_spinners
|
|
1357
|
+
|
|
1358
|
+
# IMPORTANT: Use the shared console (set by cli_runner) to avoid conflicts
|
|
1359
|
+
# with the spinner's Live display. Multiple Console instances with separate
|
|
1360
|
+
# Live displays cause cursor positioning chaos and line duplication.
|
|
1361
|
+
if self._console is not None:
|
|
1362
|
+
console = self._console
|
|
1363
|
+
else:
|
|
1364
|
+
# Fallback if console not set (shouldn't happen in normal use)
|
|
1365
|
+
console = Console()
|
|
1366
|
+
|
|
1367
|
+
# Track which part indices we're currently streaming (for Text/Thinking parts)
|
|
1368
|
+
streaming_parts: set[int] = set()
|
|
1369
|
+
thinking_parts: set[int] = (
|
|
1370
|
+
set()
|
|
1371
|
+
) # Track which parts are thinking (for dim style)
|
|
1372
|
+
text_parts: set[int] = set() # Track which parts are text
|
|
1373
|
+
banner_printed: set[int] = set() # Track if banner was already printed
|
|
1374
|
+
text_buffer: dict[int, list[str]] = {} # Buffer text for final markdown render
|
|
1375
|
+
token_count: dict[int, int] = {} # Track token count per text part
|
|
1376
|
+
did_stream_anything = False # Track if we streamed any content
|
|
1377
|
+
|
|
1378
|
+
def _print_thinking_banner() -> None:
|
|
1379
|
+
"""Print the THINKING banner with spinner pause and line clear."""
|
|
1380
|
+
nonlocal did_stream_anything
|
|
1381
|
+
import sys
|
|
1382
|
+
import time
|
|
1383
|
+
|
|
1384
|
+
from code_puppy.config import get_banner_color
|
|
1385
|
+
|
|
1386
|
+
pause_all_spinners()
|
|
1387
|
+
time.sleep(0.1) # Delay to let spinner fully clear
|
|
1388
|
+
sys.stdout.write("\r\x1b[K") # Clear line
|
|
1389
|
+
sys.stdout.flush()
|
|
1390
|
+
console.print() # Newline before banner
|
|
1391
|
+
# Bold banner with configurable color and lightning bolt
|
|
1392
|
+
thinking_color = get_banner_color("thinking")
|
|
1393
|
+
console.print(
|
|
1394
|
+
Text.from_markup(
|
|
1395
|
+
f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]⚡ "
|
|
1396
|
+
),
|
|
1397
|
+
end="",
|
|
1398
|
+
)
|
|
1399
|
+
sys.stdout.flush()
|
|
1400
|
+
did_stream_anything = True
|
|
1401
|
+
|
|
1402
|
+
def _print_response_banner() -> None:
|
|
1403
|
+
"""Print the AGENT RESPONSE banner with spinner pause and line clear."""
|
|
1404
|
+
nonlocal did_stream_anything
|
|
1405
|
+
import sys
|
|
1406
|
+
import time
|
|
1407
|
+
|
|
1408
|
+
from code_puppy.config import get_banner_color
|
|
1409
|
+
|
|
1410
|
+
pause_all_spinners()
|
|
1411
|
+
time.sleep(0.1) # Delay to let spinner fully clear
|
|
1412
|
+
sys.stdout.write("\r\x1b[K") # Clear line
|
|
1413
|
+
sys.stdout.flush()
|
|
1414
|
+
console.print() # Newline before banner
|
|
1415
|
+
response_color = get_banner_color("agent_response")
|
|
1416
|
+
console.print(
|
|
1417
|
+
Text.from_markup(
|
|
1418
|
+
f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
|
|
1419
|
+
)
|
|
1420
|
+
)
|
|
1421
|
+
sys.stdout.flush()
|
|
1422
|
+
did_stream_anything = True
|
|
1423
|
+
|
|
1424
|
+
async for event in events:
|
|
1425
|
+
# PartStartEvent - register the part but defer banner until content arrives
|
|
1426
|
+
if isinstance(event, PartStartEvent):
|
|
1427
|
+
part = event.part
|
|
1428
|
+
if isinstance(part, ThinkingPart):
|
|
1429
|
+
streaming_parts.add(event.index)
|
|
1430
|
+
thinking_parts.add(event.index)
|
|
1431
|
+
# If there's initial content, print banner + content now
|
|
1432
|
+
if part.content and part.content.strip():
|
|
1433
|
+
_print_thinking_banner()
|
|
1434
|
+
escaped = escape(part.content)
|
|
1435
|
+
console.print(f"[dim]{escaped}[/dim]", end="")
|
|
1436
|
+
banner_printed.add(event.index)
|
|
1437
|
+
elif isinstance(part, TextPart):
|
|
1438
|
+
streaming_parts.add(event.index)
|
|
1439
|
+
text_parts.add(event.index)
|
|
1440
|
+
text_buffer[event.index] = [] # Initialize buffer
|
|
1441
|
+
token_count[event.index] = 0 # Initialize token counter
|
|
1442
|
+
# Buffer initial content if present
|
|
1443
|
+
if part.content and part.content.strip():
|
|
1444
|
+
text_buffer[event.index].append(part.content)
|
|
1445
|
+
token_count[event.index] += 1
|
|
1446
|
+
|
|
1447
|
+
# PartDeltaEvent - stream the content as it arrives
|
|
1448
|
+
elif isinstance(event, PartDeltaEvent):
|
|
1449
|
+
if event.index in streaming_parts:
|
|
1450
|
+
delta = event.delta
|
|
1451
|
+
if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
|
|
1452
|
+
if delta.content_delta:
|
|
1453
|
+
# For text parts, show token counter then render at end
|
|
1454
|
+
if event.index in text_parts:
|
|
1455
|
+
import sys
|
|
1456
|
+
|
|
1457
|
+
# Print banner on first content
|
|
1458
|
+
if event.index not in banner_printed:
|
|
1459
|
+
_print_response_banner()
|
|
1460
|
+
banner_printed.add(event.index)
|
|
1461
|
+
# Accumulate text for final markdown render
|
|
1462
|
+
text_buffer[event.index].append(delta.content_delta)
|
|
1463
|
+
token_count[event.index] += 1
|
|
1464
|
+
# Update token counter in place (single line)
|
|
1465
|
+
count = token_count[event.index]
|
|
1466
|
+
sys.stdout.write(
|
|
1467
|
+
f"\r\x1b[K ⏳ Receiving... {count} tokens"
|
|
1468
|
+
)
|
|
1469
|
+
sys.stdout.flush()
|
|
1470
|
+
else:
|
|
1471
|
+
# For thinking parts, stream immediately (dim)
|
|
1472
|
+
if event.index not in banner_printed:
|
|
1473
|
+
_print_thinking_banner()
|
|
1474
|
+
banner_printed.add(event.index)
|
|
1475
|
+
escaped = escape(delta.content_delta)
|
|
1476
|
+
console.print(f"[dim]{escaped}[/dim]", end="")
|
|
1477
|
+
|
|
1478
|
+
# PartEndEvent - finish the streaming with a newline
|
|
1479
|
+
elif isinstance(event, PartEndEvent):
|
|
1480
|
+
if event.index in streaming_parts:
|
|
1481
|
+
# For text parts, clear counter line and render markdown
|
|
1482
|
+
if event.index in text_parts:
|
|
1483
|
+
import sys
|
|
1484
|
+
|
|
1485
|
+
# Clear the token counter line
|
|
1486
|
+
sys.stdout.write("\r\x1b[K")
|
|
1487
|
+
sys.stdout.flush()
|
|
1488
|
+
# Render the final markdown nicely
|
|
1489
|
+
if event.index in text_buffer:
|
|
1490
|
+
try:
|
|
1491
|
+
final_content = "".join(text_buffer[event.index])
|
|
1492
|
+
if final_content.strip():
|
|
1493
|
+
console.print(Markdown(final_content))
|
|
1494
|
+
except Exception:
|
|
1495
|
+
pass
|
|
1496
|
+
del text_buffer[event.index]
|
|
1497
|
+
# Clean up token count
|
|
1498
|
+
token_count.pop(event.index, None)
|
|
1499
|
+
# For thinking parts, just print newline
|
|
1500
|
+
elif event.index in banner_printed:
|
|
1501
|
+
console.print() # Final newline after streaming
|
|
1502
|
+
# Clean up all tracking sets
|
|
1503
|
+
streaming_parts.discard(event.index)
|
|
1504
|
+
thinking_parts.discard(event.index)
|
|
1505
|
+
text_parts.discard(event.index)
|
|
1506
|
+
banner_printed.discard(event.index)
|
|
1507
|
+
|
|
1508
|
+
# Resume spinner if next part is NOT text/thinking (avoid race condition)
|
|
1509
|
+
# If next part is a tool call or None, it's safe to resume
|
|
1510
|
+
# Note: spinner itself handles blank line before appearing
|
|
1511
|
+
from code_puppy.messaging.spinner import resume_all_spinners
|
|
1512
|
+
|
|
1513
|
+
next_kind = getattr(event, "next_part_kind", None)
|
|
1514
|
+
if next_kind not in ("text", "thinking"):
|
|
1515
|
+
resume_all_spinners()
|
|
1516
|
+
|
|
1517
|
+
# Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
|
|
1518
|
+
|
|
1274
1519
|
def _spawn_ctrl_x_key_listener(
|
|
1275
1520
|
self,
|
|
1276
1521
|
stop_event: threading.Event,
|
|
1277
1522
|
on_escape: Callable[[], None],
|
|
1523
|
+
on_cancel_agent: Optional[Callable[[], None]] = None,
|
|
1278
1524
|
) -> Optional[threading.Thread]:
|
|
1279
|
-
"""Start a
|
|
1525
|
+
"""Start a keyboard listener thread for CLI sessions.
|
|
1526
|
+
|
|
1527
|
+
Listens for Ctrl+X (shell command cancel) and optionally the configured
|
|
1528
|
+
cancel_agent_key (when not using SIGINT/Ctrl+C).
|
|
1529
|
+
|
|
1530
|
+
Args:
|
|
1531
|
+
stop_event: Event to signal the listener to stop.
|
|
1532
|
+
on_escape: Callback for Ctrl+X (shell command cancel).
|
|
1533
|
+
on_cancel_agent: Optional callback for cancel_agent_key (only used
|
|
1534
|
+
when cancel_agent_uses_signal() returns False).
|
|
1535
|
+
"""
|
|
1280
1536
|
try:
|
|
1281
1537
|
import sys
|
|
1282
1538
|
except ImportError:
|
|
@@ -1294,16 +1550,20 @@ class BaseAgent(ABC):
|
|
|
1294
1550
|
def listener() -> None:
|
|
1295
1551
|
try:
|
|
1296
1552
|
if sys.platform.startswith("win"):
|
|
1297
|
-
self._listen_for_ctrl_x_windows(
|
|
1553
|
+
self._listen_for_ctrl_x_windows(
|
|
1554
|
+
stop_event, on_escape, on_cancel_agent
|
|
1555
|
+
)
|
|
1298
1556
|
else:
|
|
1299
|
-
self._listen_for_ctrl_x_posix(
|
|
1557
|
+
self._listen_for_ctrl_x_posix(
|
|
1558
|
+
stop_event, on_escape, on_cancel_agent
|
|
1559
|
+
)
|
|
1300
1560
|
except Exception:
|
|
1301
1561
|
emit_warning(
|
|
1302
|
-
"
|
|
1562
|
+
"Key listener stopped unexpectedly; press Ctrl+C to cancel."
|
|
1303
1563
|
)
|
|
1304
1564
|
|
|
1305
1565
|
thread = threading.Thread(
|
|
1306
|
-
target=listener, name="code-puppy-
|
|
1566
|
+
target=listener, name="code-puppy-key-listener", daemon=True
|
|
1307
1567
|
)
|
|
1308
1568
|
thread.start()
|
|
1309
1569
|
return thread
|
|
@@ -1312,10 +1572,16 @@ class BaseAgent(ABC):
|
|
|
1312
1572
|
self,
|
|
1313
1573
|
stop_event: threading.Event,
|
|
1314
1574
|
on_escape: Callable[[], None],
|
|
1575
|
+
on_cancel_agent: Optional[Callable[[], None]] = None,
|
|
1315
1576
|
) -> None:
|
|
1316
1577
|
import msvcrt
|
|
1317
1578
|
import time
|
|
1318
1579
|
|
|
1580
|
+
# Get the cancel agent char code if we're using keyboard-based cancel
|
|
1581
|
+
cancel_agent_char: Optional[str] = None
|
|
1582
|
+
if on_cancel_agent is not None and not cancel_agent_uses_signal():
|
|
1583
|
+
cancel_agent_char = get_cancel_agent_char_code()
|
|
1584
|
+
|
|
1319
1585
|
while not stop_event.is_set():
|
|
1320
1586
|
try:
|
|
1321
1587
|
if msvcrt.kbhit():
|
|
@@ -1327,9 +1593,18 @@ class BaseAgent(ABC):
|
|
|
1327
1593
|
emit_warning(
|
|
1328
1594
|
"Ctrl+X handler raised unexpectedly; Ctrl+C still works."
|
|
1329
1595
|
)
|
|
1596
|
+
elif (
|
|
1597
|
+
cancel_agent_char
|
|
1598
|
+
and on_cancel_agent
|
|
1599
|
+
and key == cancel_agent_char
|
|
1600
|
+
):
|
|
1601
|
+
try:
|
|
1602
|
+
on_cancel_agent()
|
|
1603
|
+
except Exception:
|
|
1604
|
+
emit_warning("Cancel agent handler raised unexpectedly.")
|
|
1330
1605
|
except Exception:
|
|
1331
1606
|
emit_warning(
|
|
1332
|
-
"Windows
|
|
1607
|
+
"Windows key listener error; Ctrl+C is still available for cancel."
|
|
1333
1608
|
)
|
|
1334
1609
|
return
|
|
1335
1610
|
time.sleep(0.05)
|
|
@@ -1338,12 +1613,18 @@ class BaseAgent(ABC):
|
|
|
1338
1613
|
self,
|
|
1339
1614
|
stop_event: threading.Event,
|
|
1340
1615
|
on_escape: Callable[[], None],
|
|
1616
|
+
on_cancel_agent: Optional[Callable[[], None]] = None,
|
|
1341
1617
|
) -> None:
|
|
1342
1618
|
import select
|
|
1343
1619
|
import sys
|
|
1344
1620
|
import termios
|
|
1345
1621
|
import tty
|
|
1346
1622
|
|
|
1623
|
+
# Get the cancel agent char code if we're using keyboard-based cancel
|
|
1624
|
+
cancel_agent_char: Optional[str] = None
|
|
1625
|
+
if on_cancel_agent is not None and not cancel_agent_uses_signal():
|
|
1626
|
+
cancel_agent_char = get_cancel_agent_char_code()
|
|
1627
|
+
|
|
1347
1628
|
stdin = sys.stdin
|
|
1348
1629
|
try:
|
|
1349
1630
|
fd = stdin.fileno()
|
|
@@ -1373,6 +1654,13 @@ class BaseAgent(ABC):
|
|
|
1373
1654
|
emit_warning(
|
|
1374
1655
|
"Ctrl+X handler raised unexpectedly; Ctrl+C still works."
|
|
1375
1656
|
)
|
|
1657
|
+
elif (
|
|
1658
|
+
cancel_agent_char and on_cancel_agent and data == cancel_agent_char
|
|
1659
|
+
):
|
|
1660
|
+
try:
|
|
1661
|
+
on_cancel_agent()
|
|
1662
|
+
except Exception:
|
|
1663
|
+
emit_warning("Cancel agent handler raised unexpectedly.")
|
|
1376
1664
|
finally:
|
|
1377
1665
|
termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
|
|
1378
1666
|
|
|
@@ -1382,6 +1670,7 @@ class BaseAgent(ABC):
|
|
|
1382
1670
|
*,
|
|
1383
1671
|
attachments: Optional[Sequence[BinaryContent]] = None,
|
|
1384
1672
|
link_attachments: Optional[Sequence[Union[ImageUrl, DocumentUrl]]] = None,
|
|
1673
|
+
output_type: Optional[Type[Any]] = None,
|
|
1385
1674
|
**kwargs,
|
|
1386
1675
|
) -> Any:
|
|
1387
1676
|
"""Run the agent with MCP servers, attachments, and full cancellation support.
|
|
@@ -1390,10 +1679,13 @@ class BaseAgent(ABC):
|
|
|
1390
1679
|
prompt: Primary user prompt text (may be empty when attachments present).
|
|
1391
1680
|
attachments: Local binary payloads (e.g., dragged images) to include.
|
|
1392
1681
|
link_attachments: Remote assets (image/document URLs) to include.
|
|
1682
|
+
output_type: Optional Pydantic model or type for structured output.
|
|
1683
|
+
When provided, creates a temporary agent configured to return
|
|
1684
|
+
this type instead of the default string output.
|
|
1393
1685
|
**kwargs: Additional arguments forwarded to `pydantic_ai.Agent.run`.
|
|
1394
1686
|
|
|
1395
1687
|
Returns:
|
|
1396
|
-
The agent's response.
|
|
1688
|
+
The agent's response (typed according to output_type if specified).
|
|
1397
1689
|
|
|
1398
1690
|
Raises:
|
|
1399
1691
|
asyncio.CancelledError: When execution is cancelled by user.
|
|
@@ -1417,12 +1709,23 @@ class BaseAgent(ABC):
|
|
|
1417
1709
|
pydantic_agent = (
|
|
1418
1710
|
self._code_generation_agent or self.reload_code_generation_agent()
|
|
1419
1711
|
)
|
|
1420
|
-
# Handle claude-code models: prepend system prompt to first user message
|
|
1421
|
-
from code_puppy.model_utils import is_claude_code_model
|
|
1422
1712
|
|
|
1423
|
-
|
|
1713
|
+
# If a custom output_type is specified, create a temporary agent with that type
|
|
1714
|
+
if output_type is not None:
|
|
1715
|
+
pydantic_agent = self._create_agent_with_output_type(output_type)
|
|
1716
|
+
|
|
1717
|
+
# Handle claude-code and chatgpt-codex models: prepend system prompt to first user message
|
|
1718
|
+
from code_puppy.model_utils import is_chatgpt_codex_model, is_claude_code_model
|
|
1719
|
+
|
|
1720
|
+
if is_claude_code_model(self.get_model_name()) or is_chatgpt_codex_model(
|
|
1721
|
+
self.get_model_name()
|
|
1722
|
+
):
|
|
1424
1723
|
if len(self.get_message_history()) == 0:
|
|
1425
|
-
|
|
1724
|
+
system_prompt = self.get_system_prompt()
|
|
1725
|
+
puppy_rules = self.load_puppy_rules()
|
|
1726
|
+
if puppy_rules:
|
|
1727
|
+
system_prompt += f"\n{puppy_rules}"
|
|
1728
|
+
prompt = system_prompt + "\n\n" + prompt
|
|
1426
1729
|
|
|
1427
1730
|
# Build combined prompt payload when attachments are provided.
|
|
1428
1731
|
attachment_parts: List[Any] = []
|
|
@@ -1480,6 +1783,7 @@ class BaseAgent(ABC):
|
|
|
1480
1783
|
prompt_payload,
|
|
1481
1784
|
message_history=self.get_message_history(),
|
|
1482
1785
|
usage_limits=usage_limits,
|
|
1786
|
+
event_stream_handler=self._event_stream_handler,
|
|
1483
1787
|
**kwargs,
|
|
1484
1788
|
)
|
|
1485
1789
|
finally:
|
|
@@ -1492,6 +1796,7 @@ class BaseAgent(ABC):
|
|
|
1492
1796
|
prompt_payload,
|
|
1493
1797
|
message_history=self.get_message_history(),
|
|
1494
1798
|
usage_limits=usage_limits,
|
|
1799
|
+
event_stream_handler=self._event_stream_handler,
|
|
1495
1800
|
**kwargs,
|
|
1496
1801
|
)
|
|
1497
1802
|
else:
|
|
@@ -1500,6 +1805,7 @@ class BaseAgent(ABC):
|
|
|
1500
1805
|
prompt_payload,
|
|
1501
1806
|
message_history=self.get_message_history(),
|
|
1502
1807
|
usage_limits=usage_limits,
|
|
1808
|
+
event_stream_handler=self._event_stream_handler,
|
|
1503
1809
|
**kwargs,
|
|
1504
1810
|
)
|
|
1505
1811
|
return result_
|
|
@@ -1537,6 +1843,12 @@ class BaseAgent(ABC):
|
|
|
1537
1843
|
remaining_exceptions.append(exc)
|
|
1538
1844
|
emit_info(f"Unexpected error: {str(exc)}", group_id=group_id)
|
|
1539
1845
|
emit_info(f"{str(exc.args)}", group_id=group_id)
|
|
1846
|
+
# Log to file for debugging
|
|
1847
|
+
log_error(
|
|
1848
|
+
exc,
|
|
1849
|
+
context=f"Agent run (group_id={group_id})",
|
|
1850
|
+
include_traceback=True,
|
|
1851
|
+
)
|
|
1540
1852
|
|
|
1541
1853
|
collect_non_cancelled_exceptions(other_error)
|
|
1542
1854
|
|
|
@@ -1595,10 +1907,78 @@ class BaseAgent(ABC):
|
|
|
1595
1907
|
|
|
1596
1908
|
schedule_agent_cancel()
|
|
1597
1909
|
|
|
1910
|
+
def graceful_sigint_handler(_sig, _frame):
|
|
1911
|
+
# When using keyboard-based cancel, SIGINT should be a no-op
|
|
1912
|
+
# (just show a hint to user about the configured cancel key)
|
|
1913
|
+
from code_puppy.keymap import get_cancel_agent_display_name
|
|
1914
|
+
import sys
|
|
1915
|
+
|
|
1916
|
+
cancel_key = get_cancel_agent_display_name()
|
|
1917
|
+
if sys.platform == "win32":
|
|
1918
|
+
# On Windows, we use keyboard listener, so SIGINT might still fire
|
|
1919
|
+
# but we handle cancellation via the key listener
|
|
1920
|
+
pass # Silent on Windows - the key listener handles it
|
|
1921
|
+
else:
|
|
1922
|
+
emit_info(f"Use {cancel_key} to cancel the agent task.")
|
|
1923
|
+
|
|
1598
1924
|
original_handler = None
|
|
1925
|
+
key_listener_stop_event = None
|
|
1926
|
+
_key_listener_thread = None
|
|
1927
|
+
_windows_ctrl_handler = None # Store reference to prevent garbage collection
|
|
1928
|
+
|
|
1599
1929
|
try:
|
|
1600
|
-
|
|
1601
|
-
|
|
1930
|
+
if sys.platform == "win32":
|
|
1931
|
+
# Windows: Use SetConsoleCtrlHandler for reliable Ctrl+C handling
|
|
1932
|
+
import ctypes
|
|
1933
|
+
|
|
1934
|
+
# Define the handler function type
|
|
1935
|
+
HANDLER_ROUTINE = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_ulong)
|
|
1936
|
+
|
|
1937
|
+
def windows_ctrl_handler(ctrl_type):
|
|
1938
|
+
"""Handle Windows console control events."""
|
|
1939
|
+
CTRL_C_EVENT = 0
|
|
1940
|
+
CTRL_BREAK_EVENT = 1
|
|
1941
|
+
|
|
1942
|
+
if ctrl_type in (CTRL_C_EVENT, CTRL_BREAK_EVENT):
|
|
1943
|
+
# Check if we're awaiting user input
|
|
1944
|
+
if is_awaiting_user_input():
|
|
1945
|
+
return False # Let default handler run
|
|
1946
|
+
|
|
1947
|
+
# Schedule agent cancellation
|
|
1948
|
+
schedule_agent_cancel()
|
|
1949
|
+
return True # We handled it, don't terminate
|
|
1950
|
+
|
|
1951
|
+
return False # Let other handlers process it
|
|
1952
|
+
|
|
1953
|
+
# Create the callback - must keep reference alive!
|
|
1954
|
+
_windows_ctrl_handler = HANDLER_ROUTINE(windows_ctrl_handler)
|
|
1955
|
+
|
|
1956
|
+
# Register the handler
|
|
1957
|
+
kernel32 = ctypes.windll.kernel32
|
|
1958
|
+
if not kernel32.SetConsoleCtrlHandler(_windows_ctrl_handler, True):
|
|
1959
|
+
emit_warning("Failed to set Windows Ctrl+C handler")
|
|
1960
|
+
|
|
1961
|
+
# Also spawn keyboard listener for Ctrl+X (shell cancel) and other keys
|
|
1962
|
+
key_listener_stop_event = threading.Event()
|
|
1963
|
+
_key_listener_thread = self._spawn_ctrl_x_key_listener(
|
|
1964
|
+
key_listener_stop_event,
|
|
1965
|
+
on_escape=lambda: None, # Ctrl+X handled by command_runner
|
|
1966
|
+
on_cancel_agent=None, # Ctrl+C handled by SetConsoleCtrlHandler above
|
|
1967
|
+
)
|
|
1968
|
+
elif cancel_agent_uses_signal():
|
|
1969
|
+
# Unix with Ctrl+C: Use SIGINT-based cancellation
|
|
1970
|
+
original_handler = signal.signal(
|
|
1971
|
+
signal.SIGINT, keyboard_interrupt_handler
|
|
1972
|
+
)
|
|
1973
|
+
else:
|
|
1974
|
+
# Unix with different cancel key: Use keyboard listener
|
|
1975
|
+
original_handler = signal.signal(signal.SIGINT, graceful_sigint_handler)
|
|
1976
|
+
key_listener_stop_event = threading.Event()
|
|
1977
|
+
_key_listener_thread = self._spawn_ctrl_x_key_listener(
|
|
1978
|
+
key_listener_stop_event,
|
|
1979
|
+
on_escape=lambda: None,
|
|
1980
|
+
on_cancel_agent=schedule_agent_cancel,
|
|
1981
|
+
)
|
|
1602
1982
|
|
|
1603
1983
|
# Wait for the task to complete or be cancelled
|
|
1604
1984
|
result = await agent_task
|
|
@@ -1618,6 +1998,20 @@ class BaseAgent(ABC):
|
|
|
1618
1998
|
if not agent_task.done():
|
|
1619
1999
|
agent_task.cancel()
|
|
1620
2000
|
finally:
|
|
1621
|
-
#
|
|
1622
|
-
if
|
|
2001
|
+
# Stop keyboard listener if it was started
|
|
2002
|
+
if key_listener_stop_event is not None:
|
|
2003
|
+
key_listener_stop_event.set()
|
|
2004
|
+
|
|
2005
|
+
# Unregister Windows Ctrl handler
|
|
2006
|
+
if sys.platform == "win32" and _windows_ctrl_handler is not None:
|
|
2007
|
+
try:
|
|
2008
|
+
import ctypes
|
|
2009
|
+
|
|
2010
|
+
kernel32 = ctypes.windll.kernel32
|
|
2011
|
+
kernel32.SetConsoleCtrlHandler(_windows_ctrl_handler, False)
|
|
2012
|
+
except Exception:
|
|
2013
|
+
pass # Best effort cleanup
|
|
2014
|
+
|
|
2015
|
+
# Restore original signal handler (Unix)
|
|
2016
|
+
if original_handler is not None:
|
|
1623
2017
|
signal.signal(signal.SIGINT, original_handler)
|