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:
+10
-15
@@ -15,21 +15,19 @@ class InMemoryWorkingMemoryStoreTest {
|
||||
fun `append assigns sequential order_idx starting from 0`() = runTest {
|
||||
val store = InMemoryWorkingMemoryStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", WorkingMemoryEntry.System("you are brief"), t0)
|
||||
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
|
||||
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
|
||||
val rows = store.list("c1")
|
||||
assertEquals(3, rows.size)
|
||||
assertEquals(listOf(0L, 1L, 2L), rows.map { it.orderIdx })
|
||||
assertEquals(2, rows.size)
|
||||
assertEquals(listOf(0L, 1L), rows.map { it.orderIdx })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `order_idx continues across conversations independently`() = runTest {
|
||||
val store = InMemoryWorkingMemoryStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", WorkingMemoryEntry.System("a"), t0)
|
||||
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0)
|
||||
store.append("c2", WorkingMemoryEntry.System("b"), t0)
|
||||
store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0)
|
||||
// c2 должен начать с 0, не продолжать c1
|
||||
val rows2 = store.list("c2")
|
||||
assertEquals(1, rows2.size)
|
||||
@@ -56,8 +54,8 @@ class InMemoryWorkingMemoryStoreTest {
|
||||
fun `clear removes all rows for a conversation`() = runTest {
|
||||
val store = InMemoryWorkingMemoryStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", WorkingMemoryEntry.System("a"), t0)
|
||||
store.append("c2", WorkingMemoryEntry.System("b"), t0)
|
||||
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0)
|
||||
store.append("c2", WorkingMemoryEntry.User("u2", listOf(Content.Text("hello")), null), t0)
|
||||
store.clear("c1")
|
||||
assertEquals(emptyList(), store.list("c1"))
|
||||
assertEquals(1, store.list("c2").size)
|
||||
@@ -67,23 +65,20 @@ class InMemoryWorkingMemoryStoreTest {
|
||||
fun `compact without summary drops tail and returns new max`() = runTest {
|
||||
val store = InMemoryWorkingMemoryStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", WorkingMemoryEntry.System("a"), t0)
|
||||
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
|
||||
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
|
||||
// dropFromOrderIdx=2 → удаляет всё >= 2 (то есть только Assistant "a1")
|
||||
val newMax = store.compact(dropFromOrderIdx = 2, conversationId = "c1", summaryText = null)
|
||||
assertEquals(1L, newMax)
|
||||
// dropFromOrderIdx=1 → удаляет всё >= 1 (то есть только Assistant "a1")
|
||||
val newMax = store.compact(dropFromOrderIdx = 1, conversationId = "c1", summaryText = null)
|
||||
assertEquals(0L, newMax)
|
||||
val rows = store.list("c1")
|
||||
assertEquals(2, rows.size)
|
||||
assertEquals("a", (rows[0].entry as WorkingMemoryEntry.System).text)
|
||||
assertEquals("u1", rows[1].entry.sourceMessageId)
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("u1", rows[0].entry.sourceMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact with summary replaces tail with synthetic Summary row`() = runTest {
|
||||
val store = InMemoryWorkingMemoryStore()
|
||||
val t0 = Instant.parse("2026-09-15T10:00:00Z")
|
||||
store.append("c1", WorkingMemoryEntry.System("you are brief"), t0)
|
||||
store.append("c1", WorkingMemoryEntry.User("u1", listOf(Content.Text("hi")), null), t0.plus(kotlin.time.Duration.parse("PT1S")))
|
||||
store.append("c1", WorkingMemoryEntry.Assistant("a1", listOf(Content.Text("hello"))), t0.plus(kotlin.time.Duration.parse("PT2S")))
|
||||
store.append("c1", WorkingMemoryEntry.User("u2", listOf(Content.Text("how are you")), null), t0.plus(kotlin.time.Duration.parse("PT3S")))
|
||||
|
||||
Reference in New Issue
Block a user