feat: format progress updates per exec spec

This commit is contained in:
banteg
2025-12-28 22:34:24 +04:00
parent 864b00d988
commit 978430cb12
2 changed files with 276 additions and 215 deletions
@@ -60,29 +60,34 @@ class ProgressEditor:
self.edit_every_s = edit_every_s
self._lock = threading.Lock()
self._pending: Optional[str] = None
self._last_sent: Optional[str] = None
self._pending: Optional[tuple[str, Optional[list[dict[str, Any]]]]] = None
self._last_sent: Optional[tuple[str, Optional[list[dict[str, Any]]]]] = None
self._last_edit_at = 0.0
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def set(self, text: str) -> None:
def set(self, text: str, entities: Optional[list[dict[str, Any]]] = None) -> None:
text = _clamp_tg_text(text)
with self._lock:
self._pending = text
self._pending = (text, entities)
def set_markdown(self, text: str) -> None:
rendered_text, entities = render_markdown(text)
self.set(rendered_text, entities or None)
def stop(self) -> None:
self._stop.set()
self._thread.join(timeout=1.0)
def _edit(self, text: str) -> None:
def _edit(self, text: str, entities: Optional[list[dict[str, Any]]]) -> None:
try:
self.bot.edit_message_text(
chat_id=self.chat_id,
message_id=self.message_id,
text=text,
entities=entities,
)
except Exception as e:
log(
@@ -92,7 +97,7 @@ class ProgressEditor:
def _run(self) -> None:
while not self._stop.is_set():
to_send: Optional[str] = None
to_send: Optional[tuple[str, Optional[list[dict[str, Any]]]]] = None
now = time.monotonic()
with self._lock:
if self._pending is not None and (now - self._last_edit_at) >= self.edit_every_s:
@@ -103,7 +108,7 @@ class ProgressEditor:
self._pending = None
if to_send is not None:
self._edit(to_send)
self._edit(to_send[0], to_send[1])
self._stop.wait(0.2)
@@ -358,12 +363,19 @@ def run(
)
typing_thread.start()
started_at = time.monotonic()
session_box: dict[str, Optional[str]] = {"id": resume_session}
progress_renderer = ExecProgressRenderer(max_actions=5)
progress_id: Optional[int] = None
progress: Optional[ProgressEditor] = None
try:
initial_text = progress_renderer.render_progress(0.0)
initial_rendered, initial_entities = render_markdown(initial_text)
progress_msg = bot.send_message(
chat_id=chat_id,
text="Working...",
text=initial_rendered,
entities=initial_entities or None,
reply_to_message_id=user_msg_id,
disable_notification=silent_progress,
)
@@ -374,10 +386,6 @@ def run(
if progress_id is not None:
progress = ProgressEditor(bot, chat_id, progress_id, edit_every_s)
started_at = time.monotonic()
session_box: dict[str, Optional[str]] = {"id": resume_session}
progress_renderer = ExecProgressRenderer(max_lines=4)
def on_event(evt: Dict[str, Any]) -> None:
event_type = evt.get("type")
if event_type == "thread.started":
@@ -392,15 +400,10 @@ def run(
thread_id,
meta={"workspace": workspace},
)
line = progress_renderer.note_event(evt)
if line and progress is not None:
elapsed = int(time.monotonic() - started_at)
session_id = session_box["id"]
header = f"Working... ({elapsed}s)"
if session_id:
header += f"\nSession: {session_id}"
msg = progress_renderer.render(header)
progress.set(msg)
if progress_renderer.note_event(evt) and progress is not None:
elapsed = time.monotonic() - started_at
msg = progress_renderer.render_progress(elapsed)
progress.set_markdown(msg)
def _stop_background() -> None:
typing_stop.set()
@@ -443,13 +446,15 @@ def run(
_stop_background()
answer = answer or "(No agent_message captured from JSON stream.)"
final_text, final_entities = render_markdown(answer)
elapsed = time.monotonic() - started_at
final_md = progress_renderer.render_final(elapsed, answer)
final_text, final_entities = render_markdown(final_md)
can_edit_final = progress_id is not None and len(final_text) <= TELEGRAM_TEXT_LIMIT
if loud_final or not can_edit_final:
sent_msgs = bot.send_message_markdown_chunked(
chat_id=chat_id,
text=answer,
text=final_md,
reply_to_message_id=user_msg_id,
)
for m in sent_msgs:
@@ -5,7 +5,93 @@ import re
from collections import deque
from dataclasses import dataclass, field
from textwrap import indent
from typing import Any, Dict, List, Optional
from typing import Any, Optional
ELLIPSIS = ""
STATUS_RUNNING = ""
STATUS_DONE = ""
HEADER_SEP = " · "
MAX_CMD_LEN = 40
MAX_REASON_LEN = 80
MAX_QUERY_LEN = 60
MAX_PATH_LEN = 40
MAX_PROGRESS_CHARS = 300
def _one_line(text: str) -> str:
return " ".join((text or "").split())
def _truncate(text: str, max_len: int) -> str:
text = _one_line(text)
if len(text) <= max_len:
return text
if max_len <= len(ELLIPSIS):
return text[:max_len]
return text[: max_len - len(ELLIPSIS)] + ELLIPSIS
def _inline_code(text: str) -> str:
return f"`{text}`"
def _format_elapsed(elapsed_s: float) -> str:
total = max(0, int(elapsed_s))
minutes, seconds = divmod(total, 60)
hours, minutes = divmod(minutes, 60)
if hours:
return f"{hours}h {minutes:02d}m"
if minutes:
return f"{minutes}m {seconds:02d}s"
return f"{seconds}s"
def _format_header(elapsed_s: float, turn: Optional[int], done: bool) -> str:
label = "Done" if done else "codex"
elapsed = _format_elapsed(elapsed_s)
if turn is not None:
return f"{label}{HEADER_SEP}{elapsed}{HEADER_SEP}turn {turn}"
return f"{label}{HEADER_SEP}{elapsed}"
def _format_reasoning(text: str) -> str:
if not text:
return ""
return f"_{_truncate(text, MAX_REASON_LEN)}_"
def _format_command(command: str) -> str:
command = _truncate(command, MAX_CMD_LEN)
if not command:
command = "(empty)"
return _inline_code(command)
def _format_query(query: str) -> str:
return _truncate(query, MAX_QUERY_LEN)
def _format_paths(paths: list[str]) -> str:
rendered = []
for path in paths:
rendered.append(_inline_code(_truncate(path, MAX_PATH_LEN)))
return ", ".join(rendered)
def _format_file_change(changes: list[dict[str, Any]]) -> str:
paths = [change.get("path") for change in changes if change.get("path")]
if not paths:
total = len(changes)
return "updated files" if total == 0 else f"updated {total} files"
if len(paths) <= 3:
return f"updated {_format_paths(paths)}"
return f"updated {len(paths)} files"
def _format_tool_call(server: str, tool: str) -> str:
name = ".".join(part for part in (server, tool) if part)
return name or "tool"
def _truncate_output(text: str, max_lines: int = 20, max_chars: int = 4000) -> str:
@@ -19,23 +105,6 @@ def _truncate_output(text: str, max_lines: int = 20, max_chars: int = 4000) -> s
return "\n".join(lines)
def _format_todo(items: list[dict[str, Any]]) -> str:
rendered: list[str] = []
for item in items:
status = "done" if item.get("completed") else "todo"
rendered.append(f"- [{status}] {item.get('text', '')}")
return "\n".join(rendered)
def _format_todo_summary(items: list[dict[str, Any]]) -> str:
total = len(items)
done = sum(1 for item in items if item.get("completed"))
next_text = next((item.get("text", "") for item in items if not item.get("completed")), "")
if next_text:
return f"plan: {done}/{total} done, next: {_shorten(next_text, 80)}"
return f"plan: {done}/{total} done"
def _maybe_parse_json(text: str) -> Optional[Any]:
try:
return json.loads(text)
@@ -43,16 +112,14 @@ def _maybe_parse_json(text: str) -> Optional[Any]:
return None
def _shorten(text: str, max_len: int = 140) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
@dataclass
class ExecRenderState:
items: dict[str, dict[str, Any]] = field(default_factory=dict)
recent: deque[str] = field(default_factory=lambda: deque(maxlen=4))
recent_actions: deque[str] = field(default_factory=lambda: deque(maxlen=5))
current_action: Optional[str] = None
current_action_id: Optional[str] = None
pending_reasoning: Optional[str] = None
current_reasoning: Optional[str] = None
last_turn: Optional[int] = None
@@ -65,6 +132,31 @@ def _record_item(state: ExecRenderState, item: dict[str, Any]) -> None:
state.last_turn = int(match.group(1))
def _set_current_action(state: ExecRenderState, item_id: Optional[str], line: str) -> bool:
changed = False
if state.current_action != line or state.current_action_id != item_id:
state.current_action = line
state.current_action_id = item_id
if state.pending_reasoning:
state.current_reasoning = state.pending_reasoning
state.pending_reasoning = None
changed = True
return changed
def _complete_action(state: ExecRenderState, item_id: Optional[str], line: str) -> bool:
changed = False
if line:
state.recent_actions.append(line)
changed = True
if item_id and state.current_action_id == item_id:
state.current_action = None
state.current_action_id = None
state.current_reasoning = None
changed = True
return changed
def render_event_cli(
event: dict[str, Any],
state: ExecRenderState,
@@ -75,26 +167,13 @@ def render_event_cli(
lines: list[str] = []
if etype == "thread.started":
thread_id = event.get("thread_id", "")
if thread_id:
lines.append(f"thread started: {thread_id}")
lines.append(f"resume with: codex exec resume {thread_id}")
else:
lines.append("thread started")
return lines
return ["thread started"]
if etype == "turn.started":
return ["turn started"]
if etype == "turn.completed":
usage = event.get("usage", {})
lines.append(
"turn completed "
f"(in={usage.get('input_tokens', 0)} "
f"cached={usage.get('cached_input_tokens', 0)} "
f"out={usage.get('output_tokens', 0)})"
)
return lines
return ["turn completed"]
if etype == "turn.failed":
error = event.get("error", {}).get("message", "")
@@ -108,9 +187,8 @@ def render_event_cli(
_record_item(state, item)
itype = item.get("type")
status = item.get("status")
if itype == "agent_message":
if itype == "agent_message" and etype == "item.completed":
text = item.get("text", "")
parsed = _maybe_parse_json(text)
if parsed is not None:
@@ -121,177 +199,155 @@ def render_event_cli(
lines.extend(indent(text, " ").splitlines() if text else [" (empty)"])
elif itype == "reasoning" and show_reasoning:
lines.append(f"reasoning: {item.get('text', '')}")
reasoning = _format_reasoning(item.get("text", ""))
if reasoning:
lines.append(reasoning)
elif itype == "command_execution":
command = item.get("command", "")
command = _format_command(item.get("command", ""))
if etype == "item.started":
lines.append(f"run: {command}")
else:
lines.append(f"{STATUS_RUNNING} running: {command}")
elif etype == "item.completed":
exit_code = item.get("exit_code")
outcome = "ok" if status == "completed" else status or "unknown"
lines.append(f"command {outcome} (exit={exit_code}): {command}")
exit_part = f" (exit {exit_code})" if exit_code is not None else ""
lines.append(f"{STATUS_DONE} ran: {command}{exit_part}")
output = _truncate_output(item.get("aggregated_output", ""))
if output:
lines.extend(indent(output, " ").splitlines())
elif itype == "file_change":
changes = item.get("changes", [])
counts = {"add": 0, "update": 0, "delete": 0}
for change in changes:
kind = change.get("kind")
if kind in counts:
counts[kind] += 1
lines.append(
"file changes "
f"(status={status}) add={counts['add']} "
f"update={counts['update']} delete={counts['delete']}"
)
for change in changes:
lines.append(f" {change.get('kind')} {change.get('path')}")
elif itype == "file_change" and etype == "item.completed":
line = _format_file_change(item.get("changes", []))
lines.append(f"{STATUS_DONE} {line}")
elif itype == "mcp_tool_call":
server = item.get("server", "")
tool = item.get("tool", "")
name = _format_tool_call(item.get("server", ""), item.get("tool", ""))
if etype == "item.started":
lines.append(f"tool call: {server}.{tool}")
else:
outcome = "ok" if status == "completed" else status or "unknown"
lines.append(f"tool {outcome}: {server}.{tool}")
if item.get("error"):
lines.append(f" error: {item['error'].get('message', '')}")
result = item.get("result") or {}
if result.get("structured_content") is not None:
lines.append(" result:")
lines.extend(
indent(json.dumps(result["structured_content"], indent=2), " ").splitlines()
)
lines.append(f"{STATUS_RUNNING} tool: {name}")
elif etype == "item.completed":
lines.append(f"{STATUS_DONE} tool: {name}")
elif itype == "web_search":
lines.append(f"web search: {item.get('query', '')}")
elif itype == "web_search" and etype == "item.completed":
query = _format_query(item.get("query", ""))
lines.append(f"{STATUS_DONE} searched: {query}")
elif itype == "todo_list":
todo = _format_todo(item.get("items", []))
lines.append(f"plan ({etype}):")
lines.extend(indent(todo, " ").splitlines())
elif itype == "error":
lines.append(f"warning: {item.get('message', '')}")
else:
lines.append(f"{etype}: {item}")
else:
if etype:
lines.append(f"event: {etype}")
else:
lines.append(f"event: {event}")
elif itype == "error" and etype == "item.completed":
warning = _truncate(item.get("message", ""), 120)
lines.append(f"{STATUS_DONE} warning: {warning}")
return lines
def render_event_progress(event: dict[str, Any], state: ExecRenderState) -> Optional[str]:
class ExecProgressRenderer:
def __init__(self, max_actions: int = 5, max_chars: int = MAX_PROGRESS_CHARS) -> None:
self.state = ExecRenderState(recent_actions=deque(maxlen=max_actions))
self.max_chars = max_chars
def note_event(self, event: dict[str, Any]) -> bool:
etype = event.get("type")
changed = False
if etype == "thread.started":
thread_id = event.get("thread_id", "")
return f"thread started: {thread_id}" if thread_id else "thread started"
if etype == "turn.started":
return "turn started"
if etype == "turn.completed":
usage = event.get("usage", {})
return (
"turn completed "
f"(in={usage.get('input_tokens', 0)} "
f"cached={usage.get('cached_input_tokens', 0)} "
f"out={usage.get('output_tokens', 0)})"
)
if etype == "turn.failed":
error = event.get("error", {}).get("message", "")
return f"turn failed: {error}"
if etype == "error":
return f"stream error: {event.get('message', '')}"
if etype in {"thread.started", "turn.started"}:
return True
if etype in {"item.started", "item.updated", "item.completed"}:
item = event.get("item", {}) or {}
_record_item(state, item)
_record_item(self.state, item)
itype = item.get("type")
status = item.get("status")
item_id = item.get("id") if isinstance(item.get("id"), str) else None
if itype == "agent_message" and etype == "item.completed":
text = item.get("text", "")
snippet = text.splitlines()[0] if text else ""
if snippet:
return f"assistant: {_shorten(snippet, 120)}"
return "assistant response ready"
if itype == "reasoning":
reasoning = _format_reasoning(item.get("text", ""))
if reasoning:
if self.state.current_action and not self.state.current_reasoning:
self.state.current_reasoning = reasoning
changed = True
else:
self.state.pending_reasoning = reasoning
return changed
if itype == "command_execution":
command = item.get("command", "")
command = _format_command(item.get("command", ""))
if etype == "item.started":
return f"run: {_shorten(command, 160)}"
line = f"{STATUS_RUNNING} running: {command}"
changed = _set_current_action(self.state, item_id, line) or changed
elif etype == "item.completed":
exit_code = item.get("exit_code")
outcome = "ok" if status == "completed" else status or "unknown"
return f"command {outcome} (exit={exit_code}): {_shorten(command, 120)}"
if itype == "file_change":
changes = item.get("changes", [])
counts = {"add": 0, "update": 0, "delete": 0}
for change in changes:
kind = change.get("kind")
if kind in counts:
counts[kind] += 1
return (
"file changes "
f"+{counts['add']} ~{counts['update']} -{counts['delete']}"
)
exit_part = f" (exit {exit_code})" if exit_code is not None else ""
line = f"{STATUS_DONE} ran: {command}{exit_part}"
changed = _complete_action(self.state, item_id, line) or changed
return changed
if itype == "mcp_tool_call":
server = item.get("server", "")
tool = item.get("tool", "")
name = _format_tool_call(item.get("server", ""), item.get("tool", ""))
if etype == "item.started":
return f"tool call: {server}.{tool}"
outcome = "ok" if status == "completed" else status or "unknown"
return f"tool {outcome}: {server}.{tool}"
line = f"{STATUS_RUNNING} tool: {name}"
changed = _set_current_action(self.state, item_id, line) or changed
elif etype == "item.completed":
line = f"{STATUS_DONE} tool: {name}"
changed = _complete_action(self.state, item_id, line) or changed
return changed
if itype == "web_search":
return f"web search: {_shorten(item.get('query', ''), 120)}"
if itype == "web_search" and etype == "item.completed":
query = _format_query(item.get("query", ""))
line = f"{STATUS_DONE} searched: {query}"
return _complete_action(self.state, item_id, line) or changed
if itype == "todo_list":
return _format_todo_summary(item.get("items", []))
if itype == "file_change" and etype == "item.completed":
line = f"{STATUS_DONE} {_format_file_change(item.get('changes', []))}"
return _complete_action(self.state, item_id, line) or changed
if itype == "error":
return f"warning: {_shorten(item.get('message', ''), 120)}"
if itype == "error" and etype == "item.completed":
warning = _truncate(item.get("message", ""), 120)
line = f"{STATUS_DONE} warning: {warning}"
return _complete_action(self.state, item_id, line) or changed
return None
return changed
def render_progress(self, elapsed_s: float) -> str:
header = _format_header(elapsed_s, self.state.last_turn, done=False)
actions = list(self.state.recent_actions)
current_reasoning = self.state.current_reasoning
current_action = self.state.current_action
class ExecProgressRenderer:
def __init__(self, max_lines: int = 4) -> None:
self.state = ExecRenderState(recent=deque(maxlen=max_lines))
def note_event(self, event: dict[str, Any]) -> Optional[str]:
line = render_event_progress(event, self.state)
if not line:
return None
line = line.strip()
if not line:
return None
if self.state.recent and self.state.recent[-1] == line:
return line
self.state.recent.append(line)
return line
def render(self, header: str) -> str:
lines: list[str] = []
if self.state.last_turn is not None:
lines.append(f"Turn: {self.state.last_turn}")
if self.state.recent:
lines.extend(self.state.recent)
if actions:
lines.extend(actions)
if current_reasoning and current_action:
lines.append(current_reasoning)
if current_action:
lines.append(current_action)
message = self._assemble(header, lines)
if len(message) <= self.max_chars:
return message
trimmed_actions = list(actions)
while len(message) > self.max_chars and trimmed_actions:
trimmed_actions.pop(0)
lines = []
if trimmed_actions:
lines.extend(trimmed_actions)
if current_reasoning and current_action:
lines.append(current_reasoning)
if current_action:
lines.append(current_action)
message = self._assemble(header, lines)
return message
def render_final(self, elapsed_s: float, answer: str) -> str:
header = _format_header(elapsed_s, self.state.last_turn, done=True)
lines: list[str] = []
if self.state.recent_actions:
lines.extend(self.state.recent_actions)
body = self._assemble(header, lines)
answer = (answer or "").strip()
if answer:
body = body + "\n\n" + answer
return body
@staticmethod
def _assemble(header: str, lines: list[str]) -> str:
if not lines:
return header
return header + "\n\n" + "\n".join(lines)