Files
agentik/docs/TOOLSETS-PLAN.md
T
subochev e192f58cd0 docs: TOOLSETS-PLAN.md — implementation roadmap for toolsets + storage refactor
Captures the locked architectural decisions, module layout, and 6-commit
sequence. Implementation proceeds autonomously per this plan.

Refs: decisions from session 2026-09-15.
2026-09-15 14:31:59 +03:00

305 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agentik: Toolsets + Storage Refactor — Implementation Plan
**Status:** LOCKED. Implementation proceeds autonomously, no mid-implementation
pings.
**Date:** 2026-09-15.
## Resolved questions (defaults applied)
- **Q1** (tool-result language): **English** — for consistency with the toolset
section in the system prompt (also English).
- **Q2** (which info in `Toolset 'X' activated.` text): **Q2-α minimal** —
`"Toolset 'X' activated."`, no tool names. Tool descriptions are already in the
next request's `tools[]` array, no duplication needed.
No further open questions.
---
## Architectural decisions (locked)
| Parameter | Decision |
|---|---|
| Layers | `:agent-core` (existing `:standalone` core), `:agent-toolsets` (new wrapper), `:storage-core` / `:storage-inmemory` / `:storage-sqlite` / `:storage-android` (new). |
| Default toolsets | `listOf()`. When empty, zero agent changes: no `enable_toolset` / `disable_toolset` tools, no toolset section in prompt. Full invisibility. |
| Activation scope | per-conversation (`conv.id`). |
| Dispatch outcome | `Run(value: String) \| Error(message: String)`. `Substituted` variant dropped. |
| Auto-activation | Stays in `ToolsetDispatchPolicy` only — never mentioned in the system prompt. Falls through silently when the model "goofed". |
| `enable_toolset` / `disable_toolset` audit | H2: written as ordinary `ToolCall` / `ToolResult` rows in SQLite (model sees them in its own history on subsequent turns). |
| `ToolsetContribution` fields | `name: String` + `description: String` + `tools: List<NamedTool>`. No `enabledByDefault`. |
| Tool naming | `${toolsetName}_${verb}`; prefix = `name`. |
| Catalog rendering | Both ACTIVE and INACTIVE rows render as `name — description`, identical format. |
| `Toolset` section language | English. |
| Tool-result language | English (per Q1). |
| Activation timeout | 10 minutes since last use; lazy cleanup on every `activeNames(convId)` call. No background timers. |
| Persistence | In-memory only. Not persisted. New conversation = fresh registry. |
| Storage | `StorageBundle = MessageStore + WorkingMemoryStore + ReflectionStore + SkillStore`. SQLite is one impl, others possible. |
| Skills vs toolsets | Separate concepts. No link between skill index and toolset catalog. |
| Module split | A5-γ: extract interfaces, defer actual Android impl. |
---
## Module layout (final)
```
agentik/
├── storage-core/ NEW (KMP)
│ ├── MessageStore / WorkingMemoryStore / ReflectionStore / SkillStore interfaces
│ └── StorageBundle aggregator
│
├── storage-inmemory/ NEW (KMP, tests)
│ ├── InMemoryMessageStore
│ ├── InMemoryWorkingMemoryStore
│ ├── InMemoryReflectionStore
│ ├── InMemorySkillStore
│ └── InMemoryStorageBundle
│
├── storage-sqlite/ NEW (JVM, refactor of existing)
│ ├── Sqldelight-backed impls of all four stores
│ └── SqliteStorageBundle
│
├── storage-android/ NEW (Android, deferred — placeholder
│ └── (placeholder file; full impl comes with Android module)
│
├── agent-toolsets/ NEW (KMP)
│ ├── ToolsetContribution (name + description + tools)
│ ├── ToolsetRegistry
│ ├── ToolsetDispatchPolicy (wraps inner DispatchPolicy)
│ ├── SystemPromptToolsetSection (SystemPromptContributor)
│ ├── EnableToolsetTool / DisableToolsetTool (NamedTool)
│ └── ToolsetWrapper — not exposed as a separate class; the registry + policy +
│ section are constructed and injected individually.
│
├── standalone/ MODIFIED
│ ├── Main.kt — wires new modules (StorageBundle + ToolsetRegistry)
│ ├── build.gradle.kts — new dependencies
│ ├── ChatAgent / ChatConversation — depends on StorageBundle (interface), not
│ │ SqliteStores directly. accept ToolsetRegistry + contributions via ctor.
│ └── all existing tests still green.
```
Out of scope (kept in `:standalone` for now): Main, transport adapters (`/agentik`,
`/agui`, `/a2a`), LiteLlm backend wiring, debug endpoints, MCP integration.
---
## Commit sequence (six commits, each builds + tests green)
### Commit 1 — `:storage-core` interfaces + bundle
**New module** `storage-core` (KMP, commonMain only):
- `MessageStore.kt` — interface (append, getMessages, tokenStats).
- `WorkingMemoryStore.kt` — interface (append, list, compact, archive).
- `ReflectionStore.kt` — interface (insert, listRecent, count, listForConversation, deleteOlderThan).
- `SkillStore.kt` — interface (catalog, upsert, remove, exists, all).
- `StorageBundle.kt` — `data class StorageBundle(val messageStore, workingMemoryStore, reflectionStore, skillStore)`.
- One umbrella test: `StorageInterfaceContractTest` asserting parameter naming is right (compile-time only).
**Gradle setup**:
- `settings.gradle.kts` — `include(":storage-core")`.
- `storage-core/build.gradle.kts` — KMP `commonMain` only with `api kotlinx-coroutines-core`,
`api kotlinx-datetime`. No JVM target yet.
**Verification**:
- `./gradlew :storage-core:build` — green.
- `./gradlew :storage-core:jvmTest` — green (placeholder test).
**No `:standalone` modifications yet.**
### Commit 2 — `:storage-inmemory` impl
**New module** `storage-inmemory` (KMP, commonMain):
- All four `InMemory*` implementations backed by `ConcurrentHashMap` + `MutableStateFlow`-ish
snapshots for `getMessages(..): Flow<MessageRecord>`.
- `InMemoryStorageBundle` factory.
**Tests** (KMP commonTest):
- `InMemoryMessageStoreTest` — append + getMessages (paged flow).
- `InMemoryWorkingMemoryStoreTest` — append + list + compact + archive.
- `InMemoryReflectionStoreTest` — insert + listRecent + count.
- `InMemorySkillStoreTest` — catalog + upsert + remove.
**Gradle setup**:
- Depends on `:storage-core`.
**Verification**:
- `./gradlew :storage-inmemory:allTests` — green.
### Commit 3 — `:storage-sqlite` refactor
**New module** `storage-sqlite` (JVM):
- Move existing `SqliteStores` (and related Sqldelight code) here.
- Split into `SqliteMessageStore`, `SqliteWorkingMemoryStore`, `SqliteReflectionStore`,
`SqliteSkillStore`.
- `SqliteStorageBundle(val db: AgentikDatabase)`. Existing schema/migrations are
unchanged. Reads from existing `.sq` files.
**Migration**:
- Existing tests that depend on `SqliteStores` continue to work — keep a thin
compat: `SqliteStores` becomes a deprecated alias:
```kotlin
@Deprecated("Use SqliteStorageBundle")
class SqliteStores(db: AgentikDatabase): StorageBundle by SqliteStorageBundle(db)
```
**Tests** (JVM):
- Existing sqldelight-backed tests still green.
- Add contract tests for new individual stores.
**Verification**:
- `./gradlew :storage-sqlite:jvmTest` — green.
- All existing `:standalone` tests that referenced `SqliteStores` still compile
(via the @Deprecated alias).
### Commit 4 — `:agent-toolsets` core
**New module** `agent-toolsets` (KMP, commonMain):
- `ToolsetContribution.kt` — data class.
- `ToolsetRegistry.kt` — `class ToolsetRegistry(clock: Clock = Clock.System)`.
- `activeNames(convId): Set<String>` — lazy cleanup.
- `enable(convId, name): String` — 4-case response table (see A2 above).
- `disable(convId, name): String` — 4-case response table (see A3 above).
- `DEFAULT_TIMEOUT_MS = 10 * 60 * 1000`.
- `ToolsetDispatchPolicy.kt` — `interface DispatchPolicy { dispatch(call, sessionId): DispatchOutcome }`,
`class ToolsetDispatchPolicy(inner: DispatchPolicy, registry, contributions, coreTools)`:
- Dispatch loop:
```
while (true):
if name in resolved (core + active sets) tools: return inner.dispatch(...)
if name has prefix matching known toolset T: registry.enable(sessionId, T); continue
return Error("tool 'X' not found")
```
- `SystemPromptToolsetSection.kt` — `class SystemPromptToolsetSection(contributions, enabledSets)`
implementing `:agent-core:SystemPromptContributor` (defined here for now;
could later live in `:agent-core`).
- `EnableToolsetTool.kt` / `DisableToolsetTool.kt` — `NamedTool` implementations.
Args schema: `{ "name": "<string>" }` as JSON.
- `ToolsetSystemMessages.kt` — companion with `coreToolsDescription: List<String>`
(just `["enable_toolset", "disable_toolset"]`).
**Tests** (commonTest):
- `ToolsetRegistryTest`:
- enable + activeNames immediately reflects.
- enable idempotent (returns "already active").
- disable on inactive returns "deactivated" (per A3 case 2).
- timeout cleanup via injected `Clock`.
- per-conversation isolation (different convIds).
- `ToolsetDispatchPolicyTest` — synthetic `ping` toolset:
- model calls `ping({})` without enable → auto-enabled, executed, returns "pong".
- model calls `enable_toolset({})` with empty args → error message.
- model calls `ping({})` with no such toolset registered → error.
### Commit 5 — `:agent-toolsets` integration (SystemPromptContributor)
Same module, adds:
- Define `SystemPromptContributor` interface inside `:agent-toolsets` (or move to
`:agent-core`, but `:agent-toolsets` already has it; keep here for now).
- `SystemPromptToolsetSection` renders:
```
## Toolsets
Named groups of tools. One set per conversation. Use enable_toolset({"name": X})
to add a toolset; disable_toolset({"name": X}) to remove. Both are idempotent.
ACTIVE
name — description
INACTIVE call enable_toolset({"name": X}) to add
name — description
...
```
- `:standalone/Main.kt` — when `contributions.isNotEmpty()`:
- Build `ToolsetRegistry()`.
- Add `EnableToolsetTool(registry)` and `DisableToolsetTool(registry)` to
`ChatAgent.tools`.
- Inject `SystemPromptToolsetSection` into the system-prompt contributors chain.
- Wrap the `DispatchPolicy` with `ToolsetDispatchPolicy(...)`.
**Tests**:
- `SystemPromptToolsetSectionTest` — renders both ACTIVE and INACTIVE rows identically.
- Default (:standalone config) has `contributions = listOf()` → no tools, no
prompt section.
### Commit 6 — `:standalone` swap StorageBundle
**Changes to `:standalone`**:
- `Main.kt` — build `SqliteStorageBundle(db)` instead of `SqliteStores(db)`.
- `ChatAgent` constructor: `(..., stores: StorageBundle, ...)` (was:
`SqliteStores`).
- All references to `SqliteStores.messageStore` → `stores.messageStore` (etc.).
- `build.gradle.kts` — add `implementation(project(":storage-sqlite"))`,
remove direct reliance on sqlite plumbing internals if any.
**No behaviour change**: existing tests still pass.
**Verification**:
- `./gradlew :standalone:jvmTest` — all green (was 264 tests pre-refactor).
- Build `:standalone:fatjar` — works.
- Smoke test against `/tmp/agentik-sandbox` — e2e green (model still answers,
memory still works, no regressions).
---
## Verification at every commit
After each commit:
1. `./gradlew :<module>:build` — green.
2. Affected module's tests — green.
3. `./gradlew :standalone:jvmTest` — green (no regressions in the biggest test
suite). Once `:standalone` starts depending on the new modules in commit 5/6,
this becomes the canonical regression check.
4. After commit 6 — run the e2e smoke probe against the sandbox (curl `/agentik`
`/agui` `/a2a` health + one turn).
---
## Risks and mitigations
- **Risk**: existing `SqliteStores` references scatter across `:standalone`.
**Mitigation**: keep `@Deprecated` alias until all references are swept (commit
6); final sweep at commit 7 (deferred).
- **Risk**: `ChatAgent` ctor signature changes break many call sites.
**Mitigation**: introduce `StorageBundle` as a thin ctor param; existing ctors
that default to `SqliteStorageBundle(db)` still work.
- **Risk**: `DispatchPolicy` is currently implicit (direct call to
`toolsByName`). Wrapping it from outside may break test doubles.
**Mitigation**: introduce `DispatchPolicy` interface in commit 4 alongside the
wrapper. Existing fakes gain the one-method interface trivially.
- **Risk**: toolset section length in prompt — for many toolsets, ~5 lines × N
contributions.
**Mitigation**: `description` field is short (<100 tokens); limit contributions
count via agent config.
---
## Open items for future (NOT in this implementation)
- Android `:storage-android` impl (A5-γ defers this).
- Wrapper-class abstraction over (registry + policy + section) once Android
needs it.
- Test-time Clock injection beyond `ToolsetRegistryTest`.
- Pre-validation of `description` text via LLM (probably not worth it).
- Exporting `ToolsetDispatchPolicy` to consumers outside `:standalone`.
---
## How to resume after context loss
If this session is compacted and the plan lost:
1. Read this file: `agentik/docs/TOOLSETS-PLAN.md`.
2. Verify current commit: `git log --oneline -6` — should show commits in the
order above.
3. Resume from the next commit in the sequence not yet landed.
If commits 1-3 are landed but no further, jump to commit 4.
If commits 1-5 are landed, jump to commit 6.