code-puppy 0.0.302__py3-none-any.whl → 0.0.335__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 +343 -35
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/cli_runner.py +898 -0
- code_puppy/command_line/add_model_menu.py +23 -1
- code_puppy/command_line/autosave_menu.py +271 -35
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +8 -2
- code_puppy/command_line/config_commands.py +82 -10
- code_puppy/command_line/core_commands.py +70 -7
- code_puppy/command_line/diff_menu.py +5 -0
- code_puppy/command_line/mcp/custom_server_form.py +4 -0
- 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/install_menu.py +5 -1
- 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 +58 -7
- code_puppy/command_line/motd.py +13 -7
- code_puppy/command_line/onboarding_slides.py +180 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/prompt_toolkit_completion.py +16 -2
- code_puppy/command_line/session_commands.py +11 -4
- code_puppy/config.py +106 -17
- code_puppy/http_utils.py +155 -196
- code_puppy/keymap.py +8 -0
- 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 +271 -45
- code_puppy/model_utils.py +57 -48
- code_puppy/models.json +21 -7
- code_puppy/plugins/__init__.py +12 -0
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +612 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +595 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- 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 +5 -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 +30 -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/reopenable_async_client.py +8 -8
- code_puppy/terminal_utils.py +291 -0
- code_puppy/tools/agent_tools.py +34 -9
- code_puppy/tools/command_runner.py +344 -27
- code_puppy/tools/file_operations.py +33 -45
- code_puppy/uvx_detection.py +242 -0
- {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models.json +21 -7
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/METADATA +30 -1
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/RECORD +87 -64
- {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/licenses/LICENSE +0 -0
code_puppy/agents/base_agent.py
CHANGED
|
@@ -7,7 +7,19 @@ import signal
|
|
|
7
7
|
import threading
|
|
8
8
|
import uuid
|
|
9
9
|
from abc import ABC, abstractmethod
|
|
10
|
-
from
|
|
10
|
+
from collections.abc import AsyncIterable
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
Callable,
|
|
14
|
+
Dict,
|
|
15
|
+
List,
|
|
16
|
+
Optional,
|
|
17
|
+
Sequence,
|
|
18
|
+
Set,
|
|
19
|
+
Tuple,
|
|
20
|
+
Type,
|
|
21
|
+
Union,
|
|
22
|
+
)
|
|
11
23
|
|
|
12
24
|
import mcp
|
|
13
25
|
import pydantic
|
|
@@ -18,6 +30,7 @@ from pydantic_ai import (
|
|
|
18
30
|
BinaryContent,
|
|
19
31
|
DocumentUrl,
|
|
20
32
|
ImageUrl,
|
|
33
|
+
PartEndEvent,
|
|
21
34
|
RunContext,
|
|
22
35
|
UsageLimitExceeded,
|
|
23
36
|
UsageLimits,
|
|
@@ -33,6 +46,7 @@ from pydantic_ai.messages import (
|
|
|
33
46
|
ToolReturn,
|
|
34
47
|
ToolReturnPart,
|
|
35
48
|
)
|
|
49
|
+
from rich.text import Text
|
|
36
50
|
|
|
37
51
|
# Consolidated relative imports
|
|
38
52
|
from code_puppy.config import (
|
|
@@ -44,11 +58,10 @@ from code_puppy.config import (
|
|
|
44
58
|
get_protected_token_count,
|
|
45
59
|
get_use_dbos,
|
|
46
60
|
get_value,
|
|
47
|
-
load_mcp_server_configs,
|
|
48
61
|
)
|
|
49
62
|
from code_puppy.error_logging import log_error
|
|
50
63
|
from code_puppy.keymap import cancel_agent_uses_signal, get_cancel_agent_char_code
|
|
51
|
-
from code_puppy.mcp_ import
|
|
64
|
+
from code_puppy.mcp_ import get_mcp_manager
|
|
52
65
|
from code_puppy.messaging import (
|
|
53
66
|
emit_error,
|
|
54
67
|
emit_info,
|
|
@@ -87,6 +100,9 @@ class BaseAgent(ABC):
|
|
|
87
100
|
# Cache for MCP tool definitions (for token estimation)
|
|
88
101
|
# This is populated after the first successful run when MCP tools are retrieved
|
|
89
102
|
self._mcp_tool_definitions_cache: List[Dict[str, Any]] = []
|
|
103
|
+
# Shared console for streaming output - should be set by cli_runner
|
|
104
|
+
# to avoid conflicts between spinner's Live display and response streaming
|
|
105
|
+
self._console: Optional[Any] = None
|
|
90
106
|
|
|
91
107
|
@property
|
|
92
108
|
@abstractmethod
|
|
@@ -364,7 +380,9 @@ class BaseAgent(ABC):
|
|
|
364
380
|
# fixed instructions. For other models, count the full system prompt.
|
|
365
381
|
try:
|
|
366
382
|
from code_puppy.model_utils import (
|
|
383
|
+
get_chatgpt_codex_instructions,
|
|
367
384
|
get_claude_code_instructions,
|
|
385
|
+
is_chatgpt_codex_model,
|
|
368
386
|
is_claude_code_model,
|
|
369
387
|
)
|
|
370
388
|
|
|
@@ -376,6 +394,11 @@ class BaseAgent(ABC):
|
|
|
376
394
|
# The full system prompt is already in the message history
|
|
377
395
|
instructions = get_claude_code_instructions()
|
|
378
396
|
total_tokens += self.estimate_token_count(instructions)
|
|
397
|
+
elif is_chatgpt_codex_model(model_name):
|
|
398
|
+
# For ChatGPT Codex models, only count the short fixed instructions
|
|
399
|
+
# The full system prompt is already in the message history
|
|
400
|
+
instructions = get_chatgpt_codex_instructions()
|
|
401
|
+
total_tokens += self.estimate_token_count(instructions)
|
|
379
402
|
else:
|
|
380
403
|
# For other models, count the full system prompt
|
|
381
404
|
system_prompt = self.get_system_prompt()
|
|
@@ -979,45 +1002,31 @@ class BaseAgent(ABC):
|
|
|
979
1002
|
return self._puppy_rules
|
|
980
1003
|
|
|
981
1004
|
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.
|
|
1005
|
+
"""Load MCP servers through the manager and return pydantic-ai compatible servers.
|
|
1006
|
+
|
|
1007
|
+
Note: The manager automatically syncs from mcp_servers.json during initialization,
|
|
1008
|
+
so we don't need to sync here. Use reload_mcp_servers() to force a re-sync.
|
|
1009
|
+
"""
|
|
983
1010
|
|
|
984
1011
|
mcp_disabled = get_value("disable_mcp_servers")
|
|
985
1012
|
if mcp_disabled and str(mcp_disabled).lower() in ("1", "true", "yes", "on"):
|
|
986
1013
|
return []
|
|
987
1014
|
|
|
988
1015
|
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
1016
|
return manager.get_servers_for_agent()
|
|
1014
1017
|
|
|
1015
1018
|
def reload_mcp_servers(self):
|
|
1016
|
-
"""Reload MCP servers and return updated servers.
|
|
1019
|
+
"""Reload MCP servers and return updated servers.
|
|
1020
|
+
|
|
1021
|
+
Forces a re-sync from mcp_servers.json to pick up any configuration changes.
|
|
1022
|
+
"""
|
|
1017
1023
|
# Clear the MCP tool cache when servers are reloaded
|
|
1018
1024
|
self._mcp_tool_definitions_cache = []
|
|
1019
|
-
|
|
1025
|
+
|
|
1026
|
+
# Force re-sync from mcp_servers.json
|
|
1020
1027
|
manager = get_mcp_manager()
|
|
1028
|
+
manager.sync_from_config()
|
|
1029
|
+
|
|
1021
1030
|
return manager.get_servers_for_agent()
|
|
1022
1031
|
|
|
1023
1032
|
def _load_model_with_fallback(
|
|
@@ -1173,7 +1182,9 @@ class BaseAgent(ABC):
|
|
|
1173
1182
|
|
|
1174
1183
|
if len(filtered_mcp_servers) != len(mcp_servers):
|
|
1175
1184
|
emit_info(
|
|
1176
|
-
|
|
1185
|
+
Text.from_markup(
|
|
1186
|
+
f"[dim]Filtered {len(mcp_servers) - len(filtered_mcp_servers)} conflicting MCP tools[/dim]"
|
|
1187
|
+
)
|
|
1177
1188
|
)
|
|
1178
1189
|
|
|
1179
1190
|
self._last_model_name = resolved_model_name
|
|
@@ -1230,6 +1241,74 @@ class BaseAgent(ABC):
|
|
|
1230
1241
|
self._mcp_servers = mcp_servers
|
|
1231
1242
|
return self._code_generation_agent
|
|
1232
1243
|
|
|
1244
|
+
def _create_agent_with_output_type(self, output_type: Type[Any]) -> PydanticAgent:
|
|
1245
|
+
"""Create a temporary agent configured with a custom output_type.
|
|
1246
|
+
|
|
1247
|
+
This is used when structured output is requested via run_with_mcp.
|
|
1248
|
+
The agent is created fresh with the same configuration as the main agent
|
|
1249
|
+
but with the specified output_type instead of str.
|
|
1250
|
+
|
|
1251
|
+
Args:
|
|
1252
|
+
output_type: The Pydantic model or type for structured output.
|
|
1253
|
+
|
|
1254
|
+
Returns:
|
|
1255
|
+
A configured PydanticAgent (or DBOSAgent wrapper) with the custom output_type.
|
|
1256
|
+
"""
|
|
1257
|
+
from code_puppy.model_utils import prepare_prompt_for_model
|
|
1258
|
+
from code_puppy.tools import register_tools_for_agent
|
|
1259
|
+
|
|
1260
|
+
model_name = self.get_model_name()
|
|
1261
|
+
models_config = ModelFactory.load_config()
|
|
1262
|
+
model, resolved_model_name = self._load_model_with_fallback(
|
|
1263
|
+
model_name, models_config, str(uuid.uuid4())
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
instructions = self.get_system_prompt()
|
|
1267
|
+
puppy_rules = self.load_puppy_rules()
|
|
1268
|
+
if puppy_rules:
|
|
1269
|
+
instructions += f"\n{puppy_rules}"
|
|
1270
|
+
|
|
1271
|
+
mcp_servers = getattr(self, "_mcp_servers", []) or []
|
|
1272
|
+
model_settings = make_model_settings(resolved_model_name)
|
|
1273
|
+
|
|
1274
|
+
prepared = prepare_prompt_for_model(
|
|
1275
|
+
model_name, instructions, "", prepend_system_to_user=False
|
|
1276
|
+
)
|
|
1277
|
+
instructions = prepared.instructions
|
|
1278
|
+
|
|
1279
|
+
global _reload_count
|
|
1280
|
+
_reload_count += 1
|
|
1281
|
+
|
|
1282
|
+
if get_use_dbos():
|
|
1283
|
+
temp_agent = PydanticAgent(
|
|
1284
|
+
model=model,
|
|
1285
|
+
instructions=instructions,
|
|
1286
|
+
output_type=output_type,
|
|
1287
|
+
retries=3,
|
|
1288
|
+
toolsets=[],
|
|
1289
|
+
history_processors=[self.message_history_accumulator],
|
|
1290
|
+
model_settings=model_settings,
|
|
1291
|
+
)
|
|
1292
|
+
agent_tools = self.get_available_tools()
|
|
1293
|
+
register_tools_for_agent(temp_agent, agent_tools)
|
|
1294
|
+
dbos_agent = DBOSAgent(
|
|
1295
|
+
temp_agent, name=f"{self.name}-structured-{_reload_count}"
|
|
1296
|
+
)
|
|
1297
|
+
return dbos_agent
|
|
1298
|
+
else:
|
|
1299
|
+
temp_agent = PydanticAgent(
|
|
1300
|
+
model=model,
|
|
1301
|
+
instructions=instructions,
|
|
1302
|
+
output_type=output_type,
|
|
1303
|
+
retries=3,
|
|
1304
|
+
toolsets=mcp_servers,
|
|
1305
|
+
history_processors=[self.message_history_accumulator],
|
|
1306
|
+
model_settings=model_settings,
|
|
1307
|
+
)
|
|
1308
|
+
agent_tools = self.get_available_tools()
|
|
1309
|
+
register_tools_for_agent(temp_agent, agent_tools)
|
|
1310
|
+
return temp_agent
|
|
1311
|
+
|
|
1233
1312
|
# It's okay to decorate it with DBOS.step even if not using DBOS; the decorator is a no-op in that case.
|
|
1234
1313
|
@DBOS.step()
|
|
1235
1314
|
def message_history_accumulator(self, ctx: RunContext, messages: List[Any]):
|
|
@@ -1255,6 +1334,216 @@ class BaseAgent(ABC):
|
|
|
1255
1334
|
self.set_message_history(result_messages_filtered_empty_thinking)
|
|
1256
1335
|
return self.get_message_history()
|
|
1257
1336
|
|
|
1337
|
+
async def _event_stream_handler(
|
|
1338
|
+
self, ctx: RunContext, events: AsyncIterable[Any]
|
|
1339
|
+
) -> None:
|
|
1340
|
+
"""Handle streaming events from the agent run.
|
|
1341
|
+
|
|
1342
|
+
This method processes streaming events and emits TextPart, ThinkingPart,
|
|
1343
|
+
and ToolCallPart content with styled banners/tokens as they stream in.
|
|
1344
|
+
|
|
1345
|
+
Args:
|
|
1346
|
+
ctx: The run context.
|
|
1347
|
+
events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
|
|
1348
|
+
"""
|
|
1349
|
+
from pydantic_ai import PartDeltaEvent, PartStartEvent
|
|
1350
|
+
from pydantic_ai.messages import (
|
|
1351
|
+
TextPartDelta,
|
|
1352
|
+
ThinkingPartDelta,
|
|
1353
|
+
ToolCallPartDelta,
|
|
1354
|
+
)
|
|
1355
|
+
from rich.console import Console
|
|
1356
|
+
from rich.markdown import Markdown
|
|
1357
|
+
from rich.markup import escape
|
|
1358
|
+
|
|
1359
|
+
from code_puppy.messaging.spinner import pause_all_spinners
|
|
1360
|
+
|
|
1361
|
+
# IMPORTANT: Use the shared console (set by cli_runner) to avoid conflicts
|
|
1362
|
+
# with the spinner's Live display. Multiple Console instances with separate
|
|
1363
|
+
# Live displays cause cursor positioning chaos and line duplication.
|
|
1364
|
+
if self._console is not None:
|
|
1365
|
+
console = self._console
|
|
1366
|
+
else:
|
|
1367
|
+
# Fallback if console not set (shouldn't happen in normal use)
|
|
1368
|
+
console = Console()
|
|
1369
|
+
|
|
1370
|
+
# Track which part indices we're currently streaming (for Text/Thinking/Tool parts)
|
|
1371
|
+
streaming_parts: set[int] = set()
|
|
1372
|
+
thinking_parts: set[int] = (
|
|
1373
|
+
set()
|
|
1374
|
+
) # Track which parts are thinking (for dim style)
|
|
1375
|
+
text_parts: set[int] = set() # Track which parts are text
|
|
1376
|
+
tool_parts: set[int] = set() # Track which parts are tool calls
|
|
1377
|
+
banner_printed: set[int] = set() # Track if banner was already printed
|
|
1378
|
+
text_buffer: dict[int, list[str]] = {} # Buffer text for final markdown render
|
|
1379
|
+
token_count: dict[int, int] = {} # Track token count per text/tool part
|
|
1380
|
+
did_stream_anything = False # Track if we streamed any content
|
|
1381
|
+
|
|
1382
|
+
def _print_thinking_banner() -> None:
|
|
1383
|
+
"""Print the THINKING banner with spinner pause and line clear."""
|
|
1384
|
+
nonlocal did_stream_anything
|
|
1385
|
+
import time
|
|
1386
|
+
|
|
1387
|
+
from code_puppy.config import get_banner_color
|
|
1388
|
+
|
|
1389
|
+
pause_all_spinners()
|
|
1390
|
+
time.sleep(0.1) # Delay to let spinner fully clear
|
|
1391
|
+
# Clear line and print newline before banner
|
|
1392
|
+
console.print(" " * 50, end="\r")
|
|
1393
|
+
console.print() # Newline before banner
|
|
1394
|
+
# Bold banner with configurable color and lightning bolt
|
|
1395
|
+
thinking_color = get_banner_color("thinking")
|
|
1396
|
+
console.print(
|
|
1397
|
+
Text.from_markup(
|
|
1398
|
+
f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]⚡ "
|
|
1399
|
+
),
|
|
1400
|
+
end="",
|
|
1401
|
+
)
|
|
1402
|
+
did_stream_anything = True
|
|
1403
|
+
|
|
1404
|
+
def _print_response_banner() -> None:
|
|
1405
|
+
"""Print the AGENT RESPONSE banner with spinner pause and line clear."""
|
|
1406
|
+
nonlocal did_stream_anything
|
|
1407
|
+
import time
|
|
1408
|
+
|
|
1409
|
+
from code_puppy.config import get_banner_color
|
|
1410
|
+
|
|
1411
|
+
pause_all_spinners()
|
|
1412
|
+
time.sleep(0.1) # Delay to let spinner fully clear
|
|
1413
|
+
# Clear line and print newline before banner
|
|
1414
|
+
console.print(" " * 50, end="\r")
|
|
1415
|
+
console.print() # Newline before banner
|
|
1416
|
+
response_color = get_banner_color("agent_response")
|
|
1417
|
+
console.print(
|
|
1418
|
+
Text.from_markup(
|
|
1419
|
+
f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
|
|
1420
|
+
)
|
|
1421
|
+
)
|
|
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
|
+
# Count chunks (each part counts as 1)
|
|
1446
|
+
token_count[event.index] += 1
|
|
1447
|
+
elif isinstance(part, ToolCallPart):
|
|
1448
|
+
streaming_parts.add(event.index)
|
|
1449
|
+
tool_parts.add(event.index)
|
|
1450
|
+
token_count[event.index] = 0 # Initialize token counter
|
|
1451
|
+
# Track tool name for display
|
|
1452
|
+
banner_printed.add(
|
|
1453
|
+
event.index
|
|
1454
|
+
) # Use banner_printed to track if we've shown tool info
|
|
1455
|
+
|
|
1456
|
+
# PartDeltaEvent - stream the content as it arrives
|
|
1457
|
+
elif isinstance(event, PartDeltaEvent):
|
|
1458
|
+
if event.index in streaming_parts:
|
|
1459
|
+
delta = event.delta
|
|
1460
|
+
if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
|
|
1461
|
+
if delta.content_delta:
|
|
1462
|
+
# For text parts, show token counter then render at end
|
|
1463
|
+
if event.index in text_parts:
|
|
1464
|
+
# Print banner on first content
|
|
1465
|
+
if event.index not in banner_printed:
|
|
1466
|
+
_print_response_banner()
|
|
1467
|
+
banner_printed.add(event.index)
|
|
1468
|
+
# Accumulate text for final markdown render
|
|
1469
|
+
text_buffer[event.index].append(delta.content_delta)
|
|
1470
|
+
# Count chunks received
|
|
1471
|
+
token_count[event.index] += 1
|
|
1472
|
+
# Update chunk counter in place (single line)
|
|
1473
|
+
count = token_count[event.index]
|
|
1474
|
+
console.print(
|
|
1475
|
+
f" ⏳ Receiving... {count} chunks ",
|
|
1476
|
+
end="\r",
|
|
1477
|
+
)
|
|
1478
|
+
else:
|
|
1479
|
+
# For thinking parts, stream immediately (dim)
|
|
1480
|
+
if event.index not in banner_printed:
|
|
1481
|
+
_print_thinking_banner()
|
|
1482
|
+
banner_printed.add(event.index)
|
|
1483
|
+
escaped = escape(delta.content_delta)
|
|
1484
|
+
console.print(f"[dim]{escaped}[/dim]", end="")
|
|
1485
|
+
elif isinstance(delta, ToolCallPartDelta):
|
|
1486
|
+
# For tool calls, count chunks received
|
|
1487
|
+
token_count[event.index] += 1
|
|
1488
|
+
# Get tool name if available
|
|
1489
|
+
tool_name = getattr(delta, "tool_name_delta", "")
|
|
1490
|
+
count = token_count[event.index]
|
|
1491
|
+
# Display with tool wrench icon and tool name
|
|
1492
|
+
if tool_name:
|
|
1493
|
+
console.print(
|
|
1494
|
+
f" 🔧 Calling {tool_name}... {count} chunks ",
|
|
1495
|
+
end="\r",
|
|
1496
|
+
)
|
|
1497
|
+
else:
|
|
1498
|
+
console.print(
|
|
1499
|
+
f" 🔧 Calling tool... {count} chunks ",
|
|
1500
|
+
end="\r",
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
# PartEndEvent - finish the streaming with a newline
|
|
1504
|
+
elif isinstance(event, PartEndEvent):
|
|
1505
|
+
if event.index in streaming_parts:
|
|
1506
|
+
# For text parts, clear counter line and render markdown
|
|
1507
|
+
if event.index in text_parts:
|
|
1508
|
+
# Clear the chunk counter line by printing spaces and returning
|
|
1509
|
+
console.print(" " * 50, end="\r")
|
|
1510
|
+
# Render the final markdown nicely
|
|
1511
|
+
if event.index in text_buffer:
|
|
1512
|
+
try:
|
|
1513
|
+
final_content = "".join(text_buffer[event.index])
|
|
1514
|
+
if final_content.strip():
|
|
1515
|
+
console.print(Markdown(final_content))
|
|
1516
|
+
except Exception:
|
|
1517
|
+
pass
|
|
1518
|
+
del text_buffer[event.index]
|
|
1519
|
+
# For tool parts, clear the chunk counter line
|
|
1520
|
+
elif event.index in tool_parts:
|
|
1521
|
+
# Clear the chunk counter line by printing spaces and returning
|
|
1522
|
+
console.print(" " * 50, end="\r")
|
|
1523
|
+
# For thinking parts, just print newline
|
|
1524
|
+
elif event.index in banner_printed:
|
|
1525
|
+
console.print() # Final newline after streaming
|
|
1526
|
+
|
|
1527
|
+
# Clean up token count
|
|
1528
|
+
token_count.pop(event.index, None)
|
|
1529
|
+
# Clean up all tracking sets
|
|
1530
|
+
streaming_parts.discard(event.index)
|
|
1531
|
+
thinking_parts.discard(event.index)
|
|
1532
|
+
text_parts.discard(event.index)
|
|
1533
|
+
tool_parts.discard(event.index)
|
|
1534
|
+
banner_printed.discard(event.index)
|
|
1535
|
+
|
|
1536
|
+
# Resume spinner if next part is NOT text/thinking/tool (avoid race condition)
|
|
1537
|
+
# If next part is None or handled differently, it's safe to resume
|
|
1538
|
+
# Note: spinner itself handles blank line before appearing
|
|
1539
|
+
from code_puppy.messaging.spinner import resume_all_spinners
|
|
1540
|
+
|
|
1541
|
+
next_kind = getattr(event, "next_part_kind", None)
|
|
1542
|
+
if next_kind not in ("text", "thinking", "tool-call"):
|
|
1543
|
+
resume_all_spinners()
|
|
1544
|
+
|
|
1545
|
+
# Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
|
|
1546
|
+
|
|
1258
1547
|
def _spawn_ctrl_x_key_listener(
|
|
1259
1548
|
self,
|
|
1260
1549
|
stop_event: threading.Event,
|
|
@@ -1409,6 +1698,7 @@ class BaseAgent(ABC):
|
|
|
1409
1698
|
*,
|
|
1410
1699
|
attachments: Optional[Sequence[BinaryContent]] = None,
|
|
1411
1700
|
link_attachments: Optional[Sequence[Union[ImageUrl, DocumentUrl]]] = None,
|
|
1701
|
+
output_type: Optional[Type[Any]] = None,
|
|
1412
1702
|
**kwargs,
|
|
1413
1703
|
) -> Any:
|
|
1414
1704
|
"""Run the agent with MCP servers, attachments, and full cancellation support.
|
|
@@ -1417,10 +1707,13 @@ class BaseAgent(ABC):
|
|
|
1417
1707
|
prompt: Primary user prompt text (may be empty when attachments present).
|
|
1418
1708
|
attachments: Local binary payloads (e.g., dragged images) to include.
|
|
1419
1709
|
link_attachments: Remote assets (image/document URLs) to include.
|
|
1710
|
+
output_type: Optional Pydantic model or type for structured output.
|
|
1711
|
+
When provided, creates a temporary agent configured to return
|
|
1712
|
+
this type instead of the default string output.
|
|
1420
1713
|
**kwargs: Additional arguments forwarded to `pydantic_ai.Agent.run`.
|
|
1421
1714
|
|
|
1422
1715
|
Returns:
|
|
1423
|
-
The agent's response.
|
|
1716
|
+
The agent's response (typed according to output_type if specified).
|
|
1424
1717
|
|
|
1425
1718
|
Raises:
|
|
1426
1719
|
asyncio.CancelledError: When execution is cancelled by user.
|
|
@@ -1444,10 +1737,17 @@ class BaseAgent(ABC):
|
|
|
1444
1737
|
pydantic_agent = (
|
|
1445
1738
|
self._code_generation_agent or self.reload_code_generation_agent()
|
|
1446
1739
|
)
|
|
1447
|
-
# Handle claude-code models: prepend system prompt to first user message
|
|
1448
|
-
from code_puppy.model_utils import is_claude_code_model
|
|
1449
1740
|
|
|
1450
|
-
|
|
1741
|
+
# If a custom output_type is specified, create a temporary agent with that type
|
|
1742
|
+
if output_type is not None:
|
|
1743
|
+
pydantic_agent = self._create_agent_with_output_type(output_type)
|
|
1744
|
+
|
|
1745
|
+
# Handle claude-code and chatgpt-codex models: prepend system prompt to first user message
|
|
1746
|
+
from code_puppy.model_utils import is_chatgpt_codex_model, is_claude_code_model
|
|
1747
|
+
|
|
1748
|
+
if is_claude_code_model(self.get_model_name()) or is_chatgpt_codex_model(
|
|
1749
|
+
self.get_model_name()
|
|
1750
|
+
):
|
|
1451
1751
|
if len(self.get_message_history()) == 0:
|
|
1452
1752
|
system_prompt = self.get_system_prompt()
|
|
1453
1753
|
puppy_rules = self.load_puppy_rules()
|
|
@@ -1511,6 +1811,7 @@ class BaseAgent(ABC):
|
|
|
1511
1811
|
prompt_payload,
|
|
1512
1812
|
message_history=self.get_message_history(),
|
|
1513
1813
|
usage_limits=usage_limits,
|
|
1814
|
+
event_stream_handler=self._event_stream_handler,
|
|
1514
1815
|
**kwargs,
|
|
1515
1816
|
)
|
|
1516
1817
|
finally:
|
|
@@ -1523,6 +1824,7 @@ class BaseAgent(ABC):
|
|
|
1523
1824
|
prompt_payload,
|
|
1524
1825
|
message_history=self.get_message_history(),
|
|
1525
1826
|
usage_limits=usage_limits,
|
|
1827
|
+
event_stream_handler=self._event_stream_handler,
|
|
1526
1828
|
**kwargs,
|
|
1527
1829
|
)
|
|
1528
1830
|
else:
|
|
@@ -1531,6 +1833,7 @@ class BaseAgent(ABC):
|
|
|
1531
1833
|
prompt_payload,
|
|
1532
1834
|
message_history=self.get_message_history(),
|
|
1533
1835
|
usage_limits=usage_limits,
|
|
1836
|
+
event_stream_handler=self._event_stream_handler,
|
|
1534
1837
|
**kwargs,
|
|
1535
1838
|
)
|
|
1536
1839
|
return result_
|
|
@@ -1635,7 +1938,12 @@ class BaseAgent(ABC):
|
|
|
1635
1938
|
def graceful_sigint_handler(_sig, _frame):
|
|
1636
1939
|
# When using keyboard-based cancel, SIGINT should be a no-op
|
|
1637
1940
|
# (just show a hint to user about the configured cancel key)
|
|
1941
|
+
# Also reset terminal to prevent bricking on Windows+uvx
|
|
1638
1942
|
from code_puppy.keymap import get_cancel_agent_display_name
|
|
1943
|
+
from code_puppy.terminal_utils import reset_windows_terminal_full
|
|
1944
|
+
|
|
1945
|
+
# Reset terminal state first to prevent bricking
|
|
1946
|
+
reset_windows_terminal_full()
|
|
1639
1947
|
|
|
1640
1948
|
cancel_key = get_cancel_agent_display_name()
|
|
1641
1949
|
emit_info(f"Use {cancel_key} to cancel the agent task.")
|