diff --git a/CHANGELOG.md b/CHANGELOG.md index 485e1334..28271b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed +- **Intent LLM prompt text cap.** `build_llm_user_prompt` now truncates + request text at 64 KiB (UTF-8 safe, with a `[truncated]` marker) so + orchestrator hybrid/llm classify cannot forward an unbounded TUI, + chat, or event payload to the provider. Matches the existing + `POST /v1/intent` message limit. - **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty. - **Unreadable TUI sessions are not empty.** `session_json_is_empty` no - **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` diff --git a/include/intent.h b/include/intent.h index 89b92568..c7522a9e 100644 --- a/include/intent.h +++ b/include/intent.h @@ -16,12 +16,19 @@ // Distinct from Constitution::MemoryConfig::intent_routing, which only // boosts /mem search types. +#include #include #include #include namespace arbiter { +// HTTP POST /v1/intent already rejects messages above this size. The +// orchestrator ingress path does not, so the LLM prompt builder must +// truncate here or a large TUI / chat / event payload is forwarded +// wholesale to the provider. +inline constexpr std::size_t kIntentLlmTextMaxBytes = 64 * 1024; + struct IntentConfig { // off | heuristic | hybrid | llm // File-backed agents default off; master_constitution() sets hybrid. diff --git a/src/intent.cpp b/src/intent.cpp index 338e89fd..56767f7d 100644 --- a/src/intent.cpp +++ b/src/intent.cpp @@ -231,9 +231,24 @@ std::vector cue_kinds(const std::string& norm) { return kinds; } +std::string cap_intent_llm_text(std::string text) { + if (text.size() <= kIntentLlmTextMaxBytes) return text; + static constexpr char kMark[] = "\n[truncated]"; + constexpr std::size_t kMarkLen = sizeof(kMark) - 1; + std::size_t keep = (kIntentLlmTextMaxBytes > kMarkLen) + ? (kIntentLlmTextMaxBytes - kMarkLen) : 0; + while (keep > 0 && + (static_cast(text[keep]) & 0xC0) == 0x80) { + --keep; + } + text.resize(keep); + text += kMark; + return text; +} + std::string build_llm_user_prompt(const IntentInput& in, const Intent& hint) { std::ostringstream q; - q << "[REQUEST]\n" << in.text << "\n[END REQUEST]\n\n"; + q << "[REQUEST]\n" << cap_intent_llm_text(in.text) << "\n[END REQUEST]\n\n"; q << "[ROSTER]\n"; if (in.roster.empty()) { q << "(none)\n"; diff --git a/tests/test_intent.cpp b/tests/test_intent.cpp index 28fafc23..fbf73d67 100644 --- a/tests/test_intent.cpp +++ b/tests/test_intent.cpp @@ -356,6 +356,62 @@ TEST_CASE("standalone classify defaults to heuristic, keeps master thresholds") CHECK(cfg.model == "advisor-model"); } +TEST_CASE("llm prompt truncates request text at 64 KiB") { + IntentInput in; + in.text.assign(kIntentLlmTextMaxBytes + 4096, 'x'); + in.requested_agent = "index"; + in.roster = starter_roster(); + IntentConfig cfg; + cfg.mode = "llm"; + + std::string prompt; + auto llm = [&](const std::string& p) { + prompt = p; + return std::string{}; + }; + auto out = resolve_intent(in, cfg, llm); + CHECK(out.llm_used); + REQUIRE_FALSE(prompt.empty()); + + const auto req = prompt.find("[REQUEST]\n"); + const auto end = prompt.find("\n[END REQUEST]"); + REQUIRE(req != std::string::npos); + REQUIRE(end != std::string::npos); + REQUIRE(end > req); + const std::string body = prompt.substr( + req + std::string("[REQUEST]\n").size(), + end - (req + std::string("[REQUEST]\n").size())); + CHECK(body.size() <= kIntentLlmTextMaxBytes); + CHECK(body.find("[truncated]") != std::string::npos); + CHECK(body.find(std::string(kIntentLlmTextMaxBytes, 'x')) == std::string::npos); +} + +TEST_CASE("llm prompt keeps request text at the 64 KiB boundary") { + IntentInput in; + in.text.assign(kIntentLlmTextMaxBytes, 'y'); + in.requested_agent = "index"; + in.roster = starter_roster(); + IntentConfig cfg; + cfg.mode = "llm"; + + std::string prompt; + auto llm = [&](const std::string& p) { + prompt = p; + return std::string{}; + }; + resolve_intent(in, cfg, llm); + + const auto req = prompt.find("[REQUEST]\n"); + const auto end = prompt.find("\n[END REQUEST]"); + REQUIRE(req != std::string::npos); + REQUIRE(end != std::string::npos); + const std::string body = prompt.substr( + req + std::string("[REQUEST]\n").size(), + end - (req + std::string("[REQUEST]\n").size())); + CHECK(body == in.text); + CHECK(body.find("[truncated]") == std::string::npos); +} + TEST_CASE("standalone heuristic does not invoke llm on unconfident text") { IntentInput in; in.text = "Hello there, please help";