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
}
// Накапливаем tool_calls из post-tool continuation'ов (вызов
// sendStreamContents после addToolResult нужен для stateless-бэкендов).
// Эти вызовы обрабатываются на СЛЕДУЮЩЕЙ итерации outer-while, чтобы
// избежать бесконечной вложенности в случае моделей/fake'ов, которые
// всегда возвращают tool_calls.
var pendingPostToolCalls: List<LiteToolCall> = emptyList()
while (loopGuard++ < MAX_TOOL_LOOPS) {
// 0) Если interrupt случился до старта sendStreamContents (например во время
// compactPreTurn) — нет ни текста, ни тулов. Просто выходим,
@@ -402,7 +409,11 @@ override suspend fun interrupt() {
// the result back via addToolResult (returns LiteDelta — text +
// possibly nested toolCalls). Cycle exits when model no longer
// 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()) {
val prev = nextCalls
nextCalls = mutableListOf()
@@ -430,6 +441,59 @@ override suspend fun interrupt() {
if (delta.toolCalls.isNotEmpty()) {
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
}
@@ -540,7 +540,11 @@ class ChatAgentTest {
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,
"expected echo tool invoked with the LLM's args, result fed back via addToolResult")
assertEquals("final reply", toolLlm.finalReplyEmitted,