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
|
||||
}
|
||||
|
||||
// Накапливаем 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,
|
||||
|
||||
Reference in New Issue
Block a user