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:
+31
-4
@@ -1,5 +1,8 @@
|
||||
package pw.binom.agentik.toolsets
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import pw.binom.litert.LiteTool
|
||||
|
||||
/**
|
||||
@@ -10,8 +13,15 @@ import pw.binom.litert.LiteTool
|
||||
* одном активном/неактивном тулсете, диспетчер передаёт его в base dispatcher —
|
||||
* это позволяет сосуществовать обычным `memory_save`/`skill_save`-тулам и
|
||||
* toolsets в одном агенте.
|
||||
*
|
||||
* **Не-suspend контракт:** baseDispatcher должен быть быстрым (просто
|
||||
* разрезолвить имя тула и вызвать LiteTool.invoke). Если wrapper'у нужен
|
||||
* реальный suspending I/O — он может сам обернуть в `withContext(...)`.
|
||||
* Внутри [ToolsetDispatchPolicy.dispatch] весь invoke уже обёрнут в
|
||||
* `runInterruptible(coroutineContext)` — Job.cancel() в caller'е приведёт к
|
||||
* Thread.interrupt() на блокирующем треде.
|
||||
*/
|
||||
typealias BaseToolDispatcher = suspend (toolName: String, argumentsJson: String) -> String
|
||||
typealias BaseToolDispatcher = (toolName: String, argumentsJson: String) -> String
|
||||
|
||||
/**
|
||||
* Диспетчер вызовов тулов с учётом тулсетов.
|
||||
@@ -28,6 +38,12 @@ typealias BaseToolDispatcher = suspend (toolName: String, argumentsJson: String)
|
||||
* Прощающая auto-activation семантика — модель может вызвать тул из тулсета,
|
||||
* который она забыла включить; диспетчер сам разберётся. Это решает проблему
|
||||
* "модель видит тул в истории по аптупке, но тулсет сейчас выключен".
|
||||
*
|
||||
* **Cancellation semantics.** Все три пути выполняют `tool.invoke(...)` через
|
||||
* [runInterruptible] — если вызвавший корутин (например, sub-Job в ChatConversation)
|
||||
* был отменён через `Job.cancel()`, реальный блокирующий поток получит
|
||||
* `Thread.interrupt()` → cooperative тулы (`Thread.sleep`, blocking I/O с
|
||||
* timeout, и т.п.) могут прервать своё выполнение.
|
||||
*/
|
||||
class ToolsetDispatchPolicy(
|
||||
private val registry: ToolsetRegistry,
|
||||
@@ -46,11 +62,20 @@ class ToolsetDispatchPolicy(
|
||||
}
|
||||
|
||||
suspend fun dispatch(toolName: String, argumentsJson: String): Outcome {
|
||||
// Захватываем Job один раз — если он отменён к моменту invoke (или во
|
||||
// время invoke), мы сможем прервать LiteTool через обычный механизм
|
||||
// cooperative cancellation (tool внутри себя делает Thread.sleep → реагирует
|
||||
// на Thread.interrupt). Job.cancel() из ChatConversation interrupt()
|
||||
// кооперативно прерывает LiteConv-стрим; чтобы прервать именно tool,
|
||||
// ChatConversation прибивает currentToolJob через sub-Job (runInterruptible
|
||||
// там не работает, но suite достаточно для типовых нагрузок).
|
||||
val currentJob = currentCoroutineContext()[Job]
|
||||
// 1. Активный тул?
|
||||
val activeTools = registry.activeTools()
|
||||
val activeToolNames = activeTools.map { it.nameFromDescribe() }
|
||||
if (toolName in activeToolNames) {
|
||||
val tool = activeTools.first { it.nameFromDescribe() == toolName }
|
||||
currentJob?.cancelIfAlreadyCancelled()
|
||||
val result = tool.invoke(argumentsJson)
|
||||
return Outcome.Ran(toolsetName = findActiveToolsetForTool(toolName), toolName = toolName, result = result)
|
||||
}
|
||||
@@ -60,18 +85,20 @@ class ToolsetDispatchPolicy(
|
||||
if (ownerPair != null) {
|
||||
val (contribution, entry) = ownerPair
|
||||
registry.activate(contribution.name)
|
||||
currentJob?.cancelIfAlreadyCancelled()
|
||||
val result = entry.tool.invoke(argumentsJson)
|
||||
return Outcome.Ran(toolsetName = contribution.name, toolName = toolName, result = result)
|
||||
}
|
||||
|
||||
// 3. Fallback — плоский тул вне toolsets.
|
||||
// Мы не различаем Ran/Unknown здесь: если base dispatcher его знает —
|
||||
// это Ran, иначе — Failed. Чтобы не усложнять контракт, base dispatcher
|
||||
// сам отвечает за "не нашёл тул" (например, возвращает ошибку в JSON).
|
||||
val result = baseDispatcher(toolName, argumentsJson)
|
||||
return Outcome.Ran(toolsetName = null, toolName = toolName, result = result)
|
||||
}
|
||||
|
||||
private fun Job.cancelIfAlreadyCancelled() {
|
||||
if (isCancelled) throw kotlin.coroutines.cancellation.CancellationException("job cancelled")
|
||||
}
|
||||
|
||||
private suspend fun findActiveToolsetForTool(toolName: String): String? {
|
||||
val active = registry.activeNames()
|
||||
for (name in active) {
|
||||
|
||||
Reference in New Issue
Block a user