fix(agent): refine status output and CLI rendering

Keep status output responsive while estimating current context from session history, dropping low-value queue/subagent counters, and marking command-style replies for plain-text rendering in CLI. Also route direct CLI calls through outbound metadata so help/status formatting stays explicit instead of relying on content heuristics.

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-03-21 15:52:10 +00:00
parent 4d1897609d
commit e430b1daf5
4 changed files with 142 additions and 24 deletions

View File

@@ -189,7 +189,6 @@ class AgentLoop:
"""Build a human-readable runtime status snapshot.""" """Build a human-readable runtime status snapshot."""
history = session.get_history(max_messages=0) history = session.get_history(max_messages=0)
msg_count = len(history) msg_count = len(history)
active_subs = self.subagents.get_running_count()
uptime_s = int(time.time() - self._start_time) uptime_s = int(time.time() - self._start_time)
uptime = ( uptime = (
@@ -201,7 +200,13 @@ class AgentLoop:
last_in = self._last_usage.get("prompt_tokens", 0) last_in = self._last_usage.get("prompt_tokens", 0)
last_out = self._last_usage.get("completion_tokens", 0) last_out = self._last_usage.get("completion_tokens", 0)
ctx_used = last_in ctx_used = 0
try:
ctx_used, _ = self.memory_consolidator.estimate_session_prompt_tokens(session)
except Exception:
ctx_used = 0
if ctx_used <= 0:
ctx_used = last_in
ctx_total_tokens = max(self.context_window_tokens, 0) ctx_total_tokens = max(self.context_window_tokens, 0)
ctx_pct = int((ctx_used / ctx_total_tokens) * 100) if ctx_total_tokens > 0 else 0 ctx_pct = int((ctx_used / ctx_total_tokens) * 100) if ctx_total_tokens > 0 else 0
ctx_used_str = f"{ctx_used // 1000}k" if ctx_used >= 1000 else str(ctx_used) ctx_used_str = f"{ctx_used // 1000}k" if ctx_used >= 1000 else str(ctx_used)
@@ -213,8 +218,6 @@ class AgentLoop:
f"📊 Tokens: {last_in} in / {last_out} out", f"📊 Tokens: {last_in} in / {last_out} out",
f"📚 Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}%)", f"📚 Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}%)",
f"💬 Session: {msg_count} messages", f"💬 Session: {msg_count} messages",
f"👾 Subagents: {active_subs} active",
f"🪢 Queue: {self.bus.inbound.qsize()} pending",
f"⏱ Uptime: {uptime}", f"⏱ Uptime: {uptime}",
]) ])
@@ -224,6 +227,7 @@ class AgentLoop:
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
content=self._build_status_content(session), content=self._build_status_content(session),
metadata={"render_as": "text"},
) )
async def _run_agent_loop( async def _run_agent_loop(
@@ -475,7 +479,10 @@ class AgentLoop:
"/help — Show available commands", "/help — Show available commands",
] ]
return OutboundMessage( return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="\n".join(lines), channel=msg.channel,
chat_id=msg.chat_id,
content="\n".join(lines),
metadata={"render_as": "text"},
) )
await self.memory_consolidator.maybe_consolidate_by_tokens(session) await self.memory_consolidator.maybe_consolidate_by_tokens(session)
@@ -600,6 +607,19 @@ class AgentLoop:
session.messages.append(entry) session.messages.append(entry)
session.updated_at = datetime.now() session.updated_at = datetime.now()
async def process_direct_outbound(
self,
content: str,
session_key: str = "cli:direct",
channel: str = "cli",
chat_id: str = "direct",
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> OutboundMessage | None:
"""Process a message directly and return the outbound payload."""
await self._connect_mcp()
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
return await self._process_message(msg, session_key=session_key, on_progress=on_progress)
async def process_direct( async def process_direct(
self, self,
content: str, content: str,
@@ -609,7 +629,11 @@ class AgentLoop:
on_progress: Callable[[str], Awaitable[None]] | None = None, on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> str: ) -> str:
"""Process a message directly (for CLI or cron usage).""" """Process a message directly (for CLI or cron usage)."""
await self._connect_mcp() response = await self.process_direct_outbound(
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content) content,
response = await self._process_message(msg, session_key=session_key, on_progress=on_progress) session_key=session_key,
channel=channel,
chat_id=chat_id,
on_progress=on_progress,
)
return response.content if response else "" return response.content if response else ""

View File

@@ -131,17 +131,30 @@ def _render_interactive_ansi(render_fn) -> str:
return capture.get() return capture.get()
def _print_agent_response(response: str, render_markdown: bool) -> None: def _print_agent_response(
response: str,
render_markdown: bool,
metadata: dict | None = None,
) -> None:
"""Render assistant response with consistent terminal styling.""" """Render assistant response with consistent terminal styling."""
console = _make_console() console = _make_console()
content = response or "" content = response or ""
body = Markdown(content) if render_markdown else Text(content) body = _response_renderable(content, render_markdown, metadata)
console.print() console.print()
console.print(f"[cyan]{__logo__} nanobot[/cyan]") console.print(f"[cyan]{__logo__} nanobot[/cyan]")
console.print(body) console.print(body)
console.print() console.print()
def _response_renderable(content: str, render_markdown: bool, metadata: dict | None = None):
"""Render plain-text command output without markdown collapsing newlines."""
if not render_markdown:
return Text(content)
if (metadata or {}).get("render_as") == "text":
return Text(content)
return Markdown(content)
async def _print_interactive_line(text: str) -> None: async def _print_interactive_line(text: str) -> None:
"""Print async interactive updates with prompt_toolkit-safe Rich styling.""" """Print async interactive updates with prompt_toolkit-safe Rich styling."""
def _write() -> None: def _write() -> None:
@@ -153,7 +166,11 @@ async def _print_interactive_line(text: str) -> None:
await run_in_terminal(_write) await run_in_terminal(_write)
async def _print_interactive_response(response: str, render_markdown: bool) -> None: async def _print_interactive_response(
response: str,
render_markdown: bool,
metadata: dict | None = None,
) -> None:
"""Print async interactive replies with prompt_toolkit-safe Rich styling.""" """Print async interactive replies with prompt_toolkit-safe Rich styling."""
def _write() -> None: def _write() -> None:
content = response or "" content = response or ""
@@ -161,7 +178,7 @@ async def _print_interactive_response(response: str, render_markdown: bool) -> N
lambda c: ( lambda c: (
c.print(), c.print(),
c.print(f"[cyan]{__logo__} nanobot[/cyan]"), c.print(f"[cyan]{__logo__} nanobot[/cyan]"),
c.print(Markdown(content) if render_markdown else Text(content)), c.print(_response_renderable(content, render_markdown, metadata)),
c.print(), c.print(),
) )
) )
@@ -750,9 +767,17 @@ def agent(
nonlocal _thinking nonlocal _thinking
_thinking = _ThinkingSpinner(enabled=not logs) _thinking = _ThinkingSpinner(enabled=not logs)
with _thinking: with _thinking:
response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) response = await agent_loop.process_direct_outbound(
message,
session_id,
on_progress=_cli_progress,
)
_thinking = None _thinking = None
_print_agent_response(response, render_markdown=markdown) _print_agent_response(
response.content if response else "",
render_markdown=markdown,
metadata=response.metadata if response else None,
)
await agent_loop.close_mcp() await agent_loop.close_mcp()
asyncio.run(run_once()) asyncio.run(run_once())
@@ -787,7 +812,7 @@ def agent(
bus_task = asyncio.create_task(agent_loop.run()) bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event() turn_done = asyncio.Event()
turn_done.set() turn_done.set()
turn_response: list[str] = [] turn_response: list[tuple[str, dict]] = []
async def _consume_outbound(): async def _consume_outbound():
while True: while True:
@@ -805,10 +830,14 @@ def agent(
elif not turn_done.is_set(): elif not turn_done.is_set():
if msg.content: if msg.content:
turn_response.append(msg.content) turn_response.append((msg.content, dict(msg.metadata or {})))
turn_done.set() turn_done.set()
elif msg.content: elif msg.content:
await _print_interactive_response(msg.content, render_markdown=markdown) await _print_interactive_response(
msg.content,
render_markdown=markdown,
metadata=msg.metadata,
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
@@ -848,7 +877,8 @@ def agent(
_thinking = None _thinking = None
if turn_response: if turn_response:
_print_agent_response(turn_response[0], render_markdown=markdown) content, meta = turn_response[0]
_print_agent_response(content, render_markdown=markdown, metadata=meta)
except KeyboardInterrupt: except KeyboardInterrupt:
_restore_terminal() _restore_terminal()
console.print("\nGoodbye!") console.print("\nGoodbye!")

View File

@@ -111,3 +111,33 @@ async def test_print_interactive_progress_line_pauses_spinner_before_printing():
await commands._print_interactive_progress_line("tool running", thinking) await commands._print_interactive_progress_line("tool running", thinking)
assert order == ["start", "stop", "print", "start", "stop"] assert order == ["start", "stop", "print", "start", "stop"]
def test_response_renderable_uses_text_for_explicit_plain_rendering():
status = (
"🐈 nanobot v0.1.4.post5\n"
"🧠 Model: MiniMax-M2.7\n"
"📊 Tokens: 20639 in / 29 out"
)
renderable = commands._response_renderable(
status,
render_markdown=True,
metadata={"render_as": "text"},
)
assert renderable.__class__.__name__ == "Text"
def test_response_renderable_preserves_normal_markdown_rendering():
renderable = commands._response_renderable("**bold**", render_markdown=True)
assert renderable.__class__.__name__ == "Markdown"
def test_response_renderable_without_metadata_keeps_markdown_path():
help_text = "🐈 nanobot commands:\n/status — Show bot status\n/help — Show available commands"
renderable = commands._response_renderable(help_text, render_markdown=True)
assert renderable.__class__.__name__ == "Markdown"

View File

@@ -115,6 +115,7 @@ class TestRestartCommand:
assert response is not None assert response is not None
assert "/restart" in response.content assert "/restart" in response.content
assert "/status" in response.content assert "/status" in response.content
assert response.metadata == {"render_as": "text"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_status_reports_runtime_info(self): async def test_status_reports_runtime_info(self):
@@ -122,9 +123,11 @@ class TestRestartCommand:
session = MagicMock() session = MagicMock()
session.get_history.return_value = [{"role": "user"}] * 3 session.get_history.return_value = [{"role": "user"}] * 3
loop.sessions.get_or_create.return_value = session loop.sessions.get_or_create.return_value = session
loop.subagents.get_running_count.return_value = 2
loop._start_time = time.time() - 125 loop._start_time = time.time() - 125
loop._last_usage = {"prompt_tokens": 1200, "completion_tokens": 34} loop._last_usage = {"prompt_tokens": 0, "completion_tokens": 0}
loop.memory_consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(20500, "tiktoken")
)
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status") msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
@@ -132,12 +135,11 @@ class TestRestartCommand:
assert response is not None assert response is not None
assert "Model: test-model" in response.content assert "Model: test-model" in response.content
assert "Tokens: 1200 in / 34 out" in response.content assert "Tokens: 0 in / 0 out" in response.content
assert "Context: 1k/64k (1%)" in response.content assert "Context: 20k/64k (31%)" in response.content
assert "Session: 3 messages" in response.content assert "Session: 3 messages" in response.content
assert "Subagents: 2 active" in response.content
assert "Queue: 0 pending" in response.content
assert "Uptime: 2m 5s" in response.content assert "Uptime: 2m 5s" in response.content
assert response.metadata == {"render_as": "text"}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_agent_loop_resets_usage_when_provider_omits_it(self): async def test_run_agent_loop_resets_usage_when_provider_omits_it(self):
@@ -152,3 +154,35 @@ class TestRestartCommand:
await loop._run_agent_loop([]) await loop._run_agent_loop([])
assert loop._last_usage == {"prompt_tokens": 0, "completion_tokens": 0} assert loop._last_usage == {"prompt_tokens": 0, "completion_tokens": 0}
@pytest.mark.asyncio
async def test_status_falls_back_to_last_usage_when_context_estimate_missing(self):
loop, _bus = _make_loop()
session = MagicMock()
session.get_history.return_value = [{"role": "user"}]
loop.sessions.get_or_create.return_value = session
loop._last_usage = {"prompt_tokens": 1200, "completion_tokens": 34}
loop.memory_consolidator.estimate_session_prompt_tokens = MagicMock(
return_value=(0, "none")
)
response = await loop._process_message(
InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
)
assert response is not None
assert "Tokens: 1200 in / 34 out" in response.content
assert "Context: 1k/64k (1%)" in response.content
@pytest.mark.asyncio
async def test_process_direct_outbound_preserves_render_metadata(self):
loop, _bus = _make_loop()
session = MagicMock()
session.get_history.return_value = []
loop.sessions.get_or_create.return_value = session
loop.subagents.get_running_count.return_value = 0
response = await loop.process_direct_outbound("/status", session_key="cli:test")
assert response is not None
assert response.metadata == {"render_as": "text"}