fix(agent): trigger post-tool continuation sendStreamContents for stateless OpenAI backend

После addToolResult (например memory_save result) LiteRT-LM (stateful)
возвращает дельту с финальным текстом модели. Но OpenAI-бэкенд
(stateless, litert-openai) просто дописывает tool-result в history и
возвращает пустую дельту — следующий ответ модели приходит только
при следующем send.

Без этого фикса ассистент после tool-call'а выдавал пустой текст
"\n\n" (например после memory_save).

Что меняется:
- runTurn: после addToolResult вызываем sendStreamContents с пустым
  placeholder'ом (" "), который для OpenAI триггерит continuation,
  а для LiteRT-LM просто даёт no-op-ответ (соберём, отбросим).
- tool_calls из continuation НЕ обрабатываем в текущем inner-while —
  кладём в pendingPostToolCalls и обрабатываем на следующей outer
  итерации. Иначе можно попасть в бесконечный tool-loop (fake
  LiteLlm-тесты это показывают).
- emptyList() нельзя — LiteMessage требует непустой contents, поэтому
  используем пробел как placeholder.

Тесты:
- tool-call loop test: toolCallCount == 2 (user send + post-tool continuation)
- live e2e на удалённой машине (192.168.76.166) с OpenAI vLLM бэкендом:
  - простая арифметика (12+34=46) ✓
  - memory_save + recall в той же беседе ✓
  - memory persists across conversations ✓
  - прерывание mid-task (генерация рассказа про космос) → partial assistant
    + ToolExchange в working memory ✓
  - SSE events: start_reasoning, start_response, append_text, end ✓

Total: 341/341 green.
This commit is contained in:
2026-09-16 05:44:58 +03:00
parent e3f20f07d9
commit b1ae8bbd20
2 changed files with 70 additions and 2 deletions
@@ -364,6 +364,13 @@ override suspend fun interrupt() {
return return
} }
// Накапливаем tool_calls из post-tool continuation'ов (вызов
// sendStreamContents после addToolResult нужен для stateless-бэкендов).
// Эти вызовы обрабатываются на СЛЕДУЮЩЕЙ итерации outer-while, чтобы
// избежать бесконечной вложенности в случае моделей/fake'ов, которые
// всегда возвращают tool_calls.
var pendingPostToolCalls: List<LiteToolCall> = emptyList()
while (loopGuard++ < MAX_TOOL_LOOPS) { while (loopGuard++ < MAX_TOOL_LOOPS) {
// 0) Если interrupt случился до старта sendStreamContents (например во время // 0) Если interrupt случился до старта sendStreamContents (например во время
// compactPreTurn) — нет ни текста, ни тулов. Просто выходим, // compactPreTurn) — нет ни текста, ни тулов. Просто выходим,
@@ -402,7 +409,11 @@ override suspend fun interrupt() {
// the result back via addToolResult (returns LiteDelta — text + // the result back via addToolResult (returns LiteDelta — text +
// possibly nested toolCalls). Cycle exits when model no longer // possibly nested toolCalls). Cycle exits when model no longer
// requests tools. // requests tools.
var nextCalls = collectedCalls // pendingPostToolCalls (с предыдущей итерации outer-loop'а) обрабатываем
// первыми — если stateless-бэкенд вернул tool_calls в continuation,
// их надо прогнать через tool-loop, прежде чем считать turn завершённым.
var nextCalls = if (pendingPostToolCalls.isNotEmpty()) pendingPostToolCalls else collectedCalls
pendingPostToolCalls = emptyList()
while (nextCalls.isNotEmpty()) { while (nextCalls.isNotEmpty()) {
val prev = nextCalls val prev = nextCalls
nextCalls = mutableListOf() nextCalls = mutableListOf()
@@ -430,6 +441,59 @@ override suspend fun interrupt() {
if (delta.toolCalls.isNotEmpty()) { if (delta.toolCalls.isNotEmpty()) {
nextCalls.addAll(delta.toolCalls) nextCalls.addAll(delta.toolCalls)
} }
// После addToolResult вызываем sendStreamContents с пустым
// контентом — это триггерит следующий ответ модели после
// tool-result'а. Нужно для stateless-бэкендов (OpenAI):
// addToolResult у них только дописывает в history, реальный
// ответ приходит только при следующем send. Для stateful
// бэкендов (Google LiteRT-LM) addToolResult сам запускает
// генерацию — повторный send будет пустым ответом (isDone),
// collect() просто пропускает.
//
// Если бы мы этого не делали — OpenAI-бэкенд возвращал
// бы только tool_call → tool_result → пустой assistant,
// без финального текста после tool'а.
if (!interrupted.get()) {
try {
// Нельзя передавать emptyList() — LiteMessage требует
// непустой contents. Используем невидимый placeholder
// (пробел) — OpenAI-бэкенд допишет его как user-message
// и триггерит ответ модели. На стороне LiteRT-LM
// (stateful) addToolResult уже выполнил работу, так
// что ответ будет пустой/короткий и мы просто
// проигнорируем его в collect.
//
// NB: собираем ТОЛЬКО text из ответа. tool_calls из
// post-tool continuation добавляются в отдельный буфер
// outer-loop'а — иначе можно попасть в бесконечный
// tool-loop (тестовая fake-LiteLlm, например, всегда
// возвращает tool_call из sendStreamContents).
val collectedPostTool = mutableListOf<LiteToolCall>()
liteConv!!.sendStreamContents(listOf(LiteContentPart.Text(" "))).collect { followUp ->
if (followUp.text.isNotEmpty()) {
reply.append(followUp.text)
emitEvent(ProtoEvent.AppendText(date = now(), body = followUp.text))
}
if (followUp.toolCalls.isNotEmpty()) {
collectedPostTool.addAll(followUp.toolCalls)
}
}
// Обрабатываем tool_calls из continuation в outer-loop
// (следующая итерация while), а не в этом же inner-while.
// Устанавливаем флаг, чтобы вернуться к outer.
if (collectedPostTool.isNotEmpty()) {
// Передаём в outer-loop: добавляем в pendingCallsForNextIter
pendingPostToolCalls = collectedPostTool
}
} catch (e: CancellationException) {
log.info { "post-tool sendStreamContents cancelled for $id" }
break
} catch (e: Throwable) {
log.warn(e) { "post-tool sendStreamContents failed for $id" }
break
}
}
} }
if (interrupted.get()) break if (interrupted.get()) break
} }
@@ -540,7 +540,11 @@ class ChatAgentTest {
conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool"))) conv.send(listOf(pw.binom.agentik.proto.Content.Text("call the tool")))
assertEquals(1, toolLlm.toolCallCount, "expected one round-trip through LiteConversation") // sendStreamContents вызывается дважды: первый раз с user-сообщением
// (LLM отвечает tool_call), второй раз — после addToolResult — для
// триггера continuation у stateless-бэкендов (OpenAI). На этой fake
// LiteLlm оба попадают в счётчик.
assertEquals(2, toolLlm.toolCallCount, "expected user send + post-tool continuation")
assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult, assertEquals("echoed: {\"x\":\"hi\"}", toolLlm.lastToolResult,
"expected echo tool invoked with the LLM's args, result fed back via addToolResult") "expected echo tool invoked with the LLM's args, result fed back via addToolResult")
assertEquals("final reply", toolLlm.finalReplyEmitted, assertEquals("final reply", toolLlm.finalReplyEmitted,