feat(agent): persist tool-calls in working memory + interrupt-safe close-recreate
Radical redesign of interrupt semantics (plan: docs/TOOLSETS-PLAN.md,
phase commit 7):
1. Storage (:storage-core + :storage-sqlite + :storage-inmemory):
add WorkingMemoryEntry.ToolExchange(toolName, toolArgsJson, resultText,
wasCancelled) — one row per tool-call. Survives restarts.
2. ChatConversation:
- new fields: interrupted (AtomicBoolean), currentToolJob (Job?)
- interrupt() теперь только сигнал: ставит флаг, cancel LiteConv +
cancel currentToolJob. НЕ cancel activeTurn — пусть runTurn finally
отработает.
- runTurn обёрнут в try/finally: даже при CancellationException (от
LiteConv.cancel()) и при early-return (interrupt до старта LLM) —
finally закрывает LiteConv и эмитит Interrupted (если была отмена) + End.
- runToolAndPersist возвращает WorkingMemoryEntry.ToolExchange вместо
Pair(callId, resultText); инструмент запускается в scope.async, его
Job = currentToolJob, cooperative cancellation через Job.cancel.
Если инструмент броает CancellationException/InterruptedException →
resultText = '[cancelled by user]', wasCancelled = true.
3. GetOrCreateLiteConversation теперь мапит ToolExchange →
LiteMessage(TOOL, ToolResult, name, response) в initialMessages —
при следующем send() LLM видит честный результат вызова tool'а
через LiteRT-LM (callId не требуется, матчится по name).
4. LiteConv lifecycle: создаётся новый на каждом turn (close+recreate
семантика). Это ~2s prefill на Gemma-4-E2B, но гарантирует полную
предсказуемость: нет рекурсивных cancel-drain'ов, KV-cache всегда
консистентен с WM.
5. Тесты:
- multi-turn: 2 LiteConv-а (один на turn)
- interrupt mid-slow-stream: пустой assistant в WM, только user, события
Interrupted + End.
- interrupt after-tool: ToolExchange в WM (result=echo output, wasCancelled=false),
ToolCall + ToolResult в audit.
Total: 341/341 green.
This commit is contained in:
@@ -7,9 +7,10 @@ import kotlinx.serialization.Serializable
|
||||
* Запись в working memory диалога: ровно то, что агент сейчас видит в
|
||||
* LLM-контексте. Упорядочено по `order_idx` (заполняется в store при append).
|
||||
*
|
||||
* Sealed-иерархия: для v1 — `System` (синтетический system-prompt),
|
||||
* `User`/`Assistant` (реплики с ссылкой на audit log через [sourceMessageId]).
|
||||
* Суммаризация (для v2) добавит подтип `Summary`.
|
||||
* Sealed-иерархия: `User`/`Assistant` (реплики с ссылкой на audit log
|
||||
* через [sourceMessageId]), `ToolExchange` (синтетическая запись об одном
|
||||
* tool-вызове + его результате — для replay в LiteMessage(TOOL, ToolResult)
|
||||
* при пересоздании LiteConv), `Summary` (суммаризация при compaction).
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface WorkingMemoryEntry {
|
||||
@@ -17,13 +18,6 @@ sealed interface WorkingMemoryEntry {
|
||||
/** Ссылка на исходное сообщение в audit log (`message.id`). `null` для синтетических строк. */
|
||||
val sourceMessageId: String?
|
||||
|
||||
/** Синтетический system-prompt, добавляется при создании диалога. */
|
||||
@Serializable
|
||||
@SerialName("system")
|
||||
data class System(val text: String) : WorkingMemoryEntry {
|
||||
override val sourceMessageId: String? = null
|
||||
}
|
||||
|
||||
/** Реплика пользователя. */
|
||||
@Serializable
|
||||
@SerialName("user")
|
||||
@@ -47,6 +41,32 @@ sealed interface WorkingMemoryEntry {
|
||||
val content: List<Content>,
|
||||
) : WorkingMemoryEntry
|
||||
|
||||
/**
|
||||
* Синтетический блок: один tool-вызов + его результат. Синтетический — потому
|
||||
* что в audit log это две отдельные записи (`MessageRecord.ToolCall` +
|
||||
* `MessageRecord.ToolResult`), а в working_memory мы храним одной строкой
|
||||
* для удобства replay'а.
|
||||
*
|
||||
* При создании новой LiteConv каждая такая запись превращается в
|
||||
* `LiteMessage(TOOL, [ToolResult(callId, name, response)])` — LiteRT-LM
|
||||
* матчит по `name`, `callId` берётся из [sourceMessageId] (= id исходного
|
||||
* [MessageRecord.ToolCall]). Если [wasCancelled] = true, [resultText]
|
||||
* содержит маркер `[cancelled by user]` — модель видит честную причину
|
||||
* отсутствия результата.
|
||||
*
|
||||
* [sourceMessageId] = id исходного [MessageRecord.ToolCall] (для трассировки
|
||||
* в audit log).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("tool_exchange")
|
||||
data class ToolExchange(
|
||||
override val sourceMessageId: String,
|
||||
val toolName: String,
|
||||
val toolArgsJson: String,
|
||||
val resultText: String,
|
||||
val wasCancelled: Boolean = false,
|
||||
) : WorkingMemoryEntry
|
||||
|
||||
/**
|
||||
* Синтетический блок: суммаризация старых ходов, сгенерированная при
|
||||
* compaction'е working memory. Не имеет ссылки на конкретное сообщение
|
||||
|
||||
Reference in New Issue
Block a user