Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Merge pull request #50 from huggingface/fix/ctrl-c-and-streaming-rework
Browse files- agent/main.py +186 -38
- agent/utils/terminal_display.py +42 -10
agent/main.py
CHANGED
|
@@ -10,6 +10,7 @@ import argparse
|
|
| 10 |
import asyncio
|
| 11 |
import json
|
| 12 |
import os
|
|
|
|
| 13 |
import sys
|
| 14 |
import time
|
| 15 |
from dataclasses import dataclass
|
|
@@ -268,6 +269,8 @@ class _ThinkingShimmer:
|
|
| 268 |
self._task = asyncio.ensure_future(self._animate())
|
| 269 |
|
| 270 |
def stop(self):
|
|
|
|
|
|
|
| 271 |
self._running = False
|
| 272 |
if self._task:
|
| 273 |
self._task.cancel()
|
|
@@ -312,7 +315,10 @@ class _ThinkingShimmer:
|
|
| 312 |
|
| 313 |
|
| 314 |
class _StreamBuffer:
|
| 315 |
-
"""Accumulates streamed tokens, renders
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
def __init__(self, console):
|
| 318 |
self._console = console
|
|
@@ -321,10 +327,41 @@ class _StreamBuffer:
|
|
| 321 |
def add_chunk(self, text: str):
|
| 322 |
self._buffer += text
|
| 323 |
|
| 324 |
-
def
|
| 325 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
if self._buffer.strip():
|
| 327 |
-
print_markdown(self._buffer)
|
| 328 |
self._buffer = ""
|
| 329 |
|
| 330 |
def discard(self):
|
|
@@ -338,6 +375,7 @@ async def event_listener(
|
|
| 338 |
ready_event: asyncio.Event,
|
| 339 |
prompt_session: PromptSession,
|
| 340 |
config=None,
|
|
|
|
| 341 |
) -> None:
|
| 342 |
"""Background task that listens for events and displays them"""
|
| 343 |
submission_id = [1000]
|
|
@@ -346,6 +384,12 @@ async def event_listener(
|
|
| 346 |
shimmer = _ThinkingShimmer(console)
|
| 347 |
stream_buf = _StreamBuffer(console)
|
| 348 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
while True:
|
| 350 |
try:
|
| 351 |
event = await event_queue.get()
|
|
@@ -358,14 +402,19 @@ async def event_listener(
|
|
| 358 |
shimmer.stop()
|
| 359 |
content = event.data.get("content", "") if event.data else ""
|
| 360 |
if content:
|
| 361 |
-
print_markdown(content)
|
| 362 |
elif event.event_type == "assistant_chunk":
|
| 363 |
content = event.data.get("content", "") if event.data else ""
|
| 364 |
if content:
|
| 365 |
stream_buf.add_chunk(content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
elif event.event_type == "assistant_stream_end":
|
| 367 |
shimmer.stop()
|
| 368 |
-
stream_buf.finish()
|
| 369 |
elif event.event_type == "tool_call":
|
| 370 |
shimmer.stop()
|
| 371 |
stream_buf.discard()
|
|
@@ -660,10 +709,33 @@ async def event_listener(
|
|
| 660 |
if gated is not None:
|
| 661 |
print(f"Gated: {gated}")
|
| 662 |
|
| 663 |
-
# Get user decision for this item
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
|
| 668 |
response = response.strip().lower()
|
| 669 |
|
|
@@ -910,44 +982,94 @@ async def main():
|
|
| 910 |
ready_event,
|
| 911 |
prompt_session,
|
| 912 |
config,
|
|
|
|
| 913 |
)
|
| 914 |
)
|
| 915 |
|
| 916 |
await ready_event.wait()
|
| 917 |
|
| 918 |
submission_id = [0]
|
| 919 |
-
|
| 920 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
|
| 922 |
try:
|
| 923 |
while True:
|
| 924 |
-
|
|
|
|
|
|
|
| 925 |
try:
|
| 926 |
await turn_complete_event.wait()
|
| 927 |
except asyncio.CancelledError:
|
| 928 |
break
|
| 929 |
turn_complete_event.clear()
|
| 930 |
-
agent_busy = False
|
| 931 |
|
| 932 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 933 |
try:
|
| 934 |
user_input = await get_user_input(prompt_session)
|
| 935 |
except EOFError:
|
| 936 |
break
|
| 937 |
except KeyboardInterrupt:
|
| 938 |
now = time.monotonic()
|
| 939 |
-
if now -
|
| 940 |
break
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
if agent_busy and session:
|
| 945 |
-
session.cancel()
|
| 946 |
-
else:
|
| 947 |
-
get_console().print("[dim]Ctrl+C again to exit[/dim]")
|
| 948 |
-
turn_complete_event.set()
|
| 949 |
continue
|
| 950 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 951 |
# Check for exit commands
|
| 952 |
if user_input.strip().lower() in ["exit", "quit", "/quit", "/exit"]:
|
| 953 |
break
|
|
@@ -967,7 +1089,6 @@ async def main():
|
|
| 967 |
turn_complete_event.set()
|
| 968 |
continue
|
| 969 |
else:
|
| 970 |
-
agent_busy = True
|
| 971 |
await submission_queue.put(sub)
|
| 972 |
continue
|
| 973 |
|
|
@@ -979,11 +1100,16 @@ async def main():
|
|
| 979 |
op_type=OpType.USER_INPUT, data={"text": user_input}
|
| 980 |
),
|
| 981 |
)
|
| 982 |
-
agent_busy = True
|
| 983 |
await submission_queue.put(submission)
|
| 984 |
|
| 985 |
except KeyboardInterrupt:
|
| 986 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
|
| 988 |
# Shutdown
|
| 989 |
shutdown_submission = Submission(
|
|
@@ -1071,13 +1197,17 @@ async def headless_main(
|
|
| 1071 |
)
|
| 1072 |
await submission_queue.put(submission)
|
| 1073 |
|
| 1074 |
-
# Process events until turn completes
|
|
|
|
|
|
|
| 1075 |
console = _create_rich_console()
|
| 1076 |
-
shimmer = _ThinkingShimmer(console)
|
| 1077 |
stream_buf = _StreamBuffer(console)
|
| 1078 |
_hl_last_tool = [None]
|
| 1079 |
_hl_sub_id = [1]
|
| 1080 |
-
|
|
|
|
|
|
|
|
|
|
| 1081 |
|
| 1082 |
while True:
|
| 1083 |
event = await event_queue.get()
|
|
@@ -1086,16 +1216,14 @@ async def headless_main(
|
|
| 1086 |
content = event.data.get("content", "") if event.data else ""
|
| 1087 |
if content:
|
| 1088 |
stream_buf.add_chunk(content)
|
|
|
|
| 1089 |
elif event.event_type == "assistant_stream_end":
|
| 1090 |
-
|
| 1091 |
-
stream_buf.finish()
|
| 1092 |
elif event.event_type == "assistant_message":
|
| 1093 |
-
shimmer.stop()
|
| 1094 |
content = event.data.get("content", "") if event.data else ""
|
| 1095 |
if content:
|
| 1096 |
-
print_markdown(content)
|
| 1097 |
elif event.event_type == "tool_call":
|
| 1098 |
-
shimmer.stop()
|
| 1099 |
stream_buf.discard()
|
| 1100 |
tool_name = event.data.get("tool", "") if event.data else ""
|
| 1101 |
arguments = event.data.get("arguments", {}) if event.data else {}
|
|
@@ -1109,11 +1237,33 @@ async def headless_main(
|
|
| 1109 |
success = event.data.get("success", False) if event.data else False
|
| 1110 |
if _hl_last_tool[0] == "plan_tool" and output:
|
| 1111 |
print_tool_output(output, success, truncate=False)
|
| 1112 |
-
shimmer.start()
|
| 1113 |
elif event.event_type == "tool_log":
|
| 1114 |
tool = event.data.get("tool", "") if event.data else ""
|
| 1115 |
log = event.data.get("log", "") if event.data else ""
|
| 1116 |
-
if log:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1117 |
print_tool_log(tool, log)
|
| 1118 |
elif event.event_type == "approval_required":
|
| 1119 |
# Auto-approve everything in headless mode (safety net if yolo_mode
|
|
@@ -1140,13 +1290,11 @@ async def headless_main(
|
|
| 1140 |
new_tokens = event.data.get("new_tokens", 0) if event.data else 0
|
| 1141 |
print_compacted(old_tokens, new_tokens)
|
| 1142 |
elif event.event_type == "error":
|
| 1143 |
-
shimmer.stop()
|
| 1144 |
stream_buf.discard()
|
| 1145 |
error = event.data.get("error", "Unknown error") if event.data else "Unknown error"
|
| 1146 |
print_error(error)
|
| 1147 |
break
|
| 1148 |
elif event.event_type in ("turn_complete", "interrupted"):
|
| 1149 |
-
shimmer.stop()
|
| 1150 |
stream_buf.discard()
|
| 1151 |
history_size = event.data.get("history_size", "?") if event.data else "?"
|
| 1152 |
print(f"\n--- Agent {event.event_type} (history_size={history_size}) ---", file=sys.stderr)
|
|
|
|
| 10 |
import asyncio
|
| 11 |
import json
|
| 12 |
import os
|
| 13 |
+
import signal
|
| 14 |
import sys
|
| 15 |
import time
|
| 16 |
from dataclasses import dataclass
|
|
|
|
| 269 |
self._task = asyncio.ensure_future(self._animate())
|
| 270 |
|
| 271 |
def stop(self):
|
| 272 |
+
if not self._running:
|
| 273 |
+
return # no-op when never started (e.g. headless mode)
|
| 274 |
self._running = False
|
| 275 |
if self._task:
|
| 276 |
self._task.cancel()
|
|
|
|
| 315 |
|
| 316 |
|
| 317 |
class _StreamBuffer:
|
| 318 |
+
"""Accumulates streamed tokens, renders markdown block-by-block as complete
|
| 319 |
+
blocks appear. A "block" is everything up to a paragraph break (\\n\\n).
|
| 320 |
+
Unclosed code fences (odd count of ```) hold back flushing until closed so
|
| 321 |
+
a code block is always rendered as one unit."""
|
| 322 |
|
| 323 |
def __init__(self, console):
|
| 324 |
self._console = console
|
|
|
|
| 327 |
def add_chunk(self, text: str):
|
| 328 |
self._buffer += text
|
| 329 |
|
| 330 |
+
def _pop_block(self) -> str | None:
|
| 331 |
+
"""Extract the next complete block, or return None if nothing complete."""
|
| 332 |
+
if self._buffer.count("```") % 2 == 1:
|
| 333 |
+
return None # inside an open code fence β wait for close
|
| 334 |
+
idx = self._buffer.find("\n\n")
|
| 335 |
+
if idx == -1:
|
| 336 |
+
return None
|
| 337 |
+
block = self._buffer[:idx]
|
| 338 |
+
self._buffer = self._buffer[idx + 2:]
|
| 339 |
+
return block
|
| 340 |
+
|
| 341 |
+
async def flush_ready(
|
| 342 |
+
self,
|
| 343 |
+
cancel_event: "asyncio.Event | None" = None,
|
| 344 |
+
instant: bool = False,
|
| 345 |
+
):
|
| 346 |
+
"""Render any complete blocks that have accumulated; leave the tail."""
|
| 347 |
+
while True:
|
| 348 |
+
if cancel_event is not None and cancel_event.is_set():
|
| 349 |
+
return
|
| 350 |
+
block = self._pop_block()
|
| 351 |
+
if block is None:
|
| 352 |
+
return
|
| 353 |
+
if block.strip():
|
| 354 |
+
await print_markdown(block, cancel_event=cancel_event, instant=instant)
|
| 355 |
+
|
| 356 |
+
async def finish(
|
| 357 |
+
self,
|
| 358 |
+
cancel_event: "asyncio.Event | None" = None,
|
| 359 |
+
instant: bool = False,
|
| 360 |
+
):
|
| 361 |
+
"""Flush complete blocks, then render whatever incomplete tail remains."""
|
| 362 |
+
await self.flush_ready(cancel_event=cancel_event, instant=instant)
|
| 363 |
if self._buffer.strip():
|
| 364 |
+
await print_markdown(self._buffer, cancel_event=cancel_event, instant=instant)
|
| 365 |
self._buffer = ""
|
| 366 |
|
| 367 |
def discard(self):
|
|
|
|
| 375 |
ready_event: asyncio.Event,
|
| 376 |
prompt_session: PromptSession,
|
| 377 |
config=None,
|
| 378 |
+
session_holder=None,
|
| 379 |
) -> None:
|
| 380 |
"""Background task that listens for events and displays them"""
|
| 381 |
submission_id = [1000]
|
|
|
|
| 384 |
shimmer = _ThinkingShimmer(console)
|
| 385 |
stream_buf = _StreamBuffer(console)
|
| 386 |
|
| 387 |
+
def _cancel_event():
|
| 388 |
+
"""Return the session's cancellation Event so print_markdown can abort
|
| 389 |
+
its typewriter loop mid-stream when Ctrl+C fires."""
|
| 390 |
+
s = session_holder[0] if session_holder else None
|
| 391 |
+
return s._cancelled if s is not None else None
|
| 392 |
+
|
| 393 |
while True:
|
| 394 |
try:
|
| 395 |
event = await event_queue.get()
|
|
|
|
| 402 |
shimmer.stop()
|
| 403 |
content = event.data.get("content", "") if event.data else ""
|
| 404 |
if content:
|
| 405 |
+
await print_markdown(content, cancel_event=_cancel_event())
|
| 406 |
elif event.event_type == "assistant_chunk":
|
| 407 |
content = event.data.get("content", "") if event.data else ""
|
| 408 |
if content:
|
| 409 |
stream_buf.add_chunk(content)
|
| 410 |
+
# Flush any complete markdown blocks progressively so the
|
| 411 |
+
# user sees paragraphs appear as they're produced, not just
|
| 412 |
+
# at the end of the whole response.
|
| 413 |
+
shimmer.stop()
|
| 414 |
+
await stream_buf.flush_ready(cancel_event=_cancel_event())
|
| 415 |
elif event.event_type == "assistant_stream_end":
|
| 416 |
shimmer.stop()
|
| 417 |
+
await stream_buf.finish(cancel_event=_cancel_event())
|
| 418 |
elif event.event_type == "tool_call":
|
| 419 |
shimmer.stop()
|
| 420 |
stream_buf.discard()
|
|
|
|
| 709 |
if gated is not None:
|
| 710 |
print(f"Gated: {gated}")
|
| 711 |
|
| 712 |
+
# Get user decision for this item. Ctrl+C / EOF here is
|
| 713 |
+
# treated as "reject remaining" (matches Codex's modal
|
| 714 |
+
# priority and Forgecode's approval-cancel path). Without
|
| 715 |
+
# this, KeyboardInterrupt kills the event listener and
|
| 716 |
+
# the main loop deadlocks waiting for turn_complete.
|
| 717 |
+
try:
|
| 718 |
+
response = await prompt_session.prompt_async(
|
| 719 |
+
f"Approve item {i}? (y=yes, yolo=approve all, n=no, or provide feedback): "
|
| 720 |
+
)
|
| 721 |
+
except (KeyboardInterrupt, EOFError):
|
| 722 |
+
get_console().print("[dim]Approval cancelled β rejecting remaining items[/dim]")
|
| 723 |
+
approvals.append(
|
| 724 |
+
{
|
| 725 |
+
"tool_call_id": tool_call_id,
|
| 726 |
+
"approved": False,
|
| 727 |
+
"feedback": "User cancelled approval",
|
| 728 |
+
}
|
| 729 |
+
)
|
| 730 |
+
for remaining in tools_data[i:]:
|
| 731 |
+
approvals.append(
|
| 732 |
+
{
|
| 733 |
+
"tool_call_id": remaining.get("tool_call_id", ""),
|
| 734 |
+
"approved": False,
|
| 735 |
+
"feedback": None,
|
| 736 |
+
}
|
| 737 |
+
)
|
| 738 |
+
break
|
| 739 |
|
| 740 |
response = response.strip().lower()
|
| 741 |
|
|
|
|
| 982 |
ready_event,
|
| 983 |
prompt_session,
|
| 984 |
config,
|
| 985 |
+
session_holder=session_holder,
|
| 986 |
)
|
| 987 |
)
|
| 988 |
|
| 989 |
await ready_event.wait()
|
| 990 |
|
| 991 |
submission_id = [0]
|
| 992 |
+
# Mirrors codex-rs/tui/src/bottom_pane/mod.rs:137
|
| 993 |
+
# (`QUIT_SHORTCUT_TIMEOUT = Duration::from_secs(1)`). Two Ctrl+C presses
|
| 994 |
+
# within this window quit; a single press cancels the in-flight turn.
|
| 995 |
+
CTRL_C_QUIT_WINDOW = 1.0
|
| 996 |
+
# Hint string matches codex-rs/tui/src/bottom_pane/footer.rs:746
|
| 997 |
+
# (`" again to quit"` prefixed with the key binding, rendered dim).
|
| 998 |
+
CTRL_C_HINT = "[dim]ctrl + c again to quit[/dim]"
|
| 999 |
+
interrupt_state = {"last": 0.0, "exit": False}
|
| 1000 |
+
|
| 1001 |
+
loop = asyncio.get_running_loop()
|
| 1002 |
+
|
| 1003 |
+
def _on_sigint() -> None:
|
| 1004 |
+
"""SIGINT handler β fires while the agent is generating (terminal is
|
| 1005 |
+
in cooked mode between prompts). Mirrors Codex's `on_ctrl_c` in
|
| 1006 |
+
codex-rs/tui/src/chatwidget.rs: first press cancels active work and
|
| 1007 |
+
arms the quit hint; second press within the window quits."""
|
| 1008 |
+
now = time.monotonic()
|
| 1009 |
+
session = session_holder[0]
|
| 1010 |
+
|
| 1011 |
+
if now - interrupt_state["last"] < CTRL_C_QUIT_WINDOW:
|
| 1012 |
+
interrupt_state["exit"] = True
|
| 1013 |
+
if session:
|
| 1014 |
+
session.cancel()
|
| 1015 |
+
# Wake the main loop out of turn_complete_event.wait()
|
| 1016 |
+
turn_complete_event.set()
|
| 1017 |
+
return
|
| 1018 |
+
|
| 1019 |
+
interrupt_state["last"] = now
|
| 1020 |
+
if session and not session.is_cancelled:
|
| 1021 |
+
session.cancel()
|
| 1022 |
+
get_console().print(f"\n{CTRL_C_HINT}")
|
| 1023 |
+
|
| 1024 |
+
def _install_sigint() -> bool:
|
| 1025 |
+
try:
|
| 1026 |
+
loop.add_signal_handler(signal.SIGINT, _on_sigint)
|
| 1027 |
+
return True
|
| 1028 |
+
except (NotImplementedError, RuntimeError):
|
| 1029 |
+
return False # Windows or non-main thread
|
| 1030 |
+
|
| 1031 |
+
# prompt_toolkit's prompt_async installs its own SIGINT handler and, on
|
| 1032 |
+
# exit, calls loop.remove_signal_handler(SIGINT) β which wipes ours too.
|
| 1033 |
+
# So we re-arm at the top of every loop iteration, right before the busy
|
| 1034 |
+
# wait. Without this, Ctrl+C during agent streaming after the first turn
|
| 1035 |
+
# falls through to the default handler and the terminal just echoes ^C.
|
| 1036 |
+
sigint_available = _install_sigint()
|
| 1037 |
|
| 1038 |
try:
|
| 1039 |
while True:
|
| 1040 |
+
if sigint_available:
|
| 1041 |
+
_install_sigint()
|
| 1042 |
+
|
| 1043 |
try:
|
| 1044 |
await turn_complete_event.wait()
|
| 1045 |
except asyncio.CancelledError:
|
| 1046 |
break
|
| 1047 |
turn_complete_event.clear()
|
|
|
|
| 1048 |
|
| 1049 |
+
if interrupt_state["exit"]:
|
| 1050 |
+
break
|
| 1051 |
+
|
| 1052 |
+
# Get user input. prompt_toolkit puts the terminal in raw mode and
|
| 1053 |
+
# installs its own SIGINT handling; ^C arrives as \x03 and surfaces
|
| 1054 |
+
# as KeyboardInterrupt here. On return, prompt_toolkit removes the
|
| 1055 |
+
# loop's SIGINT handler β we re-arm at the top of the next iter.
|
| 1056 |
try:
|
| 1057 |
user_input = await get_user_input(prompt_session)
|
| 1058 |
except EOFError:
|
| 1059 |
break
|
| 1060 |
except KeyboardInterrupt:
|
| 1061 |
now = time.monotonic()
|
| 1062 |
+
if now - interrupt_state["last"] < CTRL_C_QUIT_WINDOW:
|
| 1063 |
break
|
| 1064 |
+
interrupt_state["last"] = now
|
| 1065 |
+
get_console().print(CTRL_C_HINT)
|
| 1066 |
+
turn_complete_event.set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
continue
|
| 1068 |
|
| 1069 |
+
# A successful read ends the double-press window β an unrelated
|
| 1070 |
+
# Ctrl+C during the next turn should start a fresh arming.
|
| 1071 |
+
interrupt_state["last"] = 0.0
|
| 1072 |
+
|
| 1073 |
# Check for exit commands
|
| 1074 |
if user_input.strip().lower() in ["exit", "quit", "/quit", "/exit"]:
|
| 1075 |
break
|
|
|
|
| 1089 |
turn_complete_event.set()
|
| 1090 |
continue
|
| 1091 |
else:
|
|
|
|
| 1092 |
await submission_queue.put(sub)
|
| 1093 |
continue
|
| 1094 |
|
|
|
|
| 1100 |
op_type=OpType.USER_INPUT, data={"text": user_input}
|
| 1101 |
),
|
| 1102 |
)
|
|
|
|
| 1103 |
await submission_queue.put(submission)
|
| 1104 |
|
| 1105 |
except KeyboardInterrupt:
|
| 1106 |
pass
|
| 1107 |
+
finally:
|
| 1108 |
+
if sigint_available:
|
| 1109 |
+
try:
|
| 1110 |
+
loop.remove_signal_handler(signal.SIGINT)
|
| 1111 |
+
except (NotImplementedError, RuntimeError):
|
| 1112 |
+
pass
|
| 1113 |
|
| 1114 |
# Shutdown
|
| 1115 |
shutdown_submission = Submission(
|
|
|
|
| 1197 |
)
|
| 1198 |
await submission_queue.put(submission)
|
| 1199 |
|
| 1200 |
+
# Process events until turn completes. Headless mode is for scripts /
|
| 1201 |
+
# log capture: no shimmer animation, no typewriter, no live-redrawing
|
| 1202 |
+
# research overlay. Output is plain, append-only text.
|
| 1203 |
console = _create_rich_console()
|
|
|
|
| 1204 |
stream_buf = _StreamBuffer(console)
|
| 1205 |
_hl_last_tool = [None]
|
| 1206 |
_hl_sub_id = [1]
|
| 1207 |
+
# Research sub-agent tool calls are buffered and dumped once the sub-agent
|
| 1208 |
+
# finishes, instead of streaming via the live redrawing SubAgentDisplay.
|
| 1209 |
+
_hl_research_calls: list[str] = []
|
| 1210 |
+
_hl_in_research = [False]
|
| 1211 |
|
| 1212 |
while True:
|
| 1213 |
event = await event_queue.get()
|
|
|
|
| 1216 |
content = event.data.get("content", "") if event.data else ""
|
| 1217 |
if content:
|
| 1218 |
stream_buf.add_chunk(content)
|
| 1219 |
+
await stream_buf.flush_ready(instant=True)
|
| 1220 |
elif event.event_type == "assistant_stream_end":
|
| 1221 |
+
await stream_buf.finish(instant=True)
|
|
|
|
| 1222 |
elif event.event_type == "assistant_message":
|
|
|
|
| 1223 |
content = event.data.get("content", "") if event.data else ""
|
| 1224 |
if content:
|
| 1225 |
+
await print_markdown(content, instant=True)
|
| 1226 |
elif event.event_type == "tool_call":
|
|
|
|
| 1227 |
stream_buf.discard()
|
| 1228 |
tool_name = event.data.get("tool", "") if event.data else ""
|
| 1229 |
arguments = event.data.get("arguments", {}) if event.data else {}
|
|
|
|
| 1237 |
success = event.data.get("success", False) if event.data else False
|
| 1238 |
if _hl_last_tool[0] == "plan_tool" and output:
|
| 1239 |
print_tool_output(output, success, truncate=False)
|
|
|
|
| 1240 |
elif event.event_type == "tool_log":
|
| 1241 |
tool = event.data.get("tool", "") if event.data else ""
|
| 1242 |
log = event.data.get("log", "") if event.data else ""
|
| 1243 |
+
if not log:
|
| 1244 |
+
pass
|
| 1245 |
+
elif tool == "research":
|
| 1246 |
+
# Buffer research sub-agent activity; on completion, dump a
|
| 1247 |
+
# single static block that mirrors the live overlay's styling
|
| 1248 |
+
# without its line-erasing redraws (unfit for non-TTY output).
|
| 1249 |
+
if log == "Starting research sub-agent...":
|
| 1250 |
+
_hl_in_research[0] = True
|
| 1251 |
+
_hl_research_calls.clear()
|
| 1252 |
+
elif log == "Research complete.":
|
| 1253 |
+
_hl_in_research[0] = False
|
| 1254 |
+
f = get_console().file
|
| 1255 |
+
f.write(" \033[38;2;255;200;80mβΈ research\033[0m\n")
|
| 1256 |
+
for call in _hl_research_calls:
|
| 1257 |
+
f.write(f" \033[2m{call}\033[0m\n")
|
| 1258 |
+
f.flush()
|
| 1259 |
+
_hl_research_calls.clear()
|
| 1260 |
+
elif log.startswith("tokens:") or log.startswith("tools:"):
|
| 1261 |
+
pass # stats updates β only useful for the live display
|
| 1262 |
+
elif _hl_in_research[0]:
|
| 1263 |
+
_hl_research_calls.append(log)
|
| 1264 |
+
else:
|
| 1265 |
+
print_tool_log(tool, log)
|
| 1266 |
+
else:
|
| 1267 |
print_tool_log(tool, log)
|
| 1268 |
elif event.event_type == "approval_required":
|
| 1269 |
# Auto-approve everything in headless mode (safety net if yolo_mode
|
|
|
|
| 1290 |
new_tokens = event.data.get("new_tokens", 0) if event.data else 0
|
| 1291 |
print_compacted(old_tokens, new_tokens)
|
| 1292 |
elif event.event_type == "error":
|
|
|
|
| 1293 |
stream_buf.discard()
|
| 1294 |
error = event.data.get("error", "Unknown error") if event.data else "Unknown error"
|
| 1295 |
print_error(error)
|
| 1296 |
break
|
| 1297 |
elif event.event_type in ("turn_complete", "interrupted"):
|
|
|
|
| 1298 |
stream_buf.discard()
|
| 1299 |
history_size = event.data.get("history_size", "?") if event.data else "?"
|
| 1300 |
print(f"\n--- Agent {event.event_type} (history_size={history_size}) ---", file=sys.stderr)
|
agent/utils/terminal_display.py
CHANGED
|
@@ -3,10 +3,22 @@ Terminal display utilities β rich-powered CLI formatting.
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from rich.console import Console
|
| 6 |
-
from rich.markdown import Markdown
|
| 7 |
from rich.panel import Panel
|
| 8 |
from rich.theme import Theme
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
_THEME = Theme({
|
| 11 |
"tool.name": "bold rgb(255,200,80)",
|
| 12 |
"tool.args": "dim",
|
|
@@ -235,8 +247,13 @@ def print_tool_log(tool: str, log: str) -> None:
|
|
| 235 |
|
| 236 |
# ββ Messages βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 237 |
|
| 238 |
-
def print_markdown(
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
from rich.padding import Padding
|
| 241 |
|
| 242 |
_console.print()
|
|
@@ -260,21 +277,36 @@ def print_markdown(text: str) -> None:
|
|
| 260 |
lines = rendered.split("\n")
|
| 261 |
rendered = "\n".join(line.rstrip() for line in lines)
|
| 262 |
|
| 263 |
-
# CRT typewriter effect β fast, with occasional glitch
|
| 264 |
-
rng = random.Random(42)
|
| 265 |
f = _console.file
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
for ch in rendered:
|
|
|
|
|
|
|
|
|
|
| 267 |
f.write(ch)
|
| 268 |
f.flush()
|
| 269 |
if ch == "\n":
|
| 270 |
-
|
| 271 |
elif ch == " ":
|
| 272 |
-
|
| 273 |
elif rng.random() < 0.03:
|
| 274 |
-
|
| 275 |
else:
|
| 276 |
-
|
| 277 |
-
f.write("\n")
|
| 278 |
f.flush()
|
| 279 |
|
| 280 |
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from rich.console import Console
|
| 6 |
+
from rich.markdown import Heading, Markdown
|
| 7 |
from rich.panel import Panel
|
| 8 |
from rich.theme import Theme
|
| 9 |
|
| 10 |
+
|
| 11 |
+
class _LeftHeading(Heading):
|
| 12 |
+
"""Rich's default Markdown renders h1/h2 centered via Align.center.
|
| 13 |
+
Yield the styled text directly so headings stay left-aligned."""
|
| 14 |
+
|
| 15 |
+
def __rich_console__(self, console, options):
|
| 16 |
+
self.text.justify = "left"
|
| 17 |
+
yield self.text
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
Markdown.elements["heading_open"] = _LeftHeading
|
| 21 |
+
|
| 22 |
_THEME = Theme({
|
| 23 |
"tool.name": "bold rgb(255,200,80)",
|
| 24 |
"tool.args": "dim",
|
|
|
|
| 247 |
|
| 248 |
# ββ Messages βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 249 |
|
| 250 |
+
async def print_markdown(
|
| 251 |
+
text: str,
|
| 252 |
+
cancel_event: "asyncio.Event | None" = None,
|
| 253 |
+
instant: bool = False,
|
| 254 |
+
) -> None:
|
| 255 |
+
import asyncio
|
| 256 |
+
import io, random
|
| 257 |
from rich.padding import Padding
|
| 258 |
|
| 259 |
_console.print()
|
|
|
|
| 277 |
lines = rendered.split("\n")
|
| 278 |
rendered = "\n".join(line.rstrip() for line in lines)
|
| 279 |
|
|
|
|
|
|
|
| 280 |
f = _console.file
|
| 281 |
+
|
| 282 |
+
# Headless / non-interactive: dump the rendered markdown in one write.
|
| 283 |
+
if instant:
|
| 284 |
+
f.write(rendered)
|
| 285 |
+
f.write("\n")
|
| 286 |
+
f.flush()
|
| 287 |
+
return
|
| 288 |
+
|
| 289 |
+
# CRT typewriter effect β async so the event loop can service signal
|
| 290 |
+
# handlers (Ctrl+C during streaming) between characters. If cancelled
|
| 291 |
+
# mid-type, stop cleanly: write an ANSI reset so half-open color state
|
| 292 |
+
# doesn't bleed onto the "interrupted" line, and return.
|
| 293 |
+
rng = random.Random(42)
|
| 294 |
+
cancelled = False
|
| 295 |
for ch in rendered:
|
| 296 |
+
if cancel_event is not None and cancel_event.is_set():
|
| 297 |
+
cancelled = True
|
| 298 |
+
break
|
| 299 |
f.write(ch)
|
| 300 |
f.flush()
|
| 301 |
if ch == "\n":
|
| 302 |
+
await asyncio.sleep(0.002)
|
| 303 |
elif ch == " ":
|
| 304 |
+
await asyncio.sleep(0.002)
|
| 305 |
elif rng.random() < 0.03:
|
| 306 |
+
await asyncio.sleep(0.015)
|
| 307 |
else:
|
| 308 |
+
await asyncio.sleep(0.004)
|
| 309 |
+
f.write("\033[0m\n" if cancelled else "\n")
|
| 310 |
f.flush()
|
| 311 |
|
| 312 |
|