# TinyAgents Session Migration Design
Date: 2026-07-01
Status: Phase 1 implemented (2026-07-01) in `src/openhuman/session_import/`
as the `openhuman.session_import_run` controller
(`openhuman-core session-import run`). Phases 2–4 (read-side shadow, cutover,
retirement) are not started. Implementation deviations from the original
sketch are marked "as built" below.
Goal: a one-time, idempotent migration of persisted OpenHuman session data —
transcript JSONL, legacy Markdown transcripts, and run-ledger/sub-agent rows —
into TinyAgents store/journal records, so new internals can read by TinyAgents
`thread_id` / `run_id` / stream offset while legacy surfaces keep answering by
OpenHuman session key.
## Source inventory (what exists on disk today)
All facts verified against the current checkout (TinyAgents 1.5.0 pinned with the
`sqlite` feature enabled).
### 1. Transcript JSONL (source of truth)
- Path: `{workspace}/session_raw/{stem}.jsonl`
(writer/reader: `src/openhuman/agent/harness/session/transcript.rs`).
- Legacy layout still readable: `session_raw/{DDMMYYYY}/{stem}.jsonl`
(pre-0.53.4 date folders). A layout migration already exists
(`session/migration.rs`, marker `state/migrations/session_layout_v1.done`).
- Stem encodes identity and lineage:
- root session: `{unix_ts}_{agent_id}`;
- sub-agent: `{parent_chain}__{unix_ts}_{agent_id}` — the `__` chain is the
only parent/child link on disk; there is no pointer file.
- Line 1 is a `_meta` header (`MetaPayload`): `agent`, optional `agent_id` /
`agent_type` / `provider` / `model` / `thread_id` / `task_id`, `dispatcher`,
`created`, `updated`, `turn_count`, `input_tokens`, `output_tokens`,
`cached_input_tokens`, `charged_amount_usd`.
- Remaining lines are `MessageLine`s: required `role` + `content`; optional
`id`, `extra_metadata`, `provider`, `model`, `usage` (`input`, `output`,
`cached_input`, `context_window`, `cost_usd`), `reasoning_content`,
`tool_calls` (`id`, `name`, `arguments` as raw JSON string, optional
`extra_content`), `iteration`, `ts` (RFC-3339). Only the last assistant
message of each turn carries the per-turn fields.
- Tool-call encoding varies by the `_meta.dispatcher` value:
- native: structured `tool_calls` array on the assistant line;
- XML / P-format: markup (`…`, `name[a|b]`) embedded
verbatim in `content` — never re-parsed on resume today.
### 2. Markdown transcripts
- Human-readable companion: `{workspace}/sessions/{YYYY_MM_DD}/{stem}.md`
(legacy `sessions/{DDMMYYYY}/`). Never read back except by the one-release
legacy reader `read_transcript_legacy_md()` (HTML-comment ``
format). Treat `.md` as a **fallback source only** when a stem has no JSONL.
### 3. Run ledger (SQLite `{workspace}/session_db/sessions.db`)
- `agent_runs`: id, kind (subagent | worker_thread | background_agent |
team_member | workflow_child), parent_run_id, parent_thread_id, agent_id,
status, worker_thread_id, task ids, checkpoint refs, metadata, timestamps.
- `run_events` (`run_id` + `sequence` → `event_type`, `payload_json`) and
`run_telemetry` (per-run token/cost roll-up).
- `graph_checkpoints` (written by `SqlRunLedgerCheckpointer`,
`src/openhuman/tinyagents/checkpoint.rs`): seq, thread_id, checkpoint_id,
run_id, record_json (a full tinyagents `Checkpoint`), created_at.
- **No table stores the transcript stem/path.** Ledger row ↔ transcript file
correlation is by convention via `thread_id` (+ `task_id`).
### 4. Durable sub-agent sessions (JSON blob)
- `{workspace}/.openhuman/subagent_sessions.json`: a single pretty-printed
`Vec` — `subagentSessionId`, `parentSession`
(parent session_key), `parentThreadId`, `workerThreadId`, `agentId`,
toolkit/model/sandbox/action-root selector fields, `status`, `reusable`,
inline `latestHistory` message mirror, timestamps.
## Target shape (TinyAgents 1.3+ primitives)
Use the crate's `harness::store` as the substrate — no new storage layer:
- `AppendStore` / `JsonlAppendStore`: one line per `StoreRecord { offset,
value, created_at_ms }`, offset = line index. Streams live under a store
root as `.jsonl`.
- `Store` / `FileStore`: key-value records as `/.json`.
Layout as built under `{workspace}/tinyagents_store/` (`kv/` holds the
`FileStore`, `journal/` the `JsonlAppendStore`). Two constraints reshaped the
original sketch:
- TinyAgents store/stream names are **slash-free** (ASCII alphanumerics plus
`-_.` — the crate's path-traversal guard), so the `thread/{id}/messages`
shape is impossible; names are dot-separated.
- Journals are **per session, not per thread**: multiple transcript files can
share one `_meta.thread_id`, and appending them into a shared stream would
interleave sessions. The descriptor carries `thread_id`, so thread-level
views remain a projection.
| Record | Primitive | Stream / key |
| ----------------------------- | ------------- | ------------------------------------------------ |
| Message journal (per session) | `AppendStore` | stream `session.{session_key}.messages` |
| Session descriptor | `Store` | ns `sessions`, key `{session_key}` |
| Item idempotency ledger | `Store` | ns `migration_items`, key `sha256(source path)` |
| Global run marker | `Store` | ns `migrations`, key `session_import_v1` |
Run event journals and run descriptors were dropped from v1 (see the resolved
open questions at the end): run events stay queryable in SQLite and belong to
the P2 journal-canonicalization work.
Descriptor records carry the compatibility mapping both directions:
```json
// ns sessions, key {session_key} (session_key = transcript stem)
{
"session_key": "1719800000_orchestrator",
"parent_session_key": null, // from the __ stem chain
"thread_id": "…", // _meta.thread_id or imported-{stem}
"thread_id_synthesized": false,
"task_id": "…", // from _meta.task_id (nullable)
"run_ids": ["…"], // joined from agent_runs via thread_id
"stream": "session.1719800000_orchestrator.messages",
"dispatcher": "native",
"agent_name": "…", "agent_id": "…", "agent_type": "…",
"provider": "…", "model": "…",
"created": "…", "updated": "…", "turn_count": 1,
"usage": { "input": 0, "output": 0, "cached_input": 0, "cost_usd": 0.0 },
"source": { "jsonl": "session_raw/….jsonl", "md": null },
"import": { "version": 1, "imported_at": "…", "warnings": 0 }
}
```
Message journal values (as built) are full-fidelity records of what
`read_transcript()` returns — `{id?, role, content, extra_metadata?}`, where
`extra_metadata` carries the reconstructed `openhuman_turn_usage` block
(`iteration`, `reasoning_content`, per-turn `usage`, `tool_calls` including
`extra_content`). `ChatMessage` itself marks `id`/`extra_metadata`
`skip_serializing`, so the journal defines its own record type
(`JournalMessage`). Projection into the tinyagents `harness::message::Message`
model is left to the read side.
### Lineage keys
- `thread_id`: taken from `_meta.thread_id` when present; when absent (old
files), synthesize `imported:{session_key}` so every migrated session has a
stable thread stream. Record the synthesis in the descriptor.
- Parent/child: derive from the `__` stem chain and cross-check against
`agent_runs.parent_thread_id` / `subagent_sessions.json`; disagreements are
warnings, stem chain wins (it is the write-time truth).
- `root_run_id`: tinyagents carries it in graph types but OpenHuman's
`graph_checkpoints` schema drops it. The importer sets `root_run_id` on run
descriptors by walking `agent_runs.parent_run_id` to the root. (Separately,
adding a `root_run_id` column to `graph_checkpoints` is a small schema
follow-up — tracked in the audit, not part of this migration.)
## Tool-call normalization
- Native-dispatcher transcripts: `tool_calls` arrays map 1:1 onto tinyagents
`ToolCall` records (`arguments` parsed from the raw JSON string; parse
failure → keep as string + warning).
- XML / P-format transcripts: **do not re-parse in v1.** The markup stays
verbatim in message content, exactly as the live resume path treats it
today; the descriptor records `"dispatcher": "xml" | "pformat"` so a later
pass (or read-side shim) can re-extract structured calls using the existing
`ToolDispatcher` parsers if ever needed. Re-parsing at import time is high
risk (P-format needs the positional-arg registry of the tool set as it
existed then) for no current consumer.
## Idempotency and observability
Follow the proven `session_layout_v1` pattern, plus per-item ledger entries:
- Global marker: `Store` record `migrations/session_import_v1` with run
timestamp, counters, and tool version. Present → skip scan entirely
(bypassed by `--only`, `--force`, and dry runs).
- Per-source ledger: each imported stem writes
`migration_items/{sha256(workspace-relative source path)}` with source size
+ mtime. Re-runs (e.g. after a crash) skip completed items; a changed
size/mtime re-imports and overwrites that item's records (only that
session's stream file is reset and rewritten).
- Never mutate or delete sources. `session_raw/`, `sessions/`, `sessions.db`,
and `subagent_sessions.json` remain untouched; legacy readers keep working
until parity is proven.
- Surface (as built): the `openhuman.session_import_run` controller —
`openhuman-core session-import run` / RPC — with `dry_run`, `only`
(stem glob), `force`, `verbose`, and `workspace` (dir override) params.
- dry-run prints the per-file plan (stem → thread stream, message count,
dialect, warnings) and writes nothing;
- real run emits a summary: files scanned / imported / skipped / failed,
messages written, warnings list; grep-friendly `[session-import]` log
prefix on every line.
- Failure policy: per-file errors are warnings (matching
`migrate_session_layout_if_needed`); the command never aborts the batch and
never blocks core startup — it is an explicit command, not a boot hook, in
v1. Wiring it into startup comes only after parity tests.
## Fixture matrix (required before implementation is "done")
Implemented in `src/openhuman/session_import/ops_tests.rs` (18 tests covering
every row below, plus dry-run purity and sources-untouched assertions).
Golden-file tests over real captured shapes:
1. Current flat `session_raw/{ts}_{agent}.jsonl`, native dispatcher.
2. Legacy date-folder `session_raw/{DDMMYYYY}/…` (pre-layout-migration).
3. Legacy Markdown-only session (no JSONL twin) via the `` reader.
4. Sub-agent stems, including a two-level `a__b__c` chain.
5. XML-dialect transcript (markup-in-content preserved byte-for-byte).
6. P-format transcript (ditto).
7. Assistant line with `tool_calls` incl. `extra_content` (Gemini
thought-signature passthrough).
8. Malformed files: missing `_meta` first line, truncated last line, empty
file, unparseable message line (skip + warning, matching the current
reader's tolerance).
9. `_meta` without `thread_id` (synthesized thread id path).
10. Ledger cross-check: `agent_runs` row whose `parent_thread_id` disagrees
with the stem chain (warning path).
11. Idempotency: import → re-run (all skipped) → touch one source → only that
item re-imports.
Parity assertion: for every fixture, reading the migrated thread stream back
and projecting it into `ChatMessage` history must equal what
`read_transcript()` returns for the source file (same messages, same
`openhuman_turn_usage` reconstruction).
## Phasing
1. **Importer + CLI + fixtures** — done (`src/openhuman/session_import/`):
write-only into `tinyagents_store/`, sources untouched, nothing reads the
new records yet.
2. **Read-side shadow**: run-inspection surfaces read both and diff-log
mismatches (behind a debug flag).
3. **Cutover**: new internals read TinyAgents records; legacy readers stay as
compatibility projections keyed by `session_key`.
4. **Retirement**: delete legacy readers once telemetry shows no shadow
mismatches (separate decision, out of scope here).
## Open questions (resolved in v1)
- `run_events` / `run_telemetry` journaling: **not in v1** — transcripts +
descriptors only. Run events stay queryable in SQLite; P2 (journal
canonicalization) owns that surface.
- Store root: **`{workspace}/tinyagents_store/`** (`kv/` + `journal/`).
Living under the workspace means the fail-closed
`is_workspace_internal_path` guard keeps agent tools from writing here —
desirable.
- `subagent_sessions.json` `latestHistory` mirrors: **dropped** — they are a
cache of the same messages the child's own transcript carries; the child
stem imports as its own session.