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:
+65
-1
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user