"""Terminal chat: live reasoning stream plus SmolTalk tool-call rounds. Tercet-R assistant turns may contain: - a zero-loss `<|think|>` / `<|no_think|>` control prefix - a `` reasoning block - one or more SmolTalk JSON `` blocks (NVIDIA XML is also parsed) This module colours those regions as tokens arrive and, after a completed turn, collects tool observations (prefixed with `<|tool_response|>`) so the model can continue the same conversation. """ from __future__ import annotations import json import sys from collections.abc import Callable, Sequence from dataclasses import dataclass, field from typing import Any, Literal, TextIO from tiny_gdn.smoltalk_chat import ( SMOLTALK_WEB_SEARCH_TOOL, TOOL_RESPONSE_TOKEN, format_smoltalk_tool_call, wrap_smoltalk_tool_result, ) from tiny_gdn.tools import ParsedToolCall, parse_tool_calls SegmentKind = Literal["answer", "think", "tool_call"] MarkerKind = Literal[ "think_open", "think_close", "think_control", "no_think", "tool_open", "tool_close", ] THINK_OPEN = "" THINK_CLOSE = "" TOOL_CALL_OPEN = "" TOOL_CALL_CLOSE = "" THINK_CONTROL = "<|think|>" NO_THINK_CONTROL = "<|no_think|>" MARKERS: tuple[tuple[str, MarkerKind], ...] = ( (THINK_CLOSE, "think_close"), (TOOL_CALL_CLOSE, "tool_close"), (THINK_OPEN, "think_open"), (TOOL_CALL_OPEN, "tool_open"), (NO_THINK_CONTROL, "no_think"), (THINK_CONTROL, "think_control"), ) ANSI = { "think": "\033[2;33m", "tool_call": "\033[36m", "answer": "\033[0m", "reset": "\033[0m", } DEFAULT_MAX_TOOL_ROUNDS = 8 @dataclass(frozen=True) class ContentSegment: kind: SegmentKind text: str open: bool = False @dataclass(frozen=True) class GeneratedTurn: text: str token_count: int stop_reason: str tool_calls: tuple[ParsedToolCall, ...] def first_marker(text: str) -> tuple[int, str, MarkerKind] | None: best: tuple[int, str, MarkerKind] | None = None for marker, kind in MARKERS: at = text.find(marker) if at < 0: continue if ( best is None or at < best[0] or (at == best[0] and len(marker) > len(best[1])) ): best = (at, marker, kind) return best def holdback_prefix_length(text: str) -> int: if not text: return 0 keep = 0 for marker, _kind in MARKERS: limit = min(len(marker) - 1, len(text)) for size in range(1, limit + 1): if marker.startswith(text[-size:]): keep = max(keep, size) return keep def mode_after_marker(kind: MarkerKind, current: SegmentKind) -> SegmentKind: if kind in {"think_open", "think_control"}: return "think" if kind in {"think_close", "no_think", "tool_close"}: return "answer" if kind == "tool_open": return "tool_call" return current def split_assistant_segments(source: str) -> list[ContentSegment]: """Split a completed (or in-progress) assistant turn for tests / replay.""" segments: list[ContentSegment] = [] mode: SegmentKind = "answer" cursor = 0 while cursor < len(source): found = first_marker(source[cursor:]) if found is None: tail = source[cursor:] if tail: segments.append(ContentSegment(kind=mode, text=tail, open=True)) break at, marker, kind = found at += cursor if at > cursor: segments.append( ContentSegment(kind=mode, text=source[cursor:at], open=False) ) mode = mode_after_marker(kind, mode) cursor = at + len(marker) return [segment for segment in segments if segment.text] class LiveReasoningStreamer: """Colour reasoning and tool-call regions as decoded text grows.""" def __init__( self, writer: TextIO | None = None, *, color: bool | None = None, ) -> None: self.writer = writer if writer is not None else sys.stdout if color is None: color = bool(getattr(self.writer, "isatty", lambda: False)()) self.color = color self._seen = "" self._hold = "" self._mode: SegmentKind = "answer" self._style: SegmentKind | None = None self._emitted_think_label = False self._emitted_tool_label = False def update(self, decoded: str) -> None: if decoded.startswith(self._seen): delta = decoded[len(self._seen) :] else: delta = decoded self._seen = decoded if delta: self._consume(delta, final=False) def finish(self) -> str: if self._hold: self._emit(self._hold) self._hold = "" self._set_style(None) self.writer.write("\n") self.writer.flush() return self._seen def _consume(self, delta: str, *, final: bool) -> None: buffer = self._hold + delta self._hold = "" while buffer: found = first_marker(buffer) if found is None: keep = 0 if final else holdback_prefix_length(buffer) if keep: self._emit(buffer[:-keep]) self._hold = buffer[-keep:] else: self._emit(buffer) return at, marker, kind = found if at: self._emit(buffer[:at]) self._switch(kind) buffer = buffer[at + len(marker) :] if final: return def _switch(self, kind: MarkerKind) -> None: nxt = mode_after_marker(kind, self._mode) if nxt != self._mode and nxt == "answer": self._emit_plain("\n") self._mode = nxt if nxt == "think" and not self._emitted_think_label: self._emit_plain("\n") self._set_style("think") self._emit_plain("reasoning ") self._emitted_think_label = True elif nxt == "tool_call" and not self._emitted_tool_label: self._emit_plain("\n") self._set_style("tool_call") self._emit_plain("tool_call ") self._emitted_tool_label = True elif nxt == "answer": self._set_style("answer") def _emit(self, text: str) -> None: if not text: return self._set_style(self._mode) self.writer.write(text) self.writer.flush() def _emit_plain(self, text: str) -> None: if not text: return self._set_style(None) self.writer.write(text) self.writer.flush() def _set_style(self, kind: SegmentKind | None) -> None: if not self.color: self._style = kind return if kind == self._style: return self.writer.write(ANSI["reset"]) if kind in {"think", "tool_call"}: self.writer.write(ANSI[kind]) self._style = kind def prompt_tool_results( calls: Sequence[ParsedToolCall], *, read_line: Callable[[str], str], writer: TextIO | None = None, ) -> list[str]: out = writer if writer is not None else sys.stdout results: list[str] = [] out.write( f"\n{len(calls)} tool call(s). Paste each observation; " f"it is sent as {TOOL_RESPONSE_TOKEN}.\n" ) out.flush() for index, call in enumerate(calls, start=1): out.write(f"\n[{index}/{len(calls)}] {format_smoltalk_tool_call(call.name, call.arguments)}\n") out.flush() raw = read_line(f"result[{call.name}]> ") results.append(raw) return results def append_tool_round( messages: list[dict[str, Any]], assistant_text: str, raw_results: Sequence[str], ) -> None: messages.append({"role": "assistant", "content": assistant_text}) for raw in raw_results: wrapped = wrap_smoltalk_tool_result(raw) if not wrapped: raise ValueError("Tool result cannot be empty") messages.append({"role": "tool", "content": raw}) def resolve_cli_tools(spec: str | None, tools_json: str | None) -> list[dict[str, Any]] | None: tools: list[dict[str, Any]] = [] if spec: for name in spec.split(","): key = name.strip().lower() if not key or key in {"none", "off"}: continue if key == "web_search": tools.append(SMOLTALK_WEB_SEARCH_TOOL) continue raise ValueError( f"Unknown built-in tool {name!r}. Use web_search or --tools-json." ) if tools_json: payload = json.loads(tools_json) if isinstance(payload, dict): tools.append(payload) elif isinstance(payload, list): tools.extend(payload) else: raise ValueError("tools JSON must be an object or array") return tools or None @dataclass class ChatLoopState: messages: list[dict[str, Any]] = field(default_factory=list) system: str = "" enable_thinking: bool = True tools: list[dict[str, Any]] | None = None def reset(self) -> None: self.messages = [] if self.system.strip(): self.messages.append({"role": "system", "content": self.system.strip()}) def add_user(self, text: str) -> None: self.messages.append({"role": "user", "content": text}) def apply_slash_command(state: ChatLoopState, text: str) -> str | None: """Return a status string if `text` is a slash command, else None.""" command = text.strip() lowered = command.lower() if lowered in {"/exit", "/quit"}: return "exit" if lowered == "/reset": state.reset() return "history cleared" if lowered == "/think": if state.messages: return "thinking can only be changed on a fresh conversation (/reset first)" state.enable_thinking = True return "thinking on — next assistant turn is prefixed with <|think|>" if lowered in {"/no_think", "/nothink"}: if state.messages: return "thinking can only be changed on a fresh conversation (/reset first)" state.enable_thinking = False return "thinking off — next assistant turn is prefixed with <|no_think|>" if lowered.startswith("/system"): rest = command[len("/system") :].strip() state.system = rest state.reset() return "system prompt updated" if rest else "system prompt cleared" return None