code-puppy 0.0.302__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/agents/base_agent.py +373 -46
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/cli_runner.py +795 -0
- code_puppy/command_line/add_model_menu.py +8 -1
- code_puppy/command_line/autosave_menu.py +266 -35
- code_puppy/command_line/colors_menu.py +515 -0
- code_puppy/command_line/command_handler.py +8 -2
- code_puppy/command_line/config_commands.py +59 -10
- code_puppy/command_line/core_commands.py +19 -7
- code_puppy/command_line/mcp/edit_command.py +3 -1
- code_puppy/command_line/mcp/handler.py +7 -2
- code_puppy/command_line/mcp/install_command.py +8 -3
- code_puppy/command_line/mcp/logs_command.py +173 -64
- code_puppy/command_line/mcp/restart_command.py +7 -2
- code_puppy/command_line/mcp/search_command.py +10 -4
- code_puppy/command_line/mcp/start_all_command.py +16 -6
- code_puppy/command_line/mcp/start_command.py +3 -1
- code_puppy/command_line/mcp/status_command.py +2 -1
- code_puppy/command_line/mcp/stop_all_command.py +5 -1
- code_puppy/command_line/mcp/stop_command.py +3 -1
- code_puppy/command_line/mcp/wizard_utils.py +10 -4
- code_puppy/command_line/model_settings_menu.py +53 -7
- code_puppy/command_line/prompt_toolkit_completion.py +16 -2
- code_puppy/command_line/session_commands.py +11 -4
- code_puppy/config.py +103 -15
- code_puppy/keymap.py +8 -2
- code_puppy/main.py +5 -828
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/blocking_startup.py +61 -32
- code_puppy/mcp_/config_wizard.py +5 -1
- code_puppy/mcp_/managed_server.py +23 -3
- code_puppy/mcp_/manager.py +65 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/messaging/__init__.py +20 -4
- code_puppy/messaging/bus.py +64 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/messages.py +16 -0
- code_puppy/messaging/renderers.py +21 -9
- code_puppy/messaging/rich_renderer.py +113 -67
- code_puppy/messaging/spinner/console_spinner.py +34 -0
- code_puppy/model_factory.py +185 -30
- code_puppy/model_utils.py +57 -48
- code_puppy/models.json +19 -5
- code_puppy/plugins/chatgpt_oauth/config.py +5 -1
- 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 +26 -11
- code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
- code_puppy/plugins/claude_code_oauth/utils.py +1 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
- code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/terminal_utils.py +126 -0
- code_puppy/tools/agent_tools.py +34 -9
- code_puppy/tools/command_runner.py +361 -32
- code_puppy/tools/file_operations.py +33 -45
- {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/METADATA +1 -1
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/RECORD +65 -57
- {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.302.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,11 +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
63
|
from code_puppy.error_logging import log_error
|
|
50
64
|
from code_puppy.keymap import cancel_agent_uses_signal, get_cancel_agent_char_code
|
|
51
|
-
from code_puppy.mcp_ import
|
|
65
|
+
from code_puppy.mcp_ import get_mcp_manager
|
|
52
66
|
from code_puppy.messaging import (
|
|
53
67
|
emit_error,
|
|
54
68
|
emit_info,
|
|
@@ -87,6 +101,9 @@ class BaseAgent(ABC):
|
|
|
87
101
|
# Cache for MCP tool definitions (for token estimation)
|
|
88
102
|
# This is populated after the first successful run when MCP tools are retrieved
|
|
89
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
|
|
90
107
|
|
|
91
108
|
@property
|
|
92
109
|
@abstractmethod
|
|
@@ -364,7 +381,9 @@ class BaseAgent(ABC):
|
|
|
364
381
|
# fixed instructions. For other models, count the full system prompt.
|
|
365
382
|
try:
|
|
366
383
|
from code_puppy.model_utils import (
|
|
384
|
+
get_chatgpt_codex_instructions,
|
|
367
385
|
get_claude_code_instructions,
|
|
386
|
+
is_chatgpt_codex_model,
|
|
368
387
|
is_claude_code_model,
|
|
369
388
|
)
|
|
370
389
|
|
|
@@ -376,6 +395,11 @@ class BaseAgent(ABC):
|
|
|
376
395
|
# The full system prompt is already in the message history
|
|
377
396
|
instructions = get_claude_code_instructions()
|
|
378
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)
|
|
379
403
|
else:
|
|
380
404
|
# For other models, count the full system prompt
|
|
381
405
|
system_prompt = self.get_system_prompt()
|
|
@@ -979,45 +1003,31 @@ class BaseAgent(ABC):
|
|
|
979
1003
|
return self._puppy_rules
|
|
980
1004
|
|
|
981
1005
|
def load_mcp_servers(self, extra_headers: Optional[Dict[str, str]] = None):
|
|
982
|
-
"""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
|
+
"""
|
|
983
1011
|
|
|
984
1012
|
mcp_disabled = get_value("disable_mcp_servers")
|
|
985
1013
|
if mcp_disabled and str(mcp_disabled).lower() in ("1", "true", "yes", "on"):
|
|
986
1014
|
return []
|
|
987
1015
|
|
|
988
1016
|
manager = get_mcp_manager()
|
|
989
|
-
configs = load_mcp_server_configs()
|
|
990
|
-
if not configs:
|
|
991
|
-
existing_servers = manager.list_servers()
|
|
992
|
-
if not existing_servers:
|
|
993
|
-
return []
|
|
994
|
-
else:
|
|
995
|
-
for name, conf in configs.items():
|
|
996
|
-
try:
|
|
997
|
-
server_config = ServerConfig(
|
|
998
|
-
id=conf.get("id", f"{name}_{hash(name)}"),
|
|
999
|
-
name=name,
|
|
1000
|
-
type=conf.get("type", "sse"),
|
|
1001
|
-
enabled=conf.get("enabled", True),
|
|
1002
|
-
config=conf,
|
|
1003
|
-
)
|
|
1004
|
-
existing = manager.get_server_by_name(name)
|
|
1005
|
-
if not existing:
|
|
1006
|
-
manager.register_server(server_config)
|
|
1007
|
-
else:
|
|
1008
|
-
if existing.config != server_config.config:
|
|
1009
|
-
manager.update_server(existing.id, server_config)
|
|
1010
|
-
except Exception:
|
|
1011
|
-
continue
|
|
1012
|
-
|
|
1013
1017
|
return manager.get_servers_for_agent()
|
|
1014
1018
|
|
|
1015
1019
|
def reload_mcp_servers(self):
|
|
1016
|
-
"""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
|
+
"""
|
|
1017
1024
|
# Clear the MCP tool cache when servers are reloaded
|
|
1018
1025
|
self._mcp_tool_definitions_cache = []
|
|
1019
|
-
|
|
1026
|
+
|
|
1027
|
+
# Force re-sync from mcp_servers.json
|
|
1020
1028
|
manager = get_mcp_manager()
|
|
1029
|
+
manager.sync_from_config()
|
|
1030
|
+
|
|
1021
1031
|
return manager.get_servers_for_agent()
|
|
1022
1032
|
|
|
1023
1033
|
def _load_model_with_fallback(
|
|
@@ -1173,7 +1183,9 @@ class BaseAgent(ABC):
|
|
|
1173
1183
|
|
|
1174
1184
|
if len(filtered_mcp_servers) != len(mcp_servers):
|
|
1175
1185
|
emit_info(
|
|
1176
|
-
|
|
1186
|
+
Text.from_markup(
|
|
1187
|
+
f"[dim]Filtered {len(mcp_servers) - len(filtered_mcp_servers)} conflicting MCP tools[/dim]"
|
|
1188
|
+
)
|
|
1177
1189
|
)
|
|
1178
1190
|
|
|
1179
1191
|
self._last_model_name = resolved_model_name
|
|
@@ -1230,6 +1242,74 @@ class BaseAgent(ABC):
|
|
|
1230
1242
|
self._mcp_servers = mcp_servers
|
|
1231
1243
|
return self._code_generation_agent
|
|
1232
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
|
+
|
|
1233
1313
|
# It's okay to decorate it with DBOS.step even if not using DBOS; the decorator is a no-op in that case.
|
|
1234
1314
|
@DBOS.step()
|
|
1235
1315
|
def message_history_accumulator(self, ctx: RunContext, messages: List[Any]):
|
|
@@ -1255,6 +1335,187 @@ class BaseAgent(ABC):
|
|
|
1255
1335
|
self.set_message_history(result_messages_filtered_empty_thinking)
|
|
1256
1336
|
return self.get_message_history()
|
|
1257
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
|
+
|
|
1258
1519
|
def _spawn_ctrl_x_key_listener(
|
|
1259
1520
|
self,
|
|
1260
1521
|
stop_event: threading.Event,
|
|
@@ -1409,6 +1670,7 @@ class BaseAgent(ABC):
|
|
|
1409
1670
|
*,
|
|
1410
1671
|
attachments: Optional[Sequence[BinaryContent]] = None,
|
|
1411
1672
|
link_attachments: Optional[Sequence[Union[ImageUrl, DocumentUrl]]] = None,
|
|
1673
|
+
output_type: Optional[Type[Any]] = None,
|
|
1412
1674
|
**kwargs,
|
|
1413
1675
|
) -> Any:
|
|
1414
1676
|
"""Run the agent with MCP servers, attachments, and full cancellation support.
|
|
@@ -1417,10 +1679,13 @@ class BaseAgent(ABC):
|
|
|
1417
1679
|
prompt: Primary user prompt text (may be empty when attachments present).
|
|
1418
1680
|
attachments: Local binary payloads (e.g., dragged images) to include.
|
|
1419
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.
|
|
1420
1685
|
**kwargs: Additional arguments forwarded to `pydantic_ai.Agent.run`.
|
|
1421
1686
|
|
|
1422
1687
|
Returns:
|
|
1423
|
-
The agent's response.
|
|
1688
|
+
The agent's response (typed according to output_type if specified).
|
|
1424
1689
|
|
|
1425
1690
|
Raises:
|
|
1426
1691
|
asyncio.CancelledError: When execution is cancelled by user.
|
|
@@ -1444,10 +1709,17 @@ class BaseAgent(ABC):
|
|
|
1444
1709
|
pydantic_agent = (
|
|
1445
1710
|
self._code_generation_agent or self.reload_code_generation_agent()
|
|
1446
1711
|
)
|
|
1447
|
-
# Handle claude-code models: prepend system prompt to first user message
|
|
1448
|
-
from code_puppy.model_utils import is_claude_code_model
|
|
1449
1712
|
|
|
1450
|
-
|
|
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
|
+
):
|
|
1451
1723
|
if len(self.get_message_history()) == 0:
|
|
1452
1724
|
system_prompt = self.get_system_prompt()
|
|
1453
1725
|
puppy_rules = self.load_puppy_rules()
|
|
@@ -1511,6 +1783,7 @@ class BaseAgent(ABC):
|
|
|
1511
1783
|
prompt_payload,
|
|
1512
1784
|
message_history=self.get_message_history(),
|
|
1513
1785
|
usage_limits=usage_limits,
|
|
1786
|
+
event_stream_handler=self._event_stream_handler,
|
|
1514
1787
|
**kwargs,
|
|
1515
1788
|
)
|
|
1516
1789
|
finally:
|
|
@@ -1523,6 +1796,7 @@ class BaseAgent(ABC):
|
|
|
1523
1796
|
prompt_payload,
|
|
1524
1797
|
message_history=self.get_message_history(),
|
|
1525
1798
|
usage_limits=usage_limits,
|
|
1799
|
+
event_stream_handler=self._event_stream_handler,
|
|
1526
1800
|
**kwargs,
|
|
1527
1801
|
)
|
|
1528
1802
|
else:
|
|
@@ -1531,6 +1805,7 @@ class BaseAgent(ABC):
|
|
|
1531
1805
|
prompt_payload,
|
|
1532
1806
|
message_history=self.get_message_history(),
|
|
1533
1807
|
usage_limits=usage_limits,
|
|
1808
|
+
event_stream_handler=self._event_stream_handler,
|
|
1534
1809
|
**kwargs,
|
|
1535
1810
|
)
|
|
1536
1811
|
return result_
|
|
@@ -1636,29 +1911,72 @@ class BaseAgent(ABC):
|
|
|
1636
1911
|
# When using keyboard-based cancel, SIGINT should be a no-op
|
|
1637
1912
|
# (just show a hint to user about the configured cancel key)
|
|
1638
1913
|
from code_puppy.keymap import get_cancel_agent_display_name
|
|
1914
|
+
import sys
|
|
1639
1915
|
|
|
1640
1916
|
cancel_key = get_cancel_agent_display_name()
|
|
1641
|
-
|
|
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.")
|
|
1642
1923
|
|
|
1643
1924
|
original_handler = None
|
|
1644
1925
|
key_listener_stop_event = None
|
|
1645
1926
|
_key_listener_thread = None
|
|
1927
|
+
_windows_ctrl_handler = None # Store reference to prevent garbage collection
|
|
1646
1928
|
|
|
1647
1929
|
try:
|
|
1648
|
-
if
|
|
1649
|
-
# Use
|
|
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
|
|
1650
1970
|
original_handler = signal.signal(
|
|
1651
1971
|
signal.SIGINT, keyboard_interrupt_handler
|
|
1652
1972
|
)
|
|
1653
1973
|
else:
|
|
1654
|
-
# Use keyboard listener
|
|
1655
|
-
# Set a graceful SIGINT handler that shows a hint
|
|
1974
|
+
# Unix with different cancel key: Use keyboard listener
|
|
1656
1975
|
original_handler = signal.signal(signal.SIGINT, graceful_sigint_handler)
|
|
1657
|
-
# Spawn keyboard listener with the cancel agent callback
|
|
1658
1976
|
key_listener_stop_event = threading.Event()
|
|
1659
1977
|
_key_listener_thread = self._spawn_ctrl_x_key_listener(
|
|
1660
1978
|
key_listener_stop_event,
|
|
1661
|
-
on_escape=lambda: None,
|
|
1979
|
+
on_escape=lambda: None,
|
|
1662
1980
|
on_cancel_agent=schedule_agent_cancel,
|
|
1663
1981
|
)
|
|
1664
1982
|
|
|
@@ -1683,8 +2001,17 @@ class BaseAgent(ABC):
|
|
|
1683
2001
|
# Stop keyboard listener if it was started
|
|
1684
2002
|
if key_listener_stop_event is not None:
|
|
1685
2003
|
key_listener_stop_event.set()
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
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:
|
|
1690
2017
|
signal.signal(signal.SIGINT, original_handler)
|