Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
7 changes: 7 additions & 0 deletions include/intent.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,19 @@
// Distinct from Constitution::MemoryConfig::intent_routing, which only
// boosts /mem search types.

#include <cstddef>
#include <functional>
#include <string>
#include <vector>

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.
Expand Down
17 changes: 16 additions & 1 deletion src/intent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,24 @@ std::vector<std::string> 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<unsigned char>(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";
Expand Down
56 changes: 56 additions & 0 deletions tests/test_intent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading