mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
247 lines
13 KiB
Markdown
247 lines
13 KiB
Markdown
# 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 (`<tool_call>…</tool_call>`, `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 `<!--MSG …-->`
|
||
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<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-printed
|
||
`Vec<DurableSubagentSession>` — `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 `<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 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 `<!--MSG-->` 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.
|