13 KiB
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, markerstate/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.
- root session:
- Line 1 is a
_metaheader (MetaPayload):agent, optionalagent_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
MessageLines: requiredrole+content; optionalid,extra_metadata,provider,model,usage(input,output,cached_input,context_window,cost_usd),reasoning_content,tool_calls(id,name,argumentsas raw JSON string, optionalextra_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.dispatchervalue:- native: structured
tool_callsarray on the assistant line; - XML / P-format: markup (
<tool_call>…</tool_call>,name[a|b]) embedded verbatim incontent— never re-parsed on resume today.
- native: structured
2. Markdown transcripts
- Human-readable companion:
{workspace}/sessions/{YYYY_MM_DD}/{stem}.md(legacysessions/{DDMMYYYY}/). Never read back except by the one-release legacy readerread_transcript_legacy_md()(HTML-comment<!--MSG …-->format). Treat.mdas 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) andrun_telemetry(per-run token/cost roll-up).graph_checkpoints(written bySqlRunLedgerCheckpointer,src/openhuman/tinyagents/checkpoint.rs): seq, thread_id, checkpoint_id, run_id, record_json (a full tinyagentsCheckpoint<State>), 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-printedVec<DurableSubagentSession>—subagentSessionId,parentSession(parent session_key),parentThreadId,workerThreadId,agentId, toolkit/model/sandbox/action-root selector fields,status,reusable, inlinelatestHistorymessage 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 perStoreRecord { offset, value, created_at_ms }, offset = line index. Streams live under a store root as<stream>.jsonl.Store/FileStore: key-value records as<namespace>/<key>.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 thethread/{id}/messagesshape 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 carriesthread_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:
// 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_idwhen present; when absent (old files), synthesizeimported:{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 againstagent_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'sgraph_checkpointsschema drops it. The importer setsroot_run_idon run descriptors by walkingagent_runs.parent_run_idto the root. (Separately, adding aroot_run_idcolumn tograph_checkpointsis a small schema follow-up — tracked in the audit, not part of this migration.)
Tool-call normalization
- Native-dispatcher transcripts:
tool_callsarrays map 1:1 onto tinyagentsToolCallrecords (argumentsparsed 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 existingToolDispatcherparsers 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:
Storerecordmigrations/session_import_v1with 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, andsubagent_sessions.jsonremain untouched; legacy readers keep working until parity is proven. - Surface (as built): the
openhuman.session_import_runcontroller —openhuman-core session-import run/ RPC — withdry_run,only(stem glob),force,verbose, andworkspace(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:
- Current flat
session_raw/{ts}_{agent}.jsonl, native dispatcher. - Legacy date-folder
session_raw/{DDMMYYYY}/…(pre-layout-migration). - Legacy Markdown-only session (no JSONL twin) via the
<!--MSG-->reader. - Sub-agent stems, including a two-level
a__b__cchain. - XML-dialect transcript (markup-in-content preserved byte-for-byte).
- P-format transcript (ditto).
- Assistant line with
tool_callsincl.extra_content(Gemini thought-signature passthrough). - Malformed files: missing
_metafirst line, truncated last line, empty file, unparseable message line (skip + warning, matching the current reader's tolerance). _metawithoutthread_id(synthesized thread id path).- Ledger cross-check:
agent_runsrow whoseparent_thread_iddisagrees with the stem chain (warning path). - 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
- Importer + CLI + fixtures — done (
src/openhuman/session_import/): write-only intotinyagents_store/, sources untouched, nothing reads the new records yet. - Read-side shadow: run-inspection surfaces read both and diff-log mismatches (behind a debug flag).
- Cutover: new internals read TinyAgents records; legacy readers stay as
compatibility projections keyed by
session_key. - Retirement: delete legacy readers once telemetry shows no shadow mismatches (separate decision, out of scope here).
Open questions (resolved in v1)
run_events/run_telemetryjournaling: 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-closedis_workspace_internal_pathguard keeps agent tools from writing here — desirable. subagent_sessions.jsonlatestHistorymirrors: dropped — they are a cache of the same messages the child's own transcript carries; the child stem imports as its own session.