code-puppy 0.0.341__py3-none-any.whl → 0.0.348__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 +17 -248
- code_puppy/agents/event_stream_handler.py +257 -0
- code_puppy/cli_runner.py +4 -3
- code_puppy/command_line/add_model_menu.py +8 -9
- code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
- code_puppy/command_line/mcp/custom_server_form.py +54 -19
- code_puppy/command_line/mcp/custom_server_installer.py +8 -9
- code_puppy/command_line/mcp/handler.py +0 -2
- code_puppy/command_line/mcp/help_command.py +1 -5
- code_puppy/command_line/mcp/start_command.py +36 -18
- code_puppy/command_line/onboarding_slides.py +0 -1
- code_puppy/command_line/prompt_toolkit_completion.py +16 -10
- code_puppy/command_line/utils.py +54 -0
- code_puppy/mcp_/async_lifecycle.py +35 -4
- code_puppy/mcp_/managed_server.py +49 -20
- code_puppy/mcp_/manager.py +81 -52
- code_puppy/messaging/message_queue.py +11 -23
- code_puppy/tools/agent_tools.py +66 -13
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/METADATA +1 -1
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/RECORD +25 -25
- code_puppy/command_line/mcp/add_command.py +0 -170
- {code_puppy-0.0.341.data → code_puppy-0.0.348.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.341.data → code_puppy-0.0.348.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/licenses/LICENSE +0 -0
code_puppy/agents/base_agent.py
CHANGED
|
@@ -7,7 +7,6 @@ import signal
|
|
|
7
7
|
import threading
|
|
8
8
|
import uuid
|
|
9
9
|
from abc import ABC, abstractmethod
|
|
10
|
-
from collections.abc import AsyncIterable
|
|
11
10
|
from typing import (
|
|
12
11
|
Any,
|
|
13
12
|
Callable,
|
|
@@ -30,7 +29,6 @@ from pydantic_ai import (
|
|
|
30
29
|
BinaryContent,
|
|
31
30
|
DocumentUrl,
|
|
32
31
|
ImageUrl,
|
|
33
|
-
PartEndEvent,
|
|
34
32
|
RunContext,
|
|
35
33
|
UsageLimitExceeded,
|
|
36
34
|
UsageLimits,
|
|
@@ -48,6 +46,8 @@ from pydantic_ai.messages import (
|
|
|
48
46
|
)
|
|
49
47
|
from rich.text import Text
|
|
50
48
|
|
|
49
|
+
from code_puppy.agents.event_stream_handler import event_stream_handler
|
|
50
|
+
|
|
51
51
|
# Consolidated relative imports
|
|
52
52
|
from code_puppy.config import (
|
|
53
53
|
get_agent_pinned_model,
|
|
@@ -100,9 +100,6 @@ class BaseAgent(ABC):
|
|
|
100
100
|
# Cache for MCP tool definitions (for token estimation)
|
|
101
101
|
# This is populated after the first successful run when MCP tools are retrieved
|
|
102
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
|
|
106
103
|
|
|
107
104
|
@property
|
|
108
105
|
@abstractmethod
|
|
@@ -1233,9 +1230,12 @@ class BaseAgent(ABC):
|
|
|
1233
1230
|
agent_tools = self.get_available_tools()
|
|
1234
1231
|
register_tools_for_agent(agent_without_mcp, agent_tools)
|
|
1235
1232
|
|
|
1236
|
-
# Wrap with DBOS
|
|
1233
|
+
# Wrap with DBOS - pass event_stream_handler at construction time
|
|
1234
|
+
# so DBOSModel gets the handler for streaming output
|
|
1237
1235
|
dbos_agent = DBOSAgent(
|
|
1238
|
-
agent_without_mcp,
|
|
1236
|
+
agent_without_mcp,
|
|
1237
|
+
name=f"{self.name}-{_reload_count}",
|
|
1238
|
+
event_stream_handler=event_stream_handler,
|
|
1239
1239
|
)
|
|
1240
1240
|
self.pydantic_agent = dbos_agent
|
|
1241
1241
|
self._code_generation_agent = dbos_agent
|
|
@@ -1314,8 +1314,11 @@ class BaseAgent(ABC):
|
|
|
1314
1314
|
)
|
|
1315
1315
|
agent_tools = self.get_available_tools()
|
|
1316
1316
|
register_tools_for_agent(temp_agent, agent_tools)
|
|
1317
|
+
# Pass event_stream_handler at construction time for streaming output
|
|
1317
1318
|
dbos_agent = DBOSAgent(
|
|
1318
|
-
temp_agent,
|
|
1319
|
+
temp_agent,
|
|
1320
|
+
name=f"{self.name}-structured-{_reload_count}",
|
|
1321
|
+
event_stream_handler=event_stream_handler,
|
|
1319
1322
|
)
|
|
1320
1323
|
return dbos_agent
|
|
1321
1324
|
else:
|
|
@@ -1357,241 +1360,6 @@ class BaseAgent(ABC):
|
|
|
1357
1360
|
self.set_message_history(result_messages_filtered_empty_thinking)
|
|
1358
1361
|
return self.get_message_history()
|
|
1359
1362
|
|
|
1360
|
-
async def _event_stream_handler(
|
|
1361
|
-
self, ctx: RunContext, events: AsyncIterable[Any]
|
|
1362
|
-
) -> None:
|
|
1363
|
-
"""Handle streaming events from the agent run.
|
|
1364
|
-
|
|
1365
|
-
This method processes streaming events and emits TextPart, ThinkingPart,
|
|
1366
|
-
and ToolCallPart content with styled banners/tokens as they stream in.
|
|
1367
|
-
|
|
1368
|
-
Args:
|
|
1369
|
-
ctx: The run context.
|
|
1370
|
-
events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
|
|
1371
|
-
"""
|
|
1372
|
-
from pydantic_ai import PartDeltaEvent, PartStartEvent
|
|
1373
|
-
from pydantic_ai.messages import (
|
|
1374
|
-
TextPartDelta,
|
|
1375
|
-
ThinkingPartDelta,
|
|
1376
|
-
ToolCallPartDelta,
|
|
1377
|
-
)
|
|
1378
|
-
from rich.console import Console
|
|
1379
|
-
from rich.markup import escape
|
|
1380
|
-
|
|
1381
|
-
from code_puppy.messaging.spinner import pause_all_spinners
|
|
1382
|
-
|
|
1383
|
-
# IMPORTANT: Use the shared console (set by cli_runner) to avoid conflicts
|
|
1384
|
-
# with the spinner's Live display. Multiple Console instances with separate
|
|
1385
|
-
# Live displays cause cursor positioning chaos and line duplication.
|
|
1386
|
-
if self._console is not None:
|
|
1387
|
-
console = self._console
|
|
1388
|
-
else:
|
|
1389
|
-
# Fallback if console not set (shouldn't happen in normal use)
|
|
1390
|
-
console = Console()
|
|
1391
|
-
|
|
1392
|
-
# Track which part indices we're currently streaming (for Text/Thinking/Tool parts)
|
|
1393
|
-
streaming_parts: set[int] = set()
|
|
1394
|
-
thinking_parts: set[int] = (
|
|
1395
|
-
set()
|
|
1396
|
-
) # Track which parts are thinking (for dim style)
|
|
1397
|
-
text_parts: set[int] = set() # Track which parts are text
|
|
1398
|
-
tool_parts: set[int] = set() # Track which parts are tool calls
|
|
1399
|
-
banner_printed: set[int] = set() # Track if banner was already printed
|
|
1400
|
-
token_count: dict[int, int] = {} # Track token count per text/tool part
|
|
1401
|
-
did_stream_anything = False # Track if we streamed any content
|
|
1402
|
-
|
|
1403
|
-
# Termflow streaming state for text parts
|
|
1404
|
-
from termflow import Parser as TermflowParser
|
|
1405
|
-
from termflow import Renderer as TermflowRenderer
|
|
1406
|
-
|
|
1407
|
-
termflow_parsers: dict[int, TermflowParser] = {}
|
|
1408
|
-
termflow_renderers: dict[int, TermflowRenderer] = {}
|
|
1409
|
-
termflow_line_buffers: dict[int, str] = {} # Buffer incomplete lines
|
|
1410
|
-
|
|
1411
|
-
def _print_thinking_banner() -> None:
|
|
1412
|
-
"""Print the THINKING banner with spinner pause and line clear."""
|
|
1413
|
-
nonlocal did_stream_anything
|
|
1414
|
-
import time
|
|
1415
|
-
|
|
1416
|
-
from code_puppy.config import get_banner_color
|
|
1417
|
-
|
|
1418
|
-
pause_all_spinners()
|
|
1419
|
-
time.sleep(0.1) # Delay to let spinner fully clear
|
|
1420
|
-
# Clear line and print newline before banner
|
|
1421
|
-
console.print(" " * 50, end="\r")
|
|
1422
|
-
console.print() # Newline before banner
|
|
1423
|
-
# Bold banner with configurable color and lightning bolt
|
|
1424
|
-
thinking_color = get_banner_color("thinking")
|
|
1425
|
-
console.print(
|
|
1426
|
-
Text.from_markup(
|
|
1427
|
-
f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]⚡ "
|
|
1428
|
-
),
|
|
1429
|
-
end="",
|
|
1430
|
-
)
|
|
1431
|
-
did_stream_anything = True
|
|
1432
|
-
|
|
1433
|
-
def _print_response_banner() -> None:
|
|
1434
|
-
"""Print the AGENT RESPONSE banner with spinner pause and line clear."""
|
|
1435
|
-
nonlocal did_stream_anything
|
|
1436
|
-
import time
|
|
1437
|
-
|
|
1438
|
-
from code_puppy.config import get_banner_color
|
|
1439
|
-
|
|
1440
|
-
pause_all_spinners()
|
|
1441
|
-
time.sleep(0.1) # Delay to let spinner fully clear
|
|
1442
|
-
# Clear line and print newline before banner
|
|
1443
|
-
console.print(" " * 50, end="\r")
|
|
1444
|
-
console.print() # Newline before banner
|
|
1445
|
-
response_color = get_banner_color("agent_response")
|
|
1446
|
-
console.print(
|
|
1447
|
-
Text.from_markup(
|
|
1448
|
-
f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
|
|
1449
|
-
)
|
|
1450
|
-
)
|
|
1451
|
-
did_stream_anything = True
|
|
1452
|
-
|
|
1453
|
-
async for event in events:
|
|
1454
|
-
# PartStartEvent - register the part but defer banner until content arrives
|
|
1455
|
-
if isinstance(event, PartStartEvent):
|
|
1456
|
-
part = event.part
|
|
1457
|
-
if isinstance(part, ThinkingPart):
|
|
1458
|
-
streaming_parts.add(event.index)
|
|
1459
|
-
thinking_parts.add(event.index)
|
|
1460
|
-
# If there's initial content, print banner + content now
|
|
1461
|
-
if part.content and part.content.strip():
|
|
1462
|
-
_print_thinking_banner()
|
|
1463
|
-
escaped = escape(part.content)
|
|
1464
|
-
console.print(f"[dim]{escaped}[/dim]", end="")
|
|
1465
|
-
banner_printed.add(event.index)
|
|
1466
|
-
elif isinstance(part, TextPart):
|
|
1467
|
-
streaming_parts.add(event.index)
|
|
1468
|
-
text_parts.add(event.index)
|
|
1469
|
-
# Initialize termflow streaming for this text part
|
|
1470
|
-
termflow_parsers[event.index] = TermflowParser()
|
|
1471
|
-
termflow_renderers[event.index] = TermflowRenderer(
|
|
1472
|
-
output=console.file, width=console.width
|
|
1473
|
-
)
|
|
1474
|
-
termflow_line_buffers[event.index] = ""
|
|
1475
|
-
# Handle initial content if present
|
|
1476
|
-
if part.content and part.content.strip():
|
|
1477
|
-
_print_response_banner()
|
|
1478
|
-
banner_printed.add(event.index)
|
|
1479
|
-
termflow_line_buffers[event.index] = part.content
|
|
1480
|
-
elif isinstance(part, ToolCallPart):
|
|
1481
|
-
streaming_parts.add(event.index)
|
|
1482
|
-
tool_parts.add(event.index)
|
|
1483
|
-
token_count[event.index] = 0 # Initialize token counter
|
|
1484
|
-
# Track tool name for display
|
|
1485
|
-
banner_printed.add(
|
|
1486
|
-
event.index
|
|
1487
|
-
) # Use banner_printed to track if we've shown tool info
|
|
1488
|
-
|
|
1489
|
-
# PartDeltaEvent - stream the content as it arrives
|
|
1490
|
-
elif isinstance(event, PartDeltaEvent):
|
|
1491
|
-
if event.index in streaming_parts:
|
|
1492
|
-
delta = event.delta
|
|
1493
|
-
if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
|
|
1494
|
-
if delta.content_delta:
|
|
1495
|
-
# For text parts, stream markdown with termflow
|
|
1496
|
-
if event.index in text_parts:
|
|
1497
|
-
# Print banner on first content
|
|
1498
|
-
if event.index not in banner_printed:
|
|
1499
|
-
_print_response_banner()
|
|
1500
|
-
banner_printed.add(event.index)
|
|
1501
|
-
|
|
1502
|
-
# Add content to line buffer
|
|
1503
|
-
termflow_line_buffers[event.index] += (
|
|
1504
|
-
delta.content_delta
|
|
1505
|
-
)
|
|
1506
|
-
|
|
1507
|
-
# Process complete lines
|
|
1508
|
-
parser = termflow_parsers[event.index]
|
|
1509
|
-
renderer = termflow_renderers[event.index]
|
|
1510
|
-
buffer = termflow_line_buffers[event.index]
|
|
1511
|
-
|
|
1512
|
-
while "\n" in buffer:
|
|
1513
|
-
line, buffer = buffer.split("\n", 1)
|
|
1514
|
-
events_to_render = parser.parse_line(line)
|
|
1515
|
-
renderer.render_all(events_to_render)
|
|
1516
|
-
|
|
1517
|
-
termflow_line_buffers[event.index] = buffer
|
|
1518
|
-
else:
|
|
1519
|
-
# For thinking parts, stream immediately (dim)
|
|
1520
|
-
if event.index not in banner_printed:
|
|
1521
|
-
_print_thinking_banner()
|
|
1522
|
-
banner_printed.add(event.index)
|
|
1523
|
-
escaped = escape(delta.content_delta)
|
|
1524
|
-
console.print(f"[dim]{escaped}[/dim]", end="")
|
|
1525
|
-
elif isinstance(delta, ToolCallPartDelta):
|
|
1526
|
-
# For tool calls, count chunks received
|
|
1527
|
-
token_count[event.index] += 1
|
|
1528
|
-
# Get tool name if available
|
|
1529
|
-
tool_name = getattr(delta, "tool_name_delta", "")
|
|
1530
|
-
count = token_count[event.index]
|
|
1531
|
-
# Display with tool wrench icon and tool name
|
|
1532
|
-
if tool_name:
|
|
1533
|
-
console.print(
|
|
1534
|
-
f" 🔧 Calling {tool_name}... {count} chunks ",
|
|
1535
|
-
end="\r",
|
|
1536
|
-
)
|
|
1537
|
-
else:
|
|
1538
|
-
console.print(
|
|
1539
|
-
f" 🔧 Calling tool... {count} chunks ",
|
|
1540
|
-
end="\r",
|
|
1541
|
-
)
|
|
1542
|
-
|
|
1543
|
-
# PartEndEvent - finish the streaming with a newline
|
|
1544
|
-
elif isinstance(event, PartEndEvent):
|
|
1545
|
-
if event.index in streaming_parts:
|
|
1546
|
-
# For text parts, finalize termflow rendering
|
|
1547
|
-
if event.index in text_parts:
|
|
1548
|
-
# Render any remaining buffered content
|
|
1549
|
-
if event.index in termflow_parsers:
|
|
1550
|
-
parser = termflow_parsers[event.index]
|
|
1551
|
-
renderer = termflow_renderers[event.index]
|
|
1552
|
-
remaining = termflow_line_buffers.get(event.index, "")
|
|
1553
|
-
|
|
1554
|
-
# Parse and render any remaining partial line
|
|
1555
|
-
if remaining.strip():
|
|
1556
|
-
events_to_render = parser.parse_line(remaining)
|
|
1557
|
-
renderer.render_all(events_to_render)
|
|
1558
|
-
|
|
1559
|
-
# Finalize the parser to close any open blocks
|
|
1560
|
-
final_events = parser.finalize()
|
|
1561
|
-
renderer.render_all(final_events)
|
|
1562
|
-
|
|
1563
|
-
# Clean up termflow state
|
|
1564
|
-
del termflow_parsers[event.index]
|
|
1565
|
-
del termflow_renderers[event.index]
|
|
1566
|
-
del termflow_line_buffers[event.index]
|
|
1567
|
-
# For tool parts, clear the chunk counter line
|
|
1568
|
-
elif event.index in tool_parts:
|
|
1569
|
-
# Clear the chunk counter line by printing spaces and returning
|
|
1570
|
-
console.print(" " * 50, end="\r")
|
|
1571
|
-
# For thinking parts, just print newline
|
|
1572
|
-
elif event.index in banner_printed:
|
|
1573
|
-
console.print() # Final newline after streaming
|
|
1574
|
-
|
|
1575
|
-
# Clean up token count
|
|
1576
|
-
token_count.pop(event.index, None)
|
|
1577
|
-
# Clean up all tracking sets
|
|
1578
|
-
streaming_parts.discard(event.index)
|
|
1579
|
-
thinking_parts.discard(event.index)
|
|
1580
|
-
text_parts.discard(event.index)
|
|
1581
|
-
tool_parts.discard(event.index)
|
|
1582
|
-
banner_printed.discard(event.index)
|
|
1583
|
-
|
|
1584
|
-
# Resume spinner if next part is NOT text/thinking/tool (avoid race condition)
|
|
1585
|
-
# If next part is None or handled differently, it's safe to resume
|
|
1586
|
-
# Note: spinner itself handles blank line before appearing
|
|
1587
|
-
from code_puppy.messaging.spinner import resume_all_spinners
|
|
1588
|
-
|
|
1589
|
-
next_kind = getattr(event, "next_part_kind", None)
|
|
1590
|
-
if next_kind not in ("text", "thinking", "tool-call"):
|
|
1591
|
-
resume_all_spinners()
|
|
1592
|
-
|
|
1593
|
-
# Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
|
|
1594
|
-
|
|
1595
1363
|
def _spawn_ctrl_x_key_listener(
|
|
1596
1364
|
self,
|
|
1597
1365
|
stop_event: threading.Event,
|
|
@@ -1859,32 +1627,33 @@ class BaseAgent(ABC):
|
|
|
1859
1627
|
prompt_payload,
|
|
1860
1628
|
message_history=self.get_message_history(),
|
|
1861
1629
|
usage_limits=usage_limits,
|
|
1862
|
-
event_stream_handler=
|
|
1630
|
+
event_stream_handler=event_stream_handler,
|
|
1863
1631
|
**kwargs,
|
|
1864
1632
|
)
|
|
1633
|
+
return result_
|
|
1865
1634
|
finally:
|
|
1866
1635
|
# Always restore original toolsets
|
|
1867
1636
|
pydantic_agent._toolsets = original_toolsets
|
|
1868
1637
|
elif get_use_dbos():
|
|
1869
|
-
# DBOS without MCP servers
|
|
1870
1638
|
with SetWorkflowID(group_id):
|
|
1871
1639
|
result_ = await pydantic_agent.run(
|
|
1872
1640
|
prompt_payload,
|
|
1873
1641
|
message_history=self.get_message_history(),
|
|
1874
1642
|
usage_limits=usage_limits,
|
|
1875
|
-
event_stream_handler=
|
|
1643
|
+
event_stream_handler=event_stream_handler,
|
|
1876
1644
|
**kwargs,
|
|
1877
1645
|
)
|
|
1646
|
+
return result_
|
|
1878
1647
|
else:
|
|
1879
1648
|
# Non-DBOS path (MCP servers are already included)
|
|
1880
1649
|
result_ = await pydantic_agent.run(
|
|
1881
1650
|
prompt_payload,
|
|
1882
1651
|
message_history=self.get_message_history(),
|
|
1883
1652
|
usage_limits=usage_limits,
|
|
1884
|
-
event_stream_handler=
|
|
1653
|
+
event_stream_handler=event_stream_handler,
|
|
1885
1654
|
**kwargs,
|
|
1886
1655
|
)
|
|
1887
|
-
|
|
1656
|
+
return result_
|
|
1888
1657
|
except* UsageLimitExceeded as ule:
|
|
1889
1658
|
emit_info(f"Usage limit exceeded: {str(ule)}", group_id=group_id)
|
|
1890
1659
|
emit_info(
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Event stream handler for processing streaming events from agent runs."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterable
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic_ai import PartDeltaEvent, PartEndEvent, PartStartEvent, RunContext
|
|
7
|
+
from pydantic_ai.messages import (
|
|
8
|
+
TextPart,
|
|
9
|
+
TextPartDelta,
|
|
10
|
+
ThinkingPart,
|
|
11
|
+
ThinkingPartDelta,
|
|
12
|
+
ToolCallPart,
|
|
13
|
+
ToolCallPartDelta,
|
|
14
|
+
)
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.markup import escape
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from code_puppy.config import get_banner_color
|
|
20
|
+
from code_puppy.messaging.spinner import pause_all_spinners, resume_all_spinners
|
|
21
|
+
|
|
22
|
+
# Module-level console for streaming output
|
|
23
|
+
# Set via set_streaming_console() to share console with spinner
|
|
24
|
+
_streaming_console: Optional[Console] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def set_streaming_console(console: Optional[Console]) -> None:
|
|
28
|
+
"""Set the console used for streaming output.
|
|
29
|
+
|
|
30
|
+
This should be called with the same console used by the spinner
|
|
31
|
+
to avoid Live display conflicts that cause line duplication.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
console: The Rich console to use, or None to use a fallback.
|
|
35
|
+
"""
|
|
36
|
+
global _streaming_console
|
|
37
|
+
_streaming_console = console
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_streaming_console() -> Console:
|
|
41
|
+
"""Get the console for streaming output.
|
|
42
|
+
|
|
43
|
+
Returns the configured console or creates a fallback Console.
|
|
44
|
+
"""
|
|
45
|
+
if _streaming_console is not None:
|
|
46
|
+
return _streaming_console
|
|
47
|
+
return Console()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def event_stream_handler(
|
|
51
|
+
ctx: RunContext,
|
|
52
|
+
events: AsyncIterable[Any],
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Handle streaming events from the agent run.
|
|
55
|
+
|
|
56
|
+
This function processes streaming events and emits TextPart, ThinkingPart,
|
|
57
|
+
and ToolCallPart content with styled banners/tokens as they stream in.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
ctx: The run context.
|
|
61
|
+
events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
|
|
62
|
+
"""
|
|
63
|
+
import time
|
|
64
|
+
|
|
65
|
+
from termflow import Parser as TermflowParser
|
|
66
|
+
from termflow import Renderer as TermflowRenderer
|
|
67
|
+
|
|
68
|
+
# Use the module-level console (set via set_streaming_console)
|
|
69
|
+
console = get_streaming_console()
|
|
70
|
+
|
|
71
|
+
# Track which part indices we're currently streaming (for Text/Thinking/Tool parts)
|
|
72
|
+
streaming_parts: set[int] = set()
|
|
73
|
+
thinking_parts: set[int] = set() # Track which parts are thinking (for dim style)
|
|
74
|
+
text_parts: set[int] = set() # Track which parts are text
|
|
75
|
+
tool_parts: set[int] = set() # Track which parts are tool calls
|
|
76
|
+
banner_printed: set[int] = set() # Track if banner was already printed
|
|
77
|
+
token_count: dict[int, int] = {} # Track token count per text/tool part
|
|
78
|
+
did_stream_anything = False # Track if we streamed any content
|
|
79
|
+
|
|
80
|
+
# Termflow streaming state for text parts
|
|
81
|
+
termflow_parsers: dict[int, TermflowParser] = {}
|
|
82
|
+
termflow_renderers: dict[int, TermflowRenderer] = {}
|
|
83
|
+
termflow_line_buffers: dict[int, str] = {} # Buffer incomplete lines
|
|
84
|
+
|
|
85
|
+
def _print_thinking_banner() -> None:
|
|
86
|
+
"""Print the THINKING banner with spinner pause and line clear."""
|
|
87
|
+
nonlocal did_stream_anything
|
|
88
|
+
|
|
89
|
+
pause_all_spinners()
|
|
90
|
+
time.sleep(0.1) # Delay to let spinner fully clear
|
|
91
|
+
# Clear line and print newline before banner
|
|
92
|
+
console.print(" " * 50, end="\r")
|
|
93
|
+
console.print() # Newline before banner
|
|
94
|
+
# Bold banner with configurable color and lightning bolt
|
|
95
|
+
thinking_color = get_banner_color("thinking")
|
|
96
|
+
console.print(
|
|
97
|
+
Text.from_markup(
|
|
98
|
+
f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]\u26a1 "
|
|
99
|
+
),
|
|
100
|
+
end="",
|
|
101
|
+
)
|
|
102
|
+
did_stream_anything = True
|
|
103
|
+
|
|
104
|
+
def _print_response_banner() -> None:
|
|
105
|
+
"""Print the AGENT RESPONSE banner with spinner pause and line clear."""
|
|
106
|
+
nonlocal did_stream_anything
|
|
107
|
+
|
|
108
|
+
pause_all_spinners()
|
|
109
|
+
time.sleep(0.1) # Delay to let spinner fully clear
|
|
110
|
+
# Clear line and print newline before banner
|
|
111
|
+
console.print(" " * 50, end="\r")
|
|
112
|
+
console.print() # Newline before banner
|
|
113
|
+
response_color = get_banner_color("agent_response")
|
|
114
|
+
console.print(
|
|
115
|
+
Text.from_markup(
|
|
116
|
+
f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
did_stream_anything = True
|
|
120
|
+
|
|
121
|
+
async for event in events:
|
|
122
|
+
# PartStartEvent - register the part but defer banner until content arrives
|
|
123
|
+
if isinstance(event, PartStartEvent):
|
|
124
|
+
part = event.part
|
|
125
|
+
if isinstance(part, ThinkingPart):
|
|
126
|
+
streaming_parts.add(event.index)
|
|
127
|
+
thinking_parts.add(event.index)
|
|
128
|
+
# If there's initial content, print banner + content now
|
|
129
|
+
if part.content and part.content.strip():
|
|
130
|
+
_print_thinking_banner()
|
|
131
|
+
escaped = escape(part.content)
|
|
132
|
+
console.print(f"[dim]{escaped}[/dim]", end="")
|
|
133
|
+
banner_printed.add(event.index)
|
|
134
|
+
elif isinstance(part, TextPart):
|
|
135
|
+
streaming_parts.add(event.index)
|
|
136
|
+
text_parts.add(event.index)
|
|
137
|
+
# Initialize termflow streaming for this text part
|
|
138
|
+
termflow_parsers[event.index] = TermflowParser()
|
|
139
|
+
termflow_renderers[event.index] = TermflowRenderer(
|
|
140
|
+
output=console.file, width=console.width
|
|
141
|
+
)
|
|
142
|
+
termflow_line_buffers[event.index] = ""
|
|
143
|
+
# Handle initial content if present
|
|
144
|
+
if part.content and part.content.strip():
|
|
145
|
+
_print_response_banner()
|
|
146
|
+
banner_printed.add(event.index)
|
|
147
|
+
termflow_line_buffers[event.index] = part.content
|
|
148
|
+
elif isinstance(part, ToolCallPart):
|
|
149
|
+
streaming_parts.add(event.index)
|
|
150
|
+
tool_parts.add(event.index)
|
|
151
|
+
token_count[event.index] = 0 # Initialize token counter
|
|
152
|
+
# Track tool name for display
|
|
153
|
+
banner_printed.add(
|
|
154
|
+
event.index
|
|
155
|
+
) # Use banner_printed to track if we've shown tool info
|
|
156
|
+
|
|
157
|
+
# PartDeltaEvent - stream the content as it arrives
|
|
158
|
+
elif isinstance(event, PartDeltaEvent):
|
|
159
|
+
if event.index in streaming_parts:
|
|
160
|
+
delta = event.delta
|
|
161
|
+
if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
|
|
162
|
+
if delta.content_delta:
|
|
163
|
+
# For text parts, stream markdown with termflow
|
|
164
|
+
if event.index in text_parts:
|
|
165
|
+
# Print banner on first content
|
|
166
|
+
if event.index not in banner_printed:
|
|
167
|
+
_print_response_banner()
|
|
168
|
+
banner_printed.add(event.index)
|
|
169
|
+
|
|
170
|
+
# Add content to line buffer
|
|
171
|
+
termflow_line_buffers[event.index] += delta.content_delta
|
|
172
|
+
|
|
173
|
+
# Process complete lines
|
|
174
|
+
parser = termflow_parsers[event.index]
|
|
175
|
+
renderer = termflow_renderers[event.index]
|
|
176
|
+
buffer = termflow_line_buffers[event.index]
|
|
177
|
+
|
|
178
|
+
while "\n" in buffer:
|
|
179
|
+
line, buffer = buffer.split("\n", 1)
|
|
180
|
+
events_to_render = parser.parse_line(line)
|
|
181
|
+
renderer.render_all(events_to_render)
|
|
182
|
+
|
|
183
|
+
termflow_line_buffers[event.index] = buffer
|
|
184
|
+
else:
|
|
185
|
+
# For thinking parts, stream immediately (dim)
|
|
186
|
+
if event.index not in banner_printed:
|
|
187
|
+
_print_thinking_banner()
|
|
188
|
+
banner_printed.add(event.index)
|
|
189
|
+
escaped = escape(delta.content_delta)
|
|
190
|
+
console.print(f"[dim]{escaped}[/dim]", end="")
|
|
191
|
+
elif isinstance(delta, ToolCallPartDelta):
|
|
192
|
+
# For tool calls, count chunks received
|
|
193
|
+
token_count[event.index] += 1
|
|
194
|
+
# Get tool name if available
|
|
195
|
+
tool_name = getattr(delta, "tool_name_delta", "")
|
|
196
|
+
count = token_count[event.index]
|
|
197
|
+
# Display with tool wrench icon and tool name
|
|
198
|
+
if tool_name:
|
|
199
|
+
console.print(
|
|
200
|
+
f" \U0001f527 Calling {tool_name}... {count} chunks ",
|
|
201
|
+
end="\r",
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
console.print(
|
|
205
|
+
f" \U0001f527 Calling tool... {count} chunks ",
|
|
206
|
+
end="\r",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# PartEndEvent - finish the streaming with a newline
|
|
210
|
+
elif isinstance(event, PartEndEvent):
|
|
211
|
+
if event.index in streaming_parts:
|
|
212
|
+
# For text parts, finalize termflow rendering
|
|
213
|
+
if event.index in text_parts:
|
|
214
|
+
# Render any remaining buffered content
|
|
215
|
+
if event.index in termflow_parsers:
|
|
216
|
+
parser = termflow_parsers[event.index]
|
|
217
|
+
renderer = termflow_renderers[event.index]
|
|
218
|
+
remaining = termflow_line_buffers.get(event.index, "")
|
|
219
|
+
|
|
220
|
+
# Parse and render any remaining partial line
|
|
221
|
+
if remaining.strip():
|
|
222
|
+
events_to_render = parser.parse_line(remaining)
|
|
223
|
+
renderer.render_all(events_to_render)
|
|
224
|
+
|
|
225
|
+
# Finalize the parser to close any open blocks
|
|
226
|
+
final_events = parser.finalize()
|
|
227
|
+
renderer.render_all(final_events)
|
|
228
|
+
|
|
229
|
+
# Clean up termflow state
|
|
230
|
+
del termflow_parsers[event.index]
|
|
231
|
+
del termflow_renderers[event.index]
|
|
232
|
+
del termflow_line_buffers[event.index]
|
|
233
|
+
# For tool parts, clear the chunk counter line
|
|
234
|
+
elif event.index in tool_parts:
|
|
235
|
+
# Clear the chunk counter line by printing spaces and returning
|
|
236
|
+
console.print(" " * 50, end="\r")
|
|
237
|
+
# For thinking parts, just print newline
|
|
238
|
+
elif event.index in banner_printed:
|
|
239
|
+
console.print() # Final newline after streaming
|
|
240
|
+
|
|
241
|
+
# Clean up token count
|
|
242
|
+
token_count.pop(event.index, None)
|
|
243
|
+
# Clean up all tracking sets
|
|
244
|
+
streaming_parts.discard(event.index)
|
|
245
|
+
thinking_parts.discard(event.index)
|
|
246
|
+
text_parts.discard(event.index)
|
|
247
|
+
tool_parts.discard(event.index)
|
|
248
|
+
banner_printed.discard(event.index)
|
|
249
|
+
|
|
250
|
+
# Resume spinner if next part is NOT text/thinking/tool (avoid race condition)
|
|
251
|
+
# If next part is None or handled differently, it's safe to resume
|
|
252
|
+
# Note: spinner itself handles blank line before appearing
|
|
253
|
+
next_kind = getattr(event, "next_part_kind", None)
|
|
254
|
+
if next_kind not in ("text", "thinking", "tool-call"):
|
|
255
|
+
resume_all_spinners()
|
|
256
|
+
|
|
257
|
+
# Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
|
code_puppy/cli_runner.py
CHANGED
|
@@ -827,11 +827,12 @@ async def run_prompt_with_attachments(
|
|
|
827
827
|
|
|
828
828
|
link_attachments = [link.url_part for link in processed_prompt.link_attachments]
|
|
829
829
|
|
|
830
|
-
# IMPORTANT: Set the shared console
|
|
830
|
+
# IMPORTANT: Set the shared console for streaming output so it
|
|
831
831
|
# uses the same console as the spinner. This prevents Live display conflicts
|
|
832
832
|
# that cause line duplication during markdown streaming.
|
|
833
|
-
|
|
834
|
-
|
|
833
|
+
from code_puppy.agents.event_stream_handler import set_streaming_console
|
|
834
|
+
|
|
835
|
+
set_streaming_console(spinner_console)
|
|
835
836
|
|
|
836
837
|
# Create the agent task first so we can track and cancel it
|
|
837
838
|
agent_task = asyncio.create_task(
|
|
@@ -17,6 +17,7 @@ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
|
|
|
17
17
|
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
18
18
|
from prompt_toolkit.widgets import Frame
|
|
19
19
|
|
|
20
|
+
from code_puppy.command_line.utils import safe_input
|
|
20
21
|
from code_puppy.config import EXTRA_MODELS_FILE, set_config_value
|
|
21
22
|
from code_puppy.messaging import emit_error, emit_info, emit_warning
|
|
22
23
|
from code_puppy.models_dev_parser import ModelInfo, ModelsDevRegistry, ProviderInfo
|
|
@@ -724,8 +725,8 @@ class AddModelMenu:
|
|
|
724
725
|
emit_info(f" {hint}")
|
|
725
726
|
|
|
726
727
|
try:
|
|
727
|
-
# Use
|
|
728
|
-
value =
|
|
728
|
+
# Use safe_input for cross-platform compatibility (Windows fix)
|
|
729
|
+
value = safe_input(f" Enter {env_var} (or press Enter to skip): ")
|
|
729
730
|
|
|
730
731
|
if not value:
|
|
731
732
|
emit_warning(
|
|
@@ -785,7 +786,7 @@ class AddModelMenu:
|
|
|
785
786
|
)
|
|
786
787
|
|
|
787
788
|
try:
|
|
788
|
-
model_name =
|
|
789
|
+
model_name = safe_input(" Model ID: ")
|
|
789
790
|
|
|
790
791
|
if not model_name:
|
|
791
792
|
emit_warning("No model name provided, cancelled.")
|
|
@@ -795,7 +796,7 @@ class AddModelMenu:
|
|
|
795
796
|
emit_info("\n Enter the context window size (in tokens).")
|
|
796
797
|
emit_info(" Common sizes: 8192, 32768, 128000, 200000, 1000000\n")
|
|
797
798
|
|
|
798
|
-
context_input =
|
|
799
|
+
context_input = safe_input(" Context size [128000]: ")
|
|
799
800
|
|
|
800
801
|
if not context_input:
|
|
801
802
|
context_length = 128000 # Default
|
|
@@ -1045,11 +1046,9 @@ class AddModelMenu:
|
|
|
1045
1046
|
f" It will be very limited for coding tasks."
|
|
1046
1047
|
)
|
|
1047
1048
|
try:
|
|
1048
|
-
confirm = (
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
.lower()
|
|
1052
|
-
)
|
|
1049
|
+
confirm = safe_input(
|
|
1050
|
+
"\n Are you sure you want to add this model? (y/N): "
|
|
1051
|
+
).lower()
|
|
1053
1052
|
if confirm not in ("y", "yes"):
|
|
1054
1053
|
emit_info("Model addition cancelled.")
|
|
1055
1054
|
return False
|