From b1ae8bbd202169106323472f0bf728c1a86ffd84 Mon Sep 17 00:00:00 2001 From: subochev Date: Wed, 16 Sep 2026 05:44:58 +0300 Subject: [PATCH] fix(agent): trigger post-tool continuation sendStreamContents for stateless OpenAI backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После 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. --- .../standalone/agent/ChatConversation.kt | 66 ++++++++++++++++++- .../agentik/standalone/agent/ChatAgentTest.kt | 6 +- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt index 0b187f3..352caaf 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/agent/ChatConversation.kt @@ -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 = 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() + 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 } diff --git a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt index 8b8960e..8f6a114 100644 --- a/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt +++ b/standalone/src/jvmTest/kotlin/pw/binom/agentik/standalone/agent/ChatAgentTest.kt @@ -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,