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,