From 9198e6ede5fd17c73f7aae0e75dc63cb19238301 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 15:41:13 +0000 Subject: [PATCH] assembly live: drop client-side history window for deepagents summarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live cascade brain is a deepagents graph, and create_deep_agent already wires its own SummarizationMiddleware into the stack (summarize old turns, offload the evicted history to a file) — real context-window management. The engine's client-side sliding window (text.trim_history + config.max_history) was redundant in front of it, so this removes it: the engine now feeds the full untrimmed running history each turn and lets the graph compact it. max_history stays on CascadeConfig but only drives the hand-rolled --show-code / `assembly init` cascade, which talks to the gateway directly and has no middleware. text.trim_history is gone; split_sentences' inline boundary predicate is extracted into _ends_sentence (mirroring _is_boundary) to keep the module's average complexity at rank A after the helper's removal. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RgG91Q7U3j2pbJvyfTJa3X --- aai_cli/AGENTS.md | 2 +- aai_cli/agent_cascade/brain.py | 3 ++- aai_cli/agent_cascade/config.py | 6 +++++- aai_cli/agent_cascade/engine.py | 12 ++++++++---- aai_cli/agent_cascade/text.py | 29 ++++++++++++++++------------- tests/test_agent_cascade_config.py | 3 ++- tests/test_agent_cascade_reply.py | 18 ++++++++++++++---- tests/test_agent_cascade_text.py | 20 +------------------- 8 files changed, 49 insertions(+), 44 deletions(-) diff --git a/aai_cli/AGENTS.md b/aai_cli/AGENTS.md index 7efd884a..7dd31818 100644 --- a/aai_cli/AGENTS.md +++ b/aai_cli/AGENTS.md @@ -153,7 +153,7 @@ heavily-reworked commands with long bodies; small commands keep the inline - **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback. - **`core/sync_stt.py`** + **`core/signals.py`** + `commands/dictate/` — `assembly dictate`: headless dictation over the **Sync STT API** (`Environment.sync_base`, one POST `/transcribe` per utterance with the required `X-AAI-Model: u3-sync-pro` header; 80 ms–120 s of PCM/WAV). It needs no terminal: recording starts immediately and `dictate_exec._record` polls `signals.stop_on_terminate` between ~100 ms mic chunks for a SIGTERM, which finishes the utterance (clean exit 0) — so a hotkey tool like Hammerspoon can launch it as a background task and `kill -TERM`/`task:terminate()` to transcribe. SIGINT (Ctrl-C) still cancels (exit 130). Both boundaries (the stop latch, mic, HTTP) are injectable, so the suite never needs a real signal or microphone (`tests/test_dictate_exec.py` scripts the SIGTERM latch). Contrast `signals.terminate_as_interrupt` (used by `stream`/`agent`/`speak`), which routes SIGTERM into the *cancel* path instead. - **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`). -- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in, history window — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph (`brain.py`) streamed token-by-token via `brain.build_streamer` (`graph.stream(stream_mode="messages")`): the engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. Under `-v` (`debuglog.active()`) `brain._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (on by default; `--no-files` opts out) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`; `brain._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`, and binds one gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool; spec in `agent_cascade/subagents.py`, omitting `model`/`tools` so it inherits both) for delegating a focused subtask. The subagent's own `interrupt_on` mirrors `_WRITE_TOOLS`, and a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated. +- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph (`brain.py`) streamed token-by-token via `brain.build_streamer` (`graph.stream(stream_mode="messages")`): **context-window management is the brain's job, not the engine's** — `create_deep_agent` wires deepagents' own `SummarizationMiddleware` into the stack (summarize the oldest turns, offload the evicted history to a file), so the engine feeds the *full* untrimmed running history each turn and lets the graph compact it; the old client-side `text.trim_history`/`config.max_history` sliding window is gone from this path (`max_history` now only drives the hand-rolled `--show-code`/`assembly init` cascade, which doesn't use deepagents). The engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. Under `-v` (`debuglog.active()`) `brain._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (on by default; `--no-files` opts out) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`; `brain._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`, and binds one gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool; spec in `agent_cascade/subagents.py`, omitting `model`/`tools` so it inherits both) for delegating a focused subtask. The subagent's own `interrupt_on` mirrors `_WRITE_TOOLS`, and a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated. - **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless. - **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`). - **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps. diff --git a/aai_cli/agent_cascade/brain.py b/aai_cli/agent_cascade/brain.py index ead1d7fe..da55e18a 100644 --- a/aai_cli/agent_cascade/brain.py +++ b/aai_cli/agent_cascade/brain.py @@ -8,7 +8,8 @@ (:func:`build_graph`) and driven turn-by-turn with the running history the cascade already keeps (:func:`build_streamer`); tools are read-only and auto-approved, because a spoken turn can't pause for a keyboard confirmation, and the system prompt -keeps every reply short and speakable. +keeps every reply short and speakable. Context-window management is deepagents' job (its built-in +``SummarizationMiddleware``), so the engine feeds the full untrimmed history each turn. The graph is the only network seam: :func:`build_streamer` accepts an injected graph, so the per-turn streaming reply leg is unit-tested against a fake with no sockets — the diff --git a/aai_cli/agent_cascade/config.py b/aai_cli/agent_cascade/config.py index 649d18f3..b06bb0ff 100644 --- a/aai_cli/agent_cascade/config.py +++ b/aai_cli/agent_cascade/config.py @@ -27,7 +27,11 @@ "engine, so write plain spoken prose — no markdown, emoji, bullet lists, or code." ) DEFAULT_GREETING = "Hi! I'm your AssemblyAI voice agent. What can I help you with?" -# Sliding-window size: keep the last N messages of conversation as LLM context. +# Sliding-window size for the standalone, hand-rolled cascade: keep the last N messages of +# conversation as LLM context. Used by the `--show-code` generator and the `assembly init` +# template, which talk to the gateway directly. The live `assembly live` brain does NOT window +# client-side — it delegates context management to the deepagents `SummarizationMiddleware` +# (summarize old turns, offload to a file), so this knob is inert on that path. See brain.py. DEFAULT_MAX_HISTORY = 40 # Per-turn cap on how many tool calls the deepagents brain may make before it must answer. # Enforced by a ToolCallLimitMiddleware with exit_behavior="continue": once the budget is hit, diff --git a/aai_cli/agent_cascade/engine.py b/aai_cli/agent_cascade/engine.py index a048d062..baf1e39a 100644 --- a/aai_cli/agent_cascade/engine.py +++ b/aai_cli/agent_cascade/engine.py @@ -61,7 +61,7 @@ timeout_error as _timeout_error, ) from aai_cli.agent_cascade.config import CascadeConfig -from aai_cli.agent_cascade.text import pop_clauses, trim_history +from aai_cli.agent_cascade.text import pop_clauses from aai_cli.core.errors import CLIError from aai_cli.ui import output @@ -150,7 +150,6 @@ def on_turn(self, event: object) -> None: self.renderer.user_final(text) self._barge_in() self.history.append({"role": "user", "content": text}) - trim_history(self.history, self.config.max_history) self._start_reply() else: self.renderer.user_partial(text) @@ -419,11 +418,16 @@ def _feed(self, pcm: bytes) -> None: self.player.enqueue(pcm) def _record_spoken(self, spoken: list[str]) -> None: - """Append what was actually spoken to the history (kept alternating after a barge-in).""" + """Append what was actually spoken to the history (kept alternating after a barge-in). + + The transcript is no longer windowed client-side: the deepagents brain's built-in + ``SummarizationMiddleware`` does the context-window management (summarizing old turns, + offloading the evicted history to a file), so the engine keeps the full running history + and lets the graph compact it per turn — see :mod:`aai_cli.agent_cascade.brain`. + """ spoken_text = " ".join(spoken).strip() if spoken_text: self.history.append({"role": "assistant", "content": spoken_text}) - trim_history(self.history, self.config.max_history) def _surface_error(self, exc: CLIError, *, started: bool) -> None: """Record a reply-leg failure (LLM/timeout). Before any audio, the error is also shown diff --git a/aai_cli/agent_cascade/text.py b/aai_cli/agent_cascade/text.py index 762008f1..28d44483 100644 --- a/aai_cli/agent_cascade/text.py +++ b/aai_cli/agent_cascade/text.py @@ -1,7 +1,9 @@ -"""Pure text helpers for the cascade: sentence splitting and history trimming. +"""Pure text helpers for the cascade: sentence and clause splitting. Kept Rich-free and dependency-light so the orchestration logic in ``engine`` can -be unit-tested without any I/O. +be unit-tested without any I/O. (Conversation-history trimming used to live here as a +client-side sliding window; the live brain now delegates context-window management to +the deepagents ``SummarizationMiddleware`` instead — see :mod:`aai_cli.agent_cascade.brain`.) """ from __future__ import annotations @@ -27,6 +29,17 @@ def _is_boundary(text: str, index: int) -> bool: return index + 1 < len(text) and text[index + 1].isspace() +def _ends_sentence(text: str, index: int, char: str) -> bool: + """True when ``char`` at ``index`` closes a sentence: a terminator at end-of-text or + followed by whitespace. + + Unlike :func:`_is_boundary` (used for partial streamed chunks), end-of-text *does* close a + sentence here: :func:`split_sentences` runs on a complete reply, so a trailing terminator is a + real boundary, not a possibly-mid-token one. + """ + return char in _TERMINATORS and (index + 1 == len(text) or text[index + 1].isspace()) + + def pop_clauses(buffer: str, *, min_chars: int) -> tuple[list[str], str]: """Pull complete speakable clauses off the front of ``buffer`` for incremental TTS. @@ -68,7 +81,7 @@ def split_sentences(text: str) -> list[str]: sentences: list[str] = [] start = 0 for index, char in enumerate(text): - if char in _TERMINATORS and (index + 1 == len(text) or text[index + 1].isspace()): + if _ends_sentence(text, index, char): # Boundary confirmed (end-of-text or a following space); the slice includes # the terminator, so it is never blank after stripping leading whitespace. sentences.append(text[start : index + 1].strip()) @@ -77,13 +90,3 @@ def split_sentences(text: str) -> list[str]: if tail: sentences.append(tail) return sentences - - -def trim_history[T](history: list[T], max_messages: int) -> None: - """Cap ``history`` to its most recent ``max_messages`` entries, in place. - - A sliding window over the conversation so an unbounded chat doesn't grow the - context (and the per-turn token cost) without limit. - """ - if len(history) > max_messages: - del history[: len(history) - max_messages] diff --git a/tests/test_agent_cascade_config.py b/tests/test_agent_cascade_config.py index 90a4fee9..4d7280c2 100644 --- a/tests/test_agent_cascade_config.py +++ b/tests/test_agent_cascade_config.py @@ -32,7 +32,8 @@ def test_default_config_values(): "without preamble or filler. Your reply is read aloud by a text-to-speech " "engine, so write plain spoken prose — no markdown, emoji, bullet lists, or code." ) - # The sliding-window default keeps the last 40 messages of context. + # The standalone (--show-code / init template) sliding-window default; the live brain + # delegates context management to the deepagents SummarizationMiddleware instead. assert config.max_history == 40 assert DEFAULT_MAX_HISTORY == 40 # Formatting is on by default, so the reply trigger waits for the formatted turn. diff --git a/tests/test_agent_cascade_reply.py b/tests/test_agent_cascade_reply.py index 0a21b0b6..32a03505 100644 --- a/tests/test_agent_cascade_reply.py +++ b/tests/test_agent_cascade_reply.py @@ -99,22 +99,32 @@ def capture(messages): assert {"role": "user", "content": "prior"} in captured["messages"] -def test_generate_reply_trims_history_window(): +def test_generate_reply_keeps_full_untrimmed_history(): + # Context-window management now lives in the deepagents brain's SummarizationMiddleware, so the + # engine no longer windows client-side: max_history is inert here and the prior turn is kept + # even though it exceeds it (a trim would have dropped the user "hi"). session, _renderer, _player = make_session( stream_reply=_deltas("a. b."), config=CascadeConfig(max_history=1) ) session.history.append({"role": "user", "content": "hi"}) session._generate_reply() - assert session.history == [{"role": "assistant", "content": "a. b."}] + assert session.history == [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "a. b."}, + ] -def test_on_turn_trims_history_window(): +def test_on_turn_keeps_full_untrimmed_history(): + # Same: a new user turn appends without dropping the older assistant turn, despite max_history=1. session, _renderer, _player = make_session( stream_reply=_deltas(""), config=CascadeConfig(max_history=1) ) session.history.append({"role": "assistant", "content": "old"}) session.on_turn(_turn("newest")) - assert session.history == [{"role": "user", "content": "newest"}] + assert session.history == [ + {"role": "assistant", "content": "old"}, + {"role": "user", "content": "newest"}, + ] def test_generate_reply_stop_during_a_clause_drops_it_from_the_record(): diff --git a/tests/test_agent_cascade_text.py b/tests/test_agent_cascade_text.py index 170e87fc..ad2932ae 100644 --- a/tests/test_agent_cascade_text.py +++ b/tests/test_agent_cascade_text.py @@ -4,7 +4,7 @@ import pytest -from aai_cli.agent_cascade.text import pop_clauses, split_sentences, trim_history +from aai_cli.agent_cascade.text import pop_clauses, split_sentences def test_split_sentences_breaks_on_terminators(): @@ -43,24 +43,6 @@ def test_split_sentences_does_not_split_stacked_terminators(): assert split_sentences("Wait...what?!") == ["Wait...what?!"] -def test_trim_history_drops_oldest_beyond_limit(): - history = [{"role": "user", "content": str(i)} for i in range(5)] - trim_history(history, 3) - assert [item["content"] for item in history] == ["2", "3", "4"] - - -def test_trim_history_leaves_short_history_untouched(): - history = [{"role": "user", "content": "a"}, {"role": "user", "content": "b"}] - trim_history(history, 3) - assert len(history) == 2 - - -def test_trim_history_at_limit_is_untouched(): - history = [{"role": "user", "content": str(i)} for i in range(3)] - trim_history(history, 3) - assert len(history) == 3 - - def test_pop_clauses_flushes_hard_terminators_and_keeps_tail(): chunks, remainder = pop_clauses("One. Two! Three", min_chars=1) assert chunks == ["One.", "Two!"]