Files
wehub-resource-sync e4dcfc49aa
Tests / Lint and Format (push) Waiting to run
Tests / Web Node Tests (push) Waiting to run
Tests / Import Check (Python 3.11) (push) Waiting to run
Tests / Import Check (Python 3.12) (push) Waiting to run
Tests / Import Check (Python 3.13) (push) Waiting to run
Tests / Import Check (Python 3.14) (push) Waiting to run
Tests / Python Tests (Python 3.11) (push) Blocked by required conditions
Tests / Python Tests (Python 3.12) (push) Blocked by required conditions
Tests / Python Tests (Python 3.13) (push) Blocked by required conditions
Tests / Python Tests (Python 3.14) (push) Blocked by required conditions
Tests / Test Summary (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

860 lines
31 KiB
Python

"""Shared CLI helpers."""
from __future__ import annotations
import asyncio
import contextlib
import json
from pathlib import Path
import signal
import sys
from typing import Any, Callable
from rich.console import Console
from rich.markdown import Markdown
from rich.markup import escape
from rich.status import Status
from rich.table import Table
from rich.text import Text
from deeptutor.app import DeepTutorApp, TurnRequest
from ._tool_result import ToolResultBuffer, ToolResultEntry
console = Console()
# Process-wide buffer that backs the ``/show`` REPL command. The buffer
# lives at module scope so a single ``deeptutor chat`` session shares one
# ring across turns; ``deeptutor run`` doesn't read it (single-shot mode),
# but populating it is harmless.
tool_results = ToolResultBuffer()
# Content call_kinds whose ``call_status: running`` marker drives the
# spinner instead of printing a progress line (one LLM round each).
_LLM_ROUND_CALL_KINDS = frozenset({"agent_loop_round", "llm_final_response"})
class TurnInterrupted(Exception):
"""User aborted the running turn (Ctrl-C / Ctrl-D at a prompt)."""
def parse_config_items(items: list[str]) -> dict[str, Any]:
config: dict[str, Any] = {}
for item in items:
key, sep, raw_value = item.partition("=")
if not sep or not key.strip():
raise ValueError(f"Invalid --config item `{item}`. Expected KEY=VALUE.")
config[key.strip()] = _parse_scalar_value(raw_value.strip())
return config
def parse_json_object(raw: str | None) -> dict[str, Any]:
normalized = (raw or "").strip()
if not normalized:
return {}
try:
value = json.loads(normalized)
except (json.JSONDecodeError, TypeError) as exc:
raise ValueError(f"Invalid JSON config: {exc}") from exc
if not isinstance(value, dict):
raise ValueError("JSON config must be an object.")
return value
def parse_notebook_references(items: list[str]) -> list[dict[str, Any]]:
refs: list[dict[str, Any]] = []
for item in items:
notebook_id, _, record_part = item.partition(":")
resolved_notebook_id = notebook_id.strip()
if not resolved_notebook_id:
raise ValueError(f"Invalid notebook reference `{item}`.")
record_ids = [
record_id.strip() for record_id in record_part.split(",") if record_id.strip()
]
refs.append({"notebook_id": resolved_notebook_id, "record_ids": record_ids})
return refs
async def run_turn_and_render(
*,
app: DeepTutorApp,
request: TurnRequest,
fmt: str,
) -> tuple[dict[str, Any], dict[str, Any]]:
session, turn = await app.start_turn(request)
if fmt == "json":
await stream_turn_as_json(app=app, turn_id=turn["id"])
return session, turn
summary = await render_turn_stream(app=app, turn_id=turn["id"])
console.print(
f"[dim]session={session['id']} turn={turn['id']} "
f"capability={request.capability}{summary}[/]",
highlight=False,
)
return session, turn
async def regenerate_and_render(
*,
app: DeepTutorApp,
session_id: str,
capability: str = "chat",
fmt: str = "rich",
) -> tuple[dict[str, Any], dict[str, Any]] | None:
try:
session, turn = await app.regenerate_last_turn(session_id)
except RuntimeError as exc:
reason = str(exc)
if reason == "regenerate_busy":
console.print(
"[yellow]Cannot regenerate while another turn is running. "
"Wait for it to finish or cancel it first.[/]"
)
elif reason == "nothing_to_regenerate":
console.print("[yellow]Nothing to regenerate yet — send a message first.[/]")
else:
console.print(f"[red]Regenerate failed:[/] {reason}")
return None
if fmt == "json":
await stream_turn_as_json(app=app, turn_id=turn["id"])
return session, turn
summary = await render_turn_stream(app=app, turn_id=turn["id"])
console.print(
f"[dim]session={session['id']} turn={turn['id']} "
f"capability={capability}{summary} (regenerated)[/]",
highlight=False,
)
return session, turn
async def stream_turn_as_json(*, app: DeepTutorApp, turn_id: str) -> None:
"""NDJSON passthrough for ``--format json``.
``ask_user`` pauses are auto-resolved with an empty reply so headless
runs cannot hang on a question no one will answer — the model sees
"(empty reply)" as the tool result and must proceed on its own.
"""
async for item in app.stream_turn(turn_id):
# One event per line: rich wraps long lines at terminal width and
# interprets ``[...]`` as markup, both of which corrupt NDJSON.
console.print(
json.dumps(item, ensure_ascii=False),
soft_wrap=True,
markup=False,
highlight=False,
)
if _ask_user_payload(item) is not None:
await app.submit_user_reply(turn_id, text="")
async def render_turn_stream(*, app: DeepTutorApp, turn_id: str) -> str:
"""Render a turn's event stream; returns a ``key=value`` summary suffix
(rounds / tools / tokens / cost) for the caller's session line.
Ctrl-C while the turn is streaming cancels the turn server-side and
returns control to the caller (the REPL keeps running) instead of
unwinding the whole CLI. While an ``ask_user`` prompt is on screen the
default KeyboardInterrupt behaviour is restored so Ctrl-C aborts the
prompt (and with it the turn).
"""
task = asyncio.current_task()
interrupted = False
def _on_sigint() -> None:
nonlocal interrupted
if interrupted:
return
interrupted = True
if task is not None:
task.cancel()
sigint = _SigintInterceptor(_on_sigint)
renderer = TurnStreamRenderer(app=app, turn_id=turn_id, sigint=sigint)
sigint.resume()
try:
async for item in app.stream_turn(turn_id):
await renderer.handle(item)
except TurnInterrupted:
await _cancel_interrupted_turn(app=app, turn_id=turn_id, renderer=renderer)
except asyncio.CancelledError:
if not interrupted:
renderer.close()
raise
# Our own SIGINT handler cancelled us; absorb the cancellation so
# the REPL survives, then cancel the turn server-side. Drain the
# whole cancel count — a hammered Ctrl-C must not leave a pending
# cancellation that detonates at the next await.
if task is not None:
while task.cancelling() > 0:
task.uncancel()
await _cancel_interrupted_turn(app=app, turn_id=turn_id, renderer=renderer)
finally:
sigint.suspend()
renderer.close()
return renderer.summary_suffix()
async def _cancel_interrupted_turn(
*,
app: DeepTutorApp,
turn_id: str,
renderer: "TurnStreamRenderer",
) -> None:
renderer.abort()
with contextlib.suppress(Exception):
await app.cancel_turn(turn_id)
console.print("\n[dim]Interrupted — turn cancelled.[/]")
class _SigintInterceptor:
"""Route Ctrl-C to a callback while a turn is streaming (POSIX only).
``suspend``/``resume`` bracket blocking prompts so ``console.input``
gets the default KeyboardInterrupt behaviour back (an installed
asyncio handler never fires while the loop is blocked in a read).
On platforms without ``add_signal_handler`` (Windows) this is a no-op
and Ctrl-C falls through to ``maybe_run``'s graceful exit.
"""
def __init__(self, on_sigint: Callable[[], None]) -> None:
self._on_sigint = on_sigint
self._installed = False
def resume(self) -> None:
if self._installed:
return
try:
asyncio.get_running_loop().add_signal_handler(signal.SIGINT, self._on_sigint)
self._installed = True
except (NotImplementedError, RuntimeError, ValueError, AttributeError):
self._installed = False
def suspend(self) -> None:
if not self._installed:
return
with contextlib.suppress(Exception):
asyncio.get_running_loop().remove_signal_handler(signal.SIGINT)
self._installed = False
def _stdin_interactive() -> bool:
try:
return sys.stdin is not None and sys.stdin.isatty()
except Exception:
return False
def _ask_user_payload(item: dict[str, Any]) -> dict[str, Any] | None:
"""The ``ask_user`` question payload carried by a tool_result event."""
if str(item.get("type", "")) != "tool_result":
return None
metadata = item.get("metadata") or {}
tool_meta = metadata.get("tool_metadata")
if not isinstance(tool_meta, dict):
return None
ask = tool_meta.get("ask_user")
if isinstance(ask, dict) and ask.get("questions"):
return ask
return None
class TurnStreamRenderer:
"""State machine that renders one turn's event stream.
The chat agent loop streams EVERY round's text as ``content`` chunks
and only labels the round once it completes — a ``call_status`` marker
whose ``call_role`` says whether that text was ``narration`` (preamble
to tool calls; rendered dim, in place, before its tools) or the
``finish`` (the user-facing answer; rendered as Markdown). Chunks are
therefore buffered per ``call_id`` and settled when the round's marker
arrives — the marker is emitted before the round's tool calls
dispatch, so terminal order matches the model's order.
Content without that trace metadata (other capabilities) keeps the
legacy behaviour: buffer and render at stage boundaries / done.
"""
def __init__(
self,
*,
app: DeepTutorApp,
turn_id: str,
sigint: _SigintInterceptor | None = None,
) -> None:
self.app = app
self.turn_id = turn_id
self._sigint = sigint
self._legacy_buf = ""
self._round_bufs: dict[str, str] = {}
self._round_order: list[str] = []
self._thinking_bufs: dict[str, str] = {}
self._thinking_indicated: set[str] = set()
self._status: Status | None = None
self._sources: list[dict[str, Any]] = []
self._result_meta: dict[str, Any] = {}
async def handle(self, item: dict[str, Any]) -> None:
handler = getattr(self, f"_on_{str(item.get('type', ''))}", None)
if handler is not None:
await handler(item)
# ---- lifecycle -------------------------------------------------------
def abort(self) -> None:
"""Settle whatever already streamed (the backend persists the same
partial text on cancel), then stop the spinner."""
with contextlib.suppress(Exception):
self._flush_pending()
self._status_stop()
def close(self) -> None:
self._status_stop()
def summary_suffix(self) -> str:
meta = self._result_meta
parts: list[str] = []
rounds = meta.get("rounds")
if isinstance(rounds, int) and rounds > 0:
parts.append(f"rounds={rounds}")
tool_steps = meta.get("tool_steps")
if isinstance(tool_steps, int) and tool_steps > 0:
parts.append(f"tools={tool_steps}")
cost = (meta.get("metadata") or {}).get("cost_summary") or {}
tokens = cost.get("total_tokens")
if isinstance(tokens, (int, float)) and tokens > 0:
parts.append(f"tokens={_format_tokens(int(tokens))}")
cost_usd = cost.get("total_cost_usd")
if isinstance(cost_usd, (int, float)) and cost_usd > 0:
parts.append(f"cost=${cost_usd:.4f}")
return (" " + " ".join(parts)) if parts else ""
# ---- event handlers --------------------------------------------------
async def _on_stage_start(self, item: dict[str, Any]) -> None:
self._flush_pending()
stage = str(item.get("stage", "") or "")
if str(item.get("source", "")) == "chat" and stage == "responding":
# The chat loop is one wrapper stage; a banner adds nothing.
return
self._status_stop()
console.print(f"\n[bold cyan]▶ {stage or 'working'}[/]", highlight=False)
async def _on_stage_end(self, item: dict[str, Any]) -> None:
self._flush_pending()
async def _on_thinking(self, item: dict[str, Any]) -> None:
metadata = item.get("metadata") or {}
call_id = str(metadata.get("call_id") or "")
text = str(item.get("content", "") or "")
if not call_id:
# Legacy capabilities emit coarse-grained thinking lines.
if text.strip():
console.print(f" [dim]{escape(text)}[/]", highlight=False)
return
# Chat-loop reasoning streams chunk-by-chunk: collapse it to one
# indicator and stash the full text for ``/show`` at settle time.
self._thinking_bufs[call_id] = self._thinking_bufs.get(call_id, "") + text
if call_id not in self._thinking_indicated:
self._thinking_indicated.add(call_id)
if self._status is not None:
self._status.update("[dim]thinking…[/]")
console.print(" [dim]✻ thinking…[/]", highlight=False)
else:
console.print(" [dim]✻ thinking…[/]", highlight=False)
async def _on_progress(self, item: dict[str, Any]) -> None:
metadata = item.get("metadata") or {}
content = str(item.get("content", "") or "")
if metadata.get("trace_kind") == "call_status":
await self._on_call_status(content, metadata)
return
if not content.strip():
return
console.print(f" [dim]{escape(content)}[/]", highlight=False)
async def _on_call_status(self, content: str, metadata: dict[str, Any]) -> None:
call_state = str(metadata.get("call_state") or "")
if call_state == "complete":
call_role = str(metadata.get("call_role") or "")
if call_role in {"narration", "finish"}:
self._settle_round(str(metadata.get("call_id") or ""), role=call_role)
return
if content.strip():
console.print(f" [dim]{escape(content)}[/]", highlight=False)
return
if metadata.get("call_kind") in _LLM_ROUND_CALL_KINDS:
# One LLM round starting — show liveness without a printed line.
self._status_start(content.strip() or "working")
return
if content.strip():
console.print(f" [dim]{escape(content)}[/]", highlight=False)
async def _on_content(self, item: dict[str, Any]) -> None:
metadata = item.get("metadata") or {}
text = str(item.get("content", "") or "")
if not text:
return
call_id = str(metadata.get("call_id") or "")
trace_kind = str(metadata.get("trace_kind") or "")
if call_id and trace_kind == "llm_chunk":
if call_id not in self._round_bufs:
self._round_bufs[call_id] = ""
self._round_order.append(call_id)
self._round_bufs[call_id] += text
if self._status is not None:
self._status.update("[dim]writing…[/]")
return
if trace_kind == "llm_output":
# Whole-text emission (terminator tool / section / fallback).
# Flush buffered chunks first so blocks keep the model's order.
self._flush_pending()
self._status_stop()
console.print(Markdown(text))
return
self._legacy_buf += text
async def _on_tool_call(self, item: dict[str, Any]) -> None:
_render_tool_call(item)
async def _on_tool_result(self, item: dict[str, Any]) -> None:
ask = _ask_user_payload(item)
if ask is not None:
await self._handle_ask_user(ask)
return
_render_tool_result(item)
async def _on_error(self, item: dict[str, Any]) -> None:
self._status_stop()
console.print(f"[bold red]Error:[/] {escape(str(item.get('content', '') or ''))}")
async def _on_sources(self, item: dict[str, Any]) -> None:
entries = (item.get("metadata") or {}).get("sources")
if isinstance(entries, list):
self._sources.extend(entry for entry in entries if isinstance(entry, dict))
async def _on_result(self, item: dict[str, Any]) -> None:
metadata = item.get("metadata")
if isinstance(metadata, dict):
self._result_meta = metadata
async def _on_done(self, item: dict[str, Any]) -> None:
self._flush_pending()
self._status_stop()
self._print_sources()
async def _on_wait_for_input(self, item: dict[str, Any]) -> None:
"""Legacy in-band input request (``StreamBus.wait_for_input``)."""
from deeptutor.core.stream_bus import get_bus
self._status_stop()
bus = get_bus(self.turn_id)
if bus is None:
return
if not _stdin_interactive():
bus.submit_input("")
return
prompt = str(item.get("content", "") or "").strip()
if prompt:
console.print(f"\n [bold cyan]?[/] {escape(prompt)}", highlight=False)
raw = self._read_line(" [bold green]answer>[/] ")
if raw is None:
raise TurnInterrupted
bus.submit_input(raw)
# ---- ask_user --------------------------------------------------------
async def _handle_ask_user(self, ask: dict[str, Any]) -> None:
self._status_stop()
if not _stdin_interactive():
console.print(
" [yellow]●[/] [dim]ask_user: stdin is not interactive — sending an "
"empty reply so the turn can continue.[/]",
highlight=False,
)
await self.app.submit_user_reply(self.turn_id, text="")
return
answers = self._prompt_ask_user(ask)
if answers is None:
raise TurnInterrupted
await self.app.submit_user_reply(self.turn_id, answers=answers)
def _prompt_ask_user(self, ask: dict[str, Any]) -> list[dict[str, str]] | None:
"""Render the question card and collect one answer per question.
Returns ``None`` when the user aborts (Ctrl-C / Ctrl-D) — the
caller cancels the turn, mirroring the web composer's stop button.
"""
questions = [q for q in (ask.get("questions") or []) if isinstance(q, dict)]
if not questions:
return []
console.print()
console.print("[bold cyan]?[/] [bold]The model needs your input[/]", highlight=False)
intro = str(ask.get("intro") or "").strip()
if intro:
console.print(f" {escape(intro)}", highlight=False)
answers: list[dict[str, str]] = []
for index, question in enumerate(questions):
reply = self._prompt_one_question(question, index=index, total=len(questions))
if reply is None:
return None
answers.append(
{"questionId": str(question.get("id") or f"q{index + 1}"), "text": reply}
)
return answers
def _prompt_one_question(
self,
question: dict[str, Any],
*,
index: int,
total: int,
) -> str | None:
prompt = str(question.get("prompt") or "").strip()
header = str(question.get("header") or "").strip()
numbering = f"({index + 1}/{total}) " if total > 1 else ""
chip = f"[cyan]{escape(header)}[/] " if header else ""
console.print(f"\n {numbering}{chip}{escape(prompt)}", highlight=False)
labels: list[str] = []
for option in question.get("options") or []:
if not isinstance(option, dict):
continue
label = str(option.get("label") or "").strip()
if not label:
continue
labels.append(label)
description = str(option.get("description") or "").strip()
suffix = f" [dim]— {escape(description)}[/]" if description else ""
console.print(f" {len(labels)}. {escape(label)}{suffix}", highlight=False)
multi = bool(question.get("multi_select"))
hint = _answer_hint(
has_options=bool(labels),
multi=multi,
placeholder=str(question.get("placeholder") or "").strip(),
)
raw = self._read_line(f" [bold green]{hint}>[/] ")
if raw is None:
return None
return _resolve_answer(raw, labels, multi)
def _read_line(self, prompt: str) -> str | None:
"""Blocking prompt with default Ctrl-C semantics; ``None`` = abort."""
if self._sigint is not None:
self._sigint.suspend()
try:
return console.input(prompt)
except (KeyboardInterrupt, EOFError):
return None
finally:
if self._sigint is not None:
self._sigint.resume()
# ---- rendering internals ---------------------------------------------
def _settle_round(self, call_id: str, *, role: str) -> None:
"""Render a completed chat-loop round's buffered text.
``narration`` stays dim and lands before the round's tool lines
(the marker precedes tool dispatch); ``finish`` is the answer.
"""
text = self._round_bufs.pop(call_id, "")
if call_id in self._round_order:
self._round_order.remove(call_id)
thinking = self._thinking_bufs.pop(call_id, "").strip()
if thinking:
tool_results.remember("thinking", thinking)
body = text.strip()
if not body:
return
self._status_stop()
if role == "narration":
console.print(Text(body, style="dim"))
else:
console.print(Markdown(text))
def _flush_pending(self) -> None:
"""Render any unsettled buffered text as answer Markdown.
Chat rounds normally settle via their ``call_role`` marker; this is
the stage-boundary / done / llm_output fallback so no text is ever
dropped (and so other capabilities keep their old flush points).
"""
for call_id in list(self._round_order):
self._settle_round(call_id, role="finish")
if self._legacy_buf:
self._status_stop()
console.print(Markdown(self._legacy_buf))
self._legacy_buf = ""
def _print_sources(self) -> None:
if not self._sources:
return
seen: set[str] = set()
rows: list[str] = []
for source in self._sources:
title = str(
source.get("title")
or source.get("filename")
or source.get("file_name")
or source.get("source")
or source.get("url")
or ""
).strip()
location = str(source.get("url") or source.get("file_path") or "").strip()
key = location or title
if not key or key in seen:
continue
seen.add(key)
row = title or location
if location and location != row:
row = f"{row}{location}"
rows.append(row)
if not rows:
return
shown = rows[:8]
console.print(f"[dim]sources ({len(rows)}):[/]", highlight=False)
for index, row in enumerate(shown):
console.print(f" [dim][{index + 1}] {escape(row)}[/]", highlight=False)
if len(rows) > len(shown):
console.print(f" [dim]… +{len(rows) - len(shown)} more[/]", highlight=False)
def _status_start(self, label: str) -> None:
if not console.is_terminal:
return
text = f"[dim]{escape(label)}[/]"
if self._status is None:
self._status = console.status(text, spinner="dots")
self._status.start()
else:
self._status.update(text)
def _status_stop(self) -> None:
if self._status is not None:
with contextlib.suppress(Exception):
self._status.stop()
self._status = None
def _answer_hint(*, has_options: bool, multi: bool, placeholder: str) -> str:
if has_options and multi:
return "answer (numbers/text, comma-separated; Enter to skip)"
if has_options:
return "answer (number or text; Enter to skip)"
if placeholder:
return f"answer ({escape(placeholder)}; Enter to skip)"
return "answer (Enter to skip)"
def _resolve_answer(raw: str, labels: list[str], multi: bool) -> str:
"""Map ``1``-style selections onto option labels; pass text through.
Multi-select accepts comma-separated tokens, each a number or free
text; results join with ", " (the reply travels as one string).
"""
cleaned = raw.strip()
if not cleaned:
return ""
tokens = [t.strip() for t in cleaned.split(",") if t.strip()] if multi else [cleaned]
resolved: list[str] = []
for token in tokens:
if token.isdigit() and labels and 1 <= int(token) <= len(labels):
resolved.append(labels[int(token) - 1])
else:
resolved.append(token)
return ", ".join(resolved)
def _format_tokens(value: int) -> str:
if value >= 1000:
return f"{value / 1000:.1f}k"
return str(value)
def _render_tool_call(item: dict[str, Any]) -> None:
"""Print a one-line tool-call header. Long arg payloads are summarised
so the call stays scannable; the full body lands in tool_result if the
tool echoes it back, or in the stream metadata for debug tooling."""
tool_name = str(item.get("content", "") or "tool")
metadata = item.get("metadata", {}) or {}
args = metadata.get("args", {})
# Budget the args summary so the whole header — " ● <name>(<args>)" —
# fits the current terminal width on one line. We pick a soft floor so
# very narrow terminals still get something useful.
overhead = len(f" ● {tool_name}()")
budget = max(20, (console.width or 100) - overhead)
summary = _summarize_call_args(args, max_len=budget)
if summary:
console.print(f" [yellow]●[/] {tool_name}([dim]{escape(summary)}[/])", highlight=False)
else:
console.print(f" [yellow]●[/] {tool_name}", highlight=False)
def _render_tool_result(item: dict[str, Any]) -> None:
"""Print a truncated preview of a tool result, stashing the full text
in the shared :data:`tool_results` buffer so ``/show`` can expand it."""
body = str(item.get("content", "") or "")
metadata = item.get("metadata", {}) or {}
label = str(metadata.get("tool") or "tool")
entry = tool_results.remember(label, body)
head, hidden = tool_results.truncate(body)
# Empty result still gets a marker so the user can see the call closed.
if not head.strip() and not hidden:
console.print(
f" [green]└[/] [dim]#{entry.index} {label} → (empty result)[/]", highlight=False
)
return
if head:
for line in head.split("\n"):
console.print(f" [green]│[/] {escape(line)}", highlight=False)
if hidden:
console.print(
f" [green]└[/] [dim]#{entry.index} {label} — +{hidden} more line"
f"{'s' if hidden != 1 else ''}; "
f"run [bold]/show {entry.index}[/] (or [bold]/show last[/]) to expand[/]",
highlight=False,
)
else:
console.print(f" [green]└[/] [dim]#{entry.index} {label}[/]", highlight=False)
def _summarize_call_args(args: Any, max_len: int = 120) -> str:
"""Render call args as a short ``key=value, …`` string.
The full rendering is assembled first, then a single trailing-ellipsis
clip is applied so we never leave a dangling ``", "`` at the end when
the last key's value runs over the budget.
"""
if isinstance(args, dict) and args:
rendered = ", ".join(f"{key}={_one_line(value)}" for key, value in args.items())
elif args:
rendered = _one_line(args)
else:
return ""
if len(rendered) > max_len:
return rendered[: max_len - 1].rstrip(", ") + "…"
return rendered
def _one_line(value: Any) -> str:
"""Compact one-line repr for a single arg value. No truncation here —
the caller's overall budget handles that uniformly so we don't double-
clip a dict and end up with a half-finished key=value pair."""
if isinstance(value, str):
text = value
else:
try:
text = json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
text = repr(value)
return text.replace("\n", " ")
def render_tool_result_entry(entry: ToolResultEntry) -> None:
"""Fully print a stored tool result. Backs the ``/show`` REPL command."""
from rich.panel import Panel
console.print(
Panel(
escape(entry.body) if entry.body else "[dim](empty result)[/]",
title=f"#{entry.index} {entry.label}",
border_style="green",
),
highlight=False,
)
def build_turn_request(
*,
content: str,
capability: str,
session_id: str | None,
tools: list[str],
knowledge_bases: list[str],
language: str,
config_items: list[str],
config_json: str | None,
notebook_refs: list[str],
history_refs: list[str],
) -> TurnRequest:
config = parse_json_object(config_json)
config.update(parse_config_items(config_items))
return TurnRequest(
content=content,
capability=capability,
session_id=session_id,
tools=tools,
knowledge_bases=knowledge_bases,
language=language,
config=config,
notebook_references=parse_notebook_references(notebook_refs),
history_references=[item.strip() for item in history_refs if item.strip()],
)
def maybe_run(coro): # noqa: ANN001
try:
return asyncio.run(coro)
except KeyboardInterrupt:
console.print("\n[dim]Interrupted.[/]")
return None
def print_session_table(sessions: list[dict[str, Any]]) -> None:
table = Table(title="Sessions")
table.add_column("ID")
table.add_column("Title")
table.add_column("Capability")
table.add_column("Status")
table.add_column("Messages", justify="right")
for session in sessions:
table.add_row(
str(session.get("id", "")),
str(session.get("title", "")),
str(session.get("capability", "") or "chat"),
str(session.get("status", "")),
str(session.get("message_count", 0)),
)
console.print(table)
def print_notebook_table(notebooks: list[dict[str, Any]]) -> None:
table = Table(title="Notebooks")
table.add_column("ID")
table.add_column("Name")
table.add_column("Records", justify="right")
table.add_column("Description")
for notebook in notebooks:
table.add_row(
str(notebook.get("id", "")),
str(notebook.get("name", "")),
str(notebook.get("record_count", 0)),
str(notebook.get("description", "")),
)
console.print(table)
def print_path_result(path: str | Path) -> None:
console.print(f"[dim]{Path(path).resolve()}[/]")
def _parse_scalar_value(raw_value: str) -> Any:
lowered = raw_value.lower()
if lowered in {"true", "false"}:
return lowered == "true"
if lowered in {"null", "none"}:
return None
try:
return json.loads(raw_value)
except (json.JSONDecodeError, TypeError):
return raw_value