Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ reqwest = { version = "0.12", default-features = false, features = [
# Optional embedded SQLite checkpointer backend (`graph::checkpoint::sqlite`).
# `bundled` compiles SQLite from vendored C source so no system library is
# required; the dependency is pulled in only by the `sqlite` feature.
# `bundled` compiles SQLite with FTS5 already enabled, which
# `harness::session_store` relies on for its `sessions_fts` cross-session
# search table. There is no separate `fts5` cargo feature at this version — do
# not add one, it does not resolve.
Comment on lines +48 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the stale module path in these comments.

The three new comments name harness::session_store. The PR promotes that API to the top-level session module (src/lib.rs line 85, src/session/mod.rs). Rename the references so the manifest rationale matches the shipped module path.

📝 Proposed comment updates
 # `bundled` compiles SQLite with FTS5 already enabled, which
-# `harness::session_store` relies on for its `sessions_fts` cross-session
+# `session` relies on for its `sessions_fts` cross-session
 # search table. There is no separate `fts5` cargo feature at this version — do
 # not add one, it does not resolve.
-# `serde` is required by `harness::session_store`, whose records carry
+# `serde` is required by `session`, whose records carry
 # `DateTime<Utc>` timestamps across the serde boundary.
-# Throwaway workspace roots for `harness::session_store` tests, which exercise
+# Throwaway workspace roots for `session` tests, which exercise
 # the real on-disk SQLite path rather than an in-memory database.

Also applies to: 61-62, 84-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Cargo.toml` around lines 48 - 51, Update the three comments in Cargo.toml
that reference harness::session_store to use the promoted top-level session
module path, session::session_store, while preserving the existing bundled/FTS5
rationale and wording otherwise.

rusqlite = { version = "0.40", features = ["bundled"], optional = true }

# Optional embedded Rhai scripting engine powering the `.ragsh` REPL session
Expand All @@ -54,7 +58,9 @@ rusqlite = { version = "0.40", features = ["bundled"], optional = true }
rhai = { version = "1", features = ["sync"], optional = true }

# Optional builtin tool family for deterministic time/date helpers.
chrono = "0.4"
# `serde` is required by `harness::session_store`, whose records carry
# `DateTime<Utc>` timestamps across the serde boundary.
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = { version = "0.10", optional = true }

[features]
Expand All @@ -75,6 +81,9 @@ tools = ["dep:chrono-tz"]

[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "test-util"] }
# Throwaway workspace roots for `harness::session_store` tests, which exercise
# the real on-disk SQLite path rather than an in-memory database.
tempfile = "3"
# `.env` loading for the runnable examples.
dotenvy = "0.15"
# Stream combinators (`StreamExt::next`) for integration tests that drive a
Expand Down
23 changes: 23 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,27 @@ pub enum TinyAgentsError {
/// (checkpoints, model wire formats, structured output, blueprints).
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),

/// A durable-storage operation failed — opening, migrating, reading, or
/// writing a backing database for the session store and run ledger
/// ([`crate::session`]).
///
/// Distinct from [`TinyAgentsError::Checkpoint`], which covers graph
/// checkpoint durability: a session-store failure means run *history* could
/// not be recorded or queried, while a checkpoint failure means a run
/// cannot be resumed. The payload carries the operation context and the
/// underlying driver message.
#[error("storage error: {0}")]
Storage(String),
}

/// Converts a raw `rusqlite` failure into [`TinyAgentsError::Storage`] so the
/// session store and run ledger can use `?` on driver calls directly. Call
/// sites that have useful context to add should still map explicitly rather
/// than relying on this bare conversion.
#[cfg(feature = "sqlite")]
impl From<rusqlite::Error> for TinyAgentsError {
fn from(err: rusqlite::Error) -> Self {
Self::Storage(err.to_string())
}
}
23 changes: 23 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,29 @@ pub mod registry;
pub mod repl;
#[cfg(feature = "rlm")]
pub mod rlm;
/// Durable session history and run ledger — a persistence domain in its own
/// right, not part of the agent-loop harness. Requires the `sqlite` feature.
#[cfg(feature = "sqlite")]
pub mod session;
Comment thread
senamakel marked this conversation as resolved.

// --- Session: durable session history + run ledger (feature `sqlite`) ---
// Centralized here per AGENTS.md so downstream users get a predictable surface
// rather than reaching through the module path. The record/query entry points
// and the ledger's coordination types are the surface a host actually binds
// against; the rest stays reachable via `session::` for callers that want it.
#[cfg(feature = "sqlite")]
pub use session::run_ledger::{
AgentRun, AgentRunKind, AgentRunStatus, AgentRunUpsert, AgentTeam, AgentTeamMember,
AgentTeamMemberStatus, AgentTeamStatus, AgentTeamTask, AgentTeamTaskStatus, ClaimOutcome,
CompletionOutcome, RunEvent, RunEventAppend, RunTelemetry, RunTelemetryUpsert, WorkflowRun,
WorkflowRunStatus, WorkflowRunUpsert,
};
#[cfg(feature = "sqlite")]
pub use session::{
SessionMessage, SessionRecord, SessionSearchParams, SessionSearchResult, SessionStatus,
SessionToolCall, get_session, list_sessions, record_message, record_session_end,
record_session_start, record_tool_call, search_sessions,
};

// --- Error: the crate-wide error type and `Result` alias ---
pub use error::{Result, TinyAgentsError};
Expand Down
118 changes: 118 additions & 0 deletions src/session/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# `session` — durable session history and run ledger

SQLite-backed history for agent sessions, and a restart-survivable ledger for
background agent/workflow execution. Requires the `sqlite` feature.

## Why this is a top-level module

Session history is a persistence domain in its own right, not part of the agent
loop. Nothing in `harness` reads from it, and a host can use it without running
a harness at all — indexing sessions produced elsewhere, or recovering
orchestration state at boot before any agent exists. Filing it under `harness::`
would imply a dependency that exists in neither direction.

## How it differs from the other persistence layers

| Layer | Question it answers | Lifetime |
| --- | --- | --- |
| `harness::store` | "what is this run working with right now?" | during a run |
| `graph::checkpoint` | "how do I resume this interrupted run?" | until resumed |
| **`session`** | "what happened, what did it cost, how did runs nest?" | indefinitely |

Nothing resumes from this module. It is queryable history: cross-session search,
cost attribution, and orchestration recovery.

## Layout

Every entry point takes the workspace root and derives the path itself, so a
host chooses only where its workspace lives:

```text
{workspace_dir}/session_db/sessions.db
```

## Public surface

Re-exported from the crate root (see `src/lib.rs`); the full surface stays
reachable under `session::` and `session::run_ledger::`.

- **Recording** — `record_session_start`, `record_message`, `record_tool_call`,
`record_session_end`
- **Querying** — `get_session`, `list_sessions`, `search_sessions`,
`list_messages`, `list_tool_calls`, `list_children`
- **Recovery** — `mark_interrupted`
- **Run ledger** — agent runs, workflow runs, teams, members, tasks, run events,
and telemetry, with the claim/completion coordination primitives
- **Connections** — `with_connection` (autocommit) and `with_transaction`
(`BEGIN IMMEDIATE`)
Comment on lines +36 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Two statements in this section do not match the code.

  1. Line 36 says the listed surface is "Re-exported from the crate root". src/lib.rs lines 100-104 re-export only get_session, list_sessions, record_message, record_session_end, record_session_start, record_tool_call, and search_sessions. list_messages, list_tool_calls, list_children, mark_interrupted, with_connection, and with_transaction are reachable only under session::.
  2. Line 51 says "Six tables plus one FTS5 virtual table". The table below lists ten non-FTS tables: sessions, session_messages, session_tool_calls, agent_runs, workflow_runs, run_events, run_telemetry, agent_teams, agent_team_members, and agent_team_tasks.
📝 Proposed fixes
-Re-exported from the crate root (see `src/lib.rs`); the full surface stays
-reachable under `session::` and `session::run_ledger::`.
+The recording and querying entry points and the run-ledger types are re-exported
+from the crate root (see `src/lib.rs`). The rest of the surface, marked below
+with †, is reachable only under `session::` and `session::run_ledger::`.
-Six tables plus one FTS5 virtual table, created on demand and idempotently:
+Ten tables plus one FTS5 virtual table, created on demand and idempotently:

Also applies to: 51-61

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/session/README.md` around lines 36 - 47, Update the session README
statements to match the actual API and schema: clarify that only the functions
re-exported by src/lib.rs are available at the crate root, while list_messages,
list_tool_calls, list_children, mark_interrupted, with_connection, and
with_transaction remain under session::. Correct the table-count description to
state that there are ten non-FTS tables plus one FTS5 virtual table, matching
the schema table.


## Schema

Six tables plus one FTS5 virtual table, created on demand and idempotently:

| Table | Holds |
| --- | --- |
| `sessions` | one row per session; lineage via `parent_session_id` |
| `session_messages` | per-message content, model, tokens, cost |
| `session_tool_calls` | tool name, input, bounded output, status, duration |
| `sessions_fts` | FTS5 index over session name, message content, tool name |
| `agent_runs` / `workflow_runs` | background execution state |
| `run_events` / `run_telemetry` | per-run event stream and rollups |
| `agent_teams` / `agent_team_members` / `agent_team_tasks` | team coordination |

WAL journaling and `foreign_keys = ON`. FTS5 comes from `rusqlite`'s `bundled`
build — there is no separate `fts5` cargo feature at 0.40, so do not add one.

## Operational constraints

These are the non-obvious rules; each is pinned by a test in `test.rs`.

**Search input is plain text, not FTS5 syntax.** `SessionSearchParams::query` is
translated to a quoted FTS5 expression before it reaches `MATCH`. Binding raw
user input made ordinary strings (`C++`, `foo-bar`, `file.rs`) fail with a
syntax or `no such column` error instead of searching.

**Indexed content is truncated on a character boundary.** Slicing at a raw byte
offset panics on multi-byte input, and because the message insert has already
committed, the row would survive with no FTS entry — silently unsearchable.

**Tool output is bounded** to `MAX_TOOL_OUTPUT_BYTES`, truncated on a character
boundary with a marker appended.

**Telemetry counters are `Option` for partial updates.** The columns are
`NOT NULL DEFAULT`, and SQLite does not apply a column default to an explicitly
supplied `NULL`, so the insert path coalesces to the default while the update
path coalesces to the stored value. `excluded.*` cannot serve the update side —
it observes the already-coalesced row, so `None` would read as `0` and clobber a
stored counter.

**Run-event sequences are allocated by the INSERT itself.** Reading
`MAX(sequence) + 1` and then inserting is a read-modify-write race; the loser
fails the primary key and the event is lost.

**Coordination operations need `with_transaction`, not `with_connection`.**
`with_connection` is autocommit, which gives ordering but no isolation. Claim
and completion read state and then act on it, so they take the write lock up
front with `BEGIN IMMEDIATE` — racing claims serialize at `BEGIN` rather than
failing at `COMMIT` after one has already decided it won.
Comment on lines +93 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This guarantee does not hold without a busy timeout.

The text states that racing claims "serialize at BEGIN rather than failing at COMMIT". src/session/store.rs sets no busy_timeout, so the second BEGIN IMMEDIATE returns SQLITE_BUSY immediately instead of waiting. See the comment on src/session/store.rs lines 55-65. Update this paragraph after that fix lands, or state the current behaviour.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/session/README.md` around lines 93 - 97, Update the README paragraph
describing coordination operations and BEGIN IMMEDIATE to reflect the current
SQLITE_BUSY behavior when no busy timeout is configured, or revise it after the
store’s busy-timeout handling is added; do not claim racing claims wait and
serialize unless the implementation guarantees that behavior.


**A claim is meaningful only while a task is `in_progress`.** An upsert that
moves a task off that status clears `claimed_by_member_id` and `claim_token`;
leaving them set strands the task, since a new claim sees `AlreadyClaimed`,
completion sees `NotClaimed`, and release/shutdown skip it.

**Evidence accumulates across completion attempts,** including attempts whose
gate fails — otherwise a retry after fixing an unrelated gate would fail
`require_evidence` on evidence already submitted.

## Layout of this module

| File | Role |
| --- | --- |
| `mod.rs` | module docs and public surface |
| `types.rs` | serde record types |
| `store.rs` | connection/transaction helpers and schema init |
| `ops.rs` | recording and querying |
| `context.rs` | `StorageContext`, the error-context shim |
| `run_ledger/` | background run + team coordination |
| `test.rs` | module-local unit tests |
38 changes: 38 additions & 0 deletions src/session/context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! Error-context helper for the session store's SQLite surface.
//!
//! The store and run ledger are dense with driver calls whose bare failures
//! (`no such table`, `database is locked`) say nothing about which operation
//! raised them. This trait attaches that operation context while funnelling
//! everything into [`TinyAgentsError::Storage`], so the crate keeps one error
//! type without every call site writing the same closure.
//!
//! It deliberately mirrors the shape of `anyhow::Context` — including the
//! `Option` impl for "row expected but absent" — because this module was
//! ported from a host that used `anyhow`, and matching the shape kept that
//! port mechanical and reviewable.

use std::fmt::Display;

use crate::error::{Result, TinyAgentsError};

/// Attaches operation context to a fallible storage call, producing a
/// [`TinyAgentsError::Storage`].
pub(crate) trait StorageContext<T> {
/// Wraps the failure as a storage error prefixed with `context`.
fn storage_context(self, context: &str) -> Result<T>;
}

impl<T, E: Display> StorageContext<T> for std::result::Result<T, E> {
fn storage_context(self, context: &str) -> Result<T> {
self.map_err(|err| TinyAgentsError::Storage(format!("{context}: {err}")))
}
}

/// A `None` where a row was expected is a storage inconsistency, not a
/// user-facing absence — the callers using this read back a record they just
/// wrote, so `None` means the write silently failed.
impl<T> StorageContext<T> for Option<T> {
fn storage_context(self, context: &str) -> Result<T> {
self.ok_or_else(|| TinyAgentsError::Storage(context.to_string()))
}
}
Loading