feat(reply): cap interactive LLM replies to 3 mesh packets (#7)

Add terse-answer guidance to the interactive system prompt and a hard
ceiling of 3 packets (3 x connector.max_chars) on LLM replies, with an
"ask for more" indicator when truncated. Protects LoRa airtime from
runaway replies. Broadcast chunking unchanged.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-02 16:49:10 -06:00 committed by GitHub
commit 7c15fa3f09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 160 additions and 1 deletions

View file

@ -13,6 +13,35 @@ import re
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Hard ceiling on interactive LLM replies — protects LoRa airtime.
# Broadcast/notification chunking is NOT subject to this cap.
MAX_REPLY_PACKETS = 3
_TRUNCATION_INDICATOR = " …(ask for more)"
def cap_reply_chunks(chunks: list[str], max_packets: int, max_chars: int) -> list[str]:
"""Cap LLM reply chunks to max_packets; append truncation indicator if cut.
Args:
chunks: List of message chunks produced by chunk_response.
max_packets: Maximum number of packets to deliver (e.g. MAX_REPLY_PACKETS).
max_chars: Per-packet character budget (connector.max_chars).
Returns:
Original list when len(chunks) <= max_packets; otherwise a capped
copy of length max_packets with the truncation indicator appended
(and the last chunk trimmed if necessary to stay within max_chars).
"""
if len(chunks) <= max_packets:
return chunks
capped = list(chunks[:max_packets])
last = capped[-1]
if len(last) + len(_TRUNCATION_INDICATOR) > max_chars:
last = last[: max_chars - len(_TRUNCATION_INDICATOR)]
capped[-1] = last + _TRUNCATION_INDICATOR
return capped
def strip_markdown(text: str) -> str: def strip_markdown(text: str) -> str:
"""Remove markdown formatting from LLM output. """Remove markdown formatting from LLM output.

View file

@ -13,7 +13,7 @@ from .config import Config
from .connector import MeshConnector, MeshMessage from .connector import MeshConnector, MeshMessage
from .context import MeshContext from .context import MeshContext
from .history import ConversationHistory from .history import ConversationHistory
from .chunker import chunk_response, ContinuationState from .chunker import chunk_response, cap_reply_chunks, MAX_REPLY_PACKETS, ContinuationState
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -889,6 +889,13 @@ class MessageRouter:
if env_summary: if env_summary:
system_prompt += "\n\n" + env_summary system_prompt += "\n\n" + env_summary
# Terse-reply guidance for slow LoRa links (always appended last for
# interactive replies so it isn't buried by mesh-data blocks).
system_prompt += (
"\n\nYou're replying over a slow LoRa mesh. "
"Answer in 1-2 short messages. Be terse and direct; omit preamble and filler."
)
# DEBUG: Log system prompt status # DEBUG: Log system prompt status
logger.debug(f"System prompt length: {len(system_prompt)} chars") logger.debug(f"System prompt length: {len(system_prompt)} chars")
@ -929,6 +936,12 @@ class MessageRouter:
max_messages=self.config.response.max_messages, max_messages=self.config.response.max_messages,
) )
# Hard cap: LLM interactive replies must not exceed MAX_REPLY_PACKETS.
# This is a safety ceiling on top of config.response.max_messages so
# LoRa airtime is protected even if config grows the message budget.
# Broadcast/notification chunking is NOT affected by this cap.
messages = cap_reply_chunks(messages, MAX_REPLY_PACKETS, self.connector.max_chars)
# Store remaining content for continuation # Store remaining content for continuation
if remaining: if remaining:
logger.debug(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining") logger.debug(f"Storing continuation for {message.sender_id}: {len(remaining)} chars remaining")

View file

@ -0,0 +1,117 @@
"""Tests for the interactive LLM reply cap (MAX_REPLY_PACKETS).
All tests are hermetic no real LLM, no real socket.
"""
import pytest
from meshai.chunker import cap_reply_chunks, chunk_response, MAX_REPLY_PACKETS, _TRUNCATION_INDICATOR
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_chunks(n: int, chars_each: int = 50) -> list[str]:
"""Build n chunks of exactly chars_each ASCII characters."""
return [f"chunk{i}" + "x" * (chars_each - len(f"chunk{i}")) for i in range(n)]
# ---------------------------------------------------------------------------
# Test 1 — long reply produces exactly 3 chunks with truncation indicator
# ---------------------------------------------------------------------------
def test_long_reply_capped_to_three_chunks():
"""A reply long enough to produce >3 chunks results in exactly 3 delivered
chunks, and the truncation indicator is present in the last chunk."""
max_chars = 140
# Build a response that will produce >3 chunks at 140 chars each.
# Each sentence is ~130 chars so 5 sentences → 5 chunks.
sentence = "A" * 130 + "."
text = " ".join([sentence] * 5)
# chunk_response with a high max_messages so it happily returns 5 chunks
raw_chunks, _ = chunk_response(text, max_chars=max_chars, max_messages=10)
assert len(raw_chunks) > MAX_REPLY_PACKETS, (
f"Precondition: expected >3 raw chunks, got {len(raw_chunks)}"
)
capped = cap_reply_chunks(raw_chunks, MAX_REPLY_PACKETS, max_chars)
assert len(capped) == MAX_REPLY_PACKETS
assert _TRUNCATION_INDICATOR in capped[-1], (
f"Expected truncation indicator in last chunk, got: {capped[-1]!r}"
)
# ---------------------------------------------------------------------------
# Test 2 — short reply is delivered unchanged, no indicator
# ---------------------------------------------------------------------------
def test_short_reply_unchanged():
"""A short reply (<=3 chunks) is delivered unchanged with no indicator."""
max_chars = 140
# One chunk well under the limit
chunks_1 = ["Hello, this is a short reply."]
result_1 = cap_reply_chunks(chunks_1, MAX_REPLY_PACKETS, max_chars)
assert result_1 == chunks_1
assert _TRUNCATION_INDICATOR not in result_1[-1]
# Exactly 3 chunks — boundary condition
chunks_3 = _make_chunks(3, chars_each=50)
result_3 = cap_reply_chunks(chunks_3, MAX_REPLY_PACKETS, max_chars)
assert result_3 == chunks_3
assert _TRUNCATION_INDICATOR not in result_3[-1]
# ---------------------------------------------------------------------------
# Test 3 — cap respects connector.max_chars=140
# ---------------------------------------------------------------------------
def test_cap_respects_max_chars_140():
"""cap_reply_chunks uses the max_chars budget (140) from connector.max_chars."""
max_chars = 140
# 5 chunks of 80 chars each (well under max_chars individually)
chunks = _make_chunks(5, chars_each=80)
capped = cap_reply_chunks(chunks, MAX_REPLY_PACKETS, max_chars)
assert len(capped) == 3
# Last chunk must not exceed max_chars in total length
assert len(capped[-1]) <= max_chars
assert _TRUNCATION_INDICATOR in capped[-1]
# ---------------------------------------------------------------------------
# Test 4 — 3rd chunk trimmed correctly when at max_chars limit
# ---------------------------------------------------------------------------
def test_third_chunk_trimmed_to_fit_indicator():
"""When the 3rd chunk fills max_chars exactly, it is trimmed so that
trimmed_chunk + indicator == max_chars exactly."""
max_chars = 140
indicator = _TRUNCATION_INDICATOR # len 16 Python chars (" …(ask for more)")
# 3rd chunk that is exactly max_chars long
full_chunk = "B" * max_chars
chunks = [
"First chunk.",
"Second chunk.",
full_chunk,
"Fourth chunk (should be dropped).",
]
capped = cap_reply_chunks(chunks, MAX_REPLY_PACKETS, max_chars)
assert len(capped) == MAX_REPLY_PACKETS
last = capped[-1]
assert last.endswith(indicator), f"Expected indicator at end, got: {last!r}"
assert len(last) == max_chars, (
f"Expected trimmed chunk + indicator == {max_chars}, got len={len(last)}"
)
# The trimmed body is max_chars - len(indicator) Bs
expected_body = "B" * (max_chars - len(indicator))
assert last == expected_body + indicator