Download tiny_gdn/cli_chat.py from kerzgrr/Tercet-R-1.0: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/kerzgrr/Tercet-R-1.0/resolve/main/tiny_gdn/cli_chat.py
- Command line
-
hf download hf://kerzgrr/Tercet-R-1.0/tiny_gdn/cli_chat.py
-
curl -L -o cli_chat.py https://huggingface.co/kerzgrr/Tercet-R-1.0/resolve/main/tiny_gdn/cli_chat.py
10.6 kB
| """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 `<think>…</think>` reasoning block | |
| - one or more SmolTalk JSON `<tool_call>` 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>" | |
| THINK_CLOSE = "</think>" | |
| TOOL_CALL_OPEN = "<tool_call>" | |
| TOOL_CALL_CLOSE = "</tool_call>" | |
| 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 | |
| class ContentSegment: | |
| kind: SegmentKind | |
| text: str | |
| open: bool = False | |
| 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 | |
| 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 | |