diff --git a/docs/specs/2026-07-28-agent-session-transcript-to-tinyagents-design.md b/docs/specs/2026-07-28-agent-session-transcript-to-tinyagents-design.md index d1714ca4cd..3bd0882de9 100644 --- a/docs/specs/2026-07-28-agent-session-transcript-to-tinyagents-design.md +++ b/docs/specs/2026-07-28-agent-session-transcript-to-tinyagents-design.md @@ -288,7 +288,13 @@ rollups, path resolution) stay on the concrete type, reached directly by the 24 consumers that need them. - **On-disk change:** none. **Migration risk:** none. -- **Removes:** ~400 LOC of parallel abstraction, plus the conceptual duplicate. +- **Removes:** ~15 LOC — the duplicated path-resolution block in + `persist_session_transcript`. **Adds** ~90 for the read + locator seam + (S2–S4 as a whole: +782 / −0). Option A buys a single documented, + *substitutable* seam, not a line reduction. The original "~400 LOC of parallel + abstraction" figure was measured and refuted — see + [Where "~400 LOC" came from](#where-400-loc-came-from-and-why-it-is-struck) + — and it contradicted this option's own **Weakness** bullet two lines below. - **Cost:** low. Reversible. - **Weakness:** the crate trait is only used on the narrow runtime path; most of `transcript.rs` stays. Honest framing: this fixes *"two abstractions"*, not @@ -383,13 +389,185 @@ byte-identity assertion against the pre-change reader passes. ### S4 — Route the harness through the trait -The turn path takes `Arc` instead of calling transcript free +The turn path takes `Arc` instead of calling transcript free functions. The 24 consumers that need display records, usage rollups, or path resolution keep using the concrete type — that is correct, not debt. **Exit:** `agent_harness_e2e` + `scripts/test-rust-with-mock.sh` green; `threads/transcript_view` projection output unchanged (golden test). +#### Landed as `SessionHistory: ChatHistory`, not `ChatHistory` — and why + +S4's two halves as originally written contradict each other. `ChatHistory` +(`vendor/tinyagents/src/harness/memory/types.rs`) has four methods, each +carrying only a `thread_id: &str` plus `Message` / `Vec`. The turn +path's write carries three things none of them can express: + +- **`request_id`**, stamped on every line. Drives `DisplayItem::TurnBoundary` + and the `(request_id, ts)` root-turn segments that anchor every + `DisplayItem::Subagent` in `threads/transcript_view/project.rs`. +- **`turn_usage`**, attributed to the turn's last assistant row. Carries + `model`, `iteration`, `ts`, `reasoning_content` and the native `tool_calls`. + The projection reads **every** `DisplayItem::ToolCall` off + `turn_usage.tool_calls`, so losing it deletes the tool rows outright (each + following `role:"tool"` line then falls to the orphan branch), along with + `Reasoning`, `AssistantMessage.{model,iteration}` and `interim`. +- **`TranscriptMeta`'s cumulative fields** — `turn_count` plus the four + token/cost rollups `read_thread_usage_summary` reports. The turn path computes + these fresh each turn; the trait path can only re-read the file's existing + `_meta`, which would freeze them at the previous turn's values. + +A literal `Arc` write would therefore have failed S4's own exit +criterion while looking complete. The criterion wins: what landed is +`pub(crate) trait SessionHistory: ChatHistory`, declared in +`agent/harness/session/transcript_history.rs`, whose single `append_turn` method +forwards the same six arguments `append_transcript_turn` already takes. The +indirection is real, `ChatHistory` stays in the bound so S2/S3 are not orphaned, +and the on-disk bytes are unchanged by construction — +`append_turn_is_byte_identical_to_the_free_function` writes one turn both ways +and compares the files byte for byte. + +The **read** path does not cross `ChatHistory` either, and that half is settled +for the same shape of reason: `ChatHistory::messages()` returns `Vec`, +and converting back with `message_to_chat_message` flattens +`Assistant.tool_calls` into plain text — exactly what +`bound_cached_transcript_messages`' TAURI-RUST-7 trailing strip inspects, and +what native providers reject with `400 assistant message with 'tool_calls' must +be followed by tool messages`. (A round-trip probe confirmed the loss set: +assistant `tool_calls`, plus `openhuman_turn_usage` `extra_metadata` and +`AssistantMessage.id`, both inert here. The tool-failure marker is *not* in it — +that is a write-side/display-side field `read_transcript` never re-emits.) + +An earlier revision of this section concluded from that the read must stay on +the concrete free functions. That conclusion was wrong, and the reasoning behind +it rested on a premise that is false of the type as it stands: +`SessionTranscriptHistory` is bound to a resolved `PathBuf`, not to a stem — its +two constructors merely *resolve* one — so a discovered path can be bound +verbatim. What landed instead: + +- **`SessionTranscriptRead { path(); read_session() -> Option }`**, + a second supertrait of `SessionHistory`. `read_session` is the same + `read_transcript` call the free-function readers made, returning the same + struct, so losslessness is *structural*: nothing crosses `Message`, and + compaction replay, `interrupted: true` partial skipping and the `_meta` header + `maybe_shadow_read_session_store` needs all survive by construction. Split + from `SessionHistory` rather than added to it because a discovered transcript + can still be a legacy `.md` file, and handing read results out as + `Arc` makes appending JSONL into one impossible by + construction rather than by convention. +- **`SessionTranscriptHistory::opened_at(path, seed_meta)`**, which stores the + discovered path verbatim. It deliberately bypasses + `resolve_keyed_transcript_path_in_dir`, which `create_dir_all`s and forces a + `.jsonl` extension — that would mangle the legacy `.md` case and create stray + directories on a pure read. +- **`SessionHistoryLocator`** (`latest_for_agent` / `root_for_thread` / + `open_stem`), with `FileTranscriptLocator` as the default. Discovery *is* the + thing `ChatHistory` cannot express — it is `thread_id`-keyed and returns + messages, never a location — so it belongs on an OpenHuman-side object. + Leaving it as free functions was what kept the read half on the filesystem no + matter what handle was injected. + +**The injection point now exists**, which is what makes the `Arc` +non-decorative. `AgentBuilder::with_session_history_locator` sets +`Agent::session_history_locator`; `Agent::session_locator()` resolves `None` +*lazily* into a `FileTranscriptLocator` over the **current** `workspace_dir` / +`session_raw_subdir` (never frozen at build time — callers reassign +`workspace_dir` after `build()`, and a captured locator would silently keep +reading the old directory). One injected object now covers both resume reads +*and* the session's own write handle, and +`fake_locator_substitutes_the_whole_turn_path` drives all three through a fake +and asserts nothing is written under the workspace. + +`persist_session_transcript`'s own path resolution went with it: +`session_transcript_path` is now simply the bound handle's `path()`, so the two +can no longer drift. + +**Widening `ChatHistory` upstream is REJECTED, not deferred.** S0's rationale +notes this question has already been re-opened twice, so the finding is recorded +here to stop a third round: the crate's `Usage` has no `cost_usd` / +`context_window`; `TranscriptMeta` is a cumulative *file header*, not turn +provenance; and the per-message tool-failure `extra_metadata` that +`message_to_chat_message` drops is untouchable by any turn-level record. You +would pay a tinyagents release and still need a `serde_json::Value` escape +hatch — for a trait that has no consumer inside the vendored crate outside +`harness/memory/`. + +One live defect was fixed on the way in: the handle resolved its path through +`resolve_keyed_transcript_path`, which hardcodes `{workspace}/session_raw/`. A +dedicated-memory profile's sessions live in `session_raw-/`, so wiring the +handle into the turn path as-written would have silently cross-written profile +sessions into the shared profile's directory. `new_in_dir` takes the raw dir +explicitly and is what the turn path uses. + +Deliberately **not** relocated: `persisted_transcript_messages` and +`session_transcript_path` stay on `Agent`. The former is the in-memory diff +cache the append-only writer needs; substituting the handle's disk re-read is +lossy against `common_prefix_len` (`read_transcript` lifts `failure` / +`failure_detail` out of `extra_metadata` and hoists turn-usage to top-level line +fields), so the writer would emit a full compaction record every turn. The +latter is what `maybe_dual_write_session_store` needs a concrete `&Path` for. + +Also deliberately kept: the `impl ChatHistory for SessionTranscriptHistory` from +S3 still has **no production caller** after S4 — reads go through +`read_session`, writes through `append_turn`. It is not deleted, because it is +the crate-side seam Option A exists to establish and it supplies the +`Send + Sync + 'static` bounds the shared handle needs. The trigger that would +delete it is an explicit decision to drop `ChatHistory` from the +`SessionHistory` bound, which frees `transcript_history.rs`'s +`read`/`persisted`/`meta_for_write`/`write_logical_set`/`impl ChatHistory` plus +most of its test module (~570 lines together). That is the only ~400-scale +removal S4 can actually make — and it removes abstraction this work itself +added, which is not what Option A was promising. Recorded so a future audit +finds the decision rather than re-deriving it. + +#### Where "~400 LOC" came from, and why it is struck + +§4 Option A originally claimed it "Removes: ~400 LOC of parallel abstraction". +The figure has no derivation anywhere in this document or its parent, and it is +not achievable. Its arithmetic origin is recoverable: §2.1's in-scope table +totals **2,475** LOC at this document's base commit (`transcript.rs` 1,997 + +`turn_checkpoint.rs` 105 + `migration.rs` 373). Option B's "~2,100 host LOC" is +exactly 1,997 + 105. The residual is **373 ≈ "~400" = `migration.rs`** — which +§5 S1 and `docs/tinyagents-full-migration-plan/99-deletion-ledger.md:33` both +resolve as HOST-OWNED, no deletion. Under Option A the §2.1 table loses **zero** +lines. + +Every other candidate was checked and refuted: + +- **No host trait duplicates `ChatHistory`.** The only other match in `src/` is + a `MemorySource::ChatHistory` *enum variant* in `memory/remember.rs`. + `memory/store/memory_trait.rs` is the long-term semantic `Memory` trait — a + different concern with a different shape. +- **`ShortTermMemory`'s `trim` is an empty hook slot** + (`vendor/tinyagents/src/harness/memory/types.rs`), so there is no crate-side + policy for the host to be parallel to. The host side is 104 LOC of + provider-400 defences (`trim_history`, `bound_cached_transcript_messages`) + with no crate analogue — not duplicated, not deletable. +- **`agent/context/`'s reducer was already deleted under #4249**, before this + document was written (`context/manager.rs`: "Live history reduction/ + summarization moved to the tinyagents graph"). What remains is prompt + assembly + stats. + +#### The one genuine parallel abstraction, and why S4/S5 cannot remove it + +The #4249 JSONL↔store mirror **is** a second session-persistence implementation, +over crate `Store`/`AppendStore` rather than `ChatHistory`: `session_import/ +live.rs` (353), `Agent::maybe_shadow_read_session_store` / +`maybe_dual_write_session_store` (119), the `StoreRegistry` registration in +`agent/tinyagents/mod.rs`, two `AgentConfig` flags, and +`config/migrations/enable_session_shadow_reads.rs` — ~565 prod LOC. It is the +closest thing in the tree to "~400 LOC of parallel abstraction". + +It is out of scope here for two reasons. It is #4249's own 04.1/04.2 program, +gated on that issue's Phase-2 parity soak (#5396, which flipped +`session_shadow_reads` default-ON with a config migration); and its terminus — +serving reads from the store — points the opposite way from this branch's +non-negotiable zero-on-disk-change constraint. **It is also not S5's soak:** S5 +compares free-function reads against trait reads, an entirely different +comparison. Track it as a #4249 phase-3 item ("retire the JSONL↔store dual path +once the Phase-2 parity soak declares parity") with a deletion-ledger row naming +the six sites above. + ### S5 — Shadow soak, then remove the parallel path One release with both paths live and a read-side comparison logged on mismatch @@ -488,7 +666,7 @@ Recorded so a later audit does not re-litigate: | In scope | `transcript.rs` (1,997), `turn_checkpoint.rs` (105), `migration.rs` (373) — ≤ 2 host imports each | | Key finding | the crate already ships `harness::memory::ChatHistory` + `harness::store` stream API; OpenHuman has a **second implementation**, not a missing home | | Key constraint | crate `ChatHistory` cannot express compaction records, interrupted partials, or dual read paths — a naive impl corrupts model context | -| Recommendation | **Option A** — host backend behind the crate trait; ~400 LOC, zero on-disk change, reversible | +| Recommendation | **Option A** — host backend behind the crate trait; zero on-disk change, reversible. Ledger is ≈ **−15 / +90 LOC** (S2–S4 overall +782 / −0), *not* the "~400 LOC removed" originally claimed — see §5 S4, [Where "~400 LOC" came from](#where-400-loc-came-from-and-why-it-is-struck) | | Escalation | **Option B** (upstream `JsonlChatHistory`, ~2,100 LOC) only as a deliberate crate-roadmap decision | | `builder/factory.rs` re-check (§3.5.1) | stays — builds `Agent` (40+ fields of product session state), not `AgentHarness` (6 fields of execution config); one real carve-out: dispatcher selection duplicates crate `with_native_tool_calling` | | `turn/core.rs` re-check (§3.5.2) | stays — the engine left in WP-3; residue is product enrichment. ~150 LOC of message-list helpers are upstreamable | diff --git a/docs/tinyagents-full-migration-plan/99-deletion-ledger.md b/docs/tinyagents-full-migration-plan/99-deletion-ledger.md index 55b02e3938..ebc496f1fd 100644 --- a/docs/tinyagents-full-migration-plan/99-deletion-ledger.md +++ b/docs/tinyagents-full-migration-plan/99-deletion-ledger.md @@ -27,6 +27,11 @@ also name its upstream PR before the host copy is removed. | WP-5 | generic seam middlewares | Equivalent crate middleware released and adopted | PARTIAL | `SchemaGuard` deleted; TinyAgents #72 repeat tracker adopted and host duplicate accounting deleted (51 focused middleware tests green); `ArgRecovery` still awaits TinyAgents #71. Per-middleware drift rows remain authoritative. | | WP-5 | detached subagent registry mechanics | Crate `DetachedTaskRegistry` + `TaskStore`/`SteeringRegistry` own generic process-local lifecycle | CLOSED | TinyAgents #75 merged as `d548657` and canonical pointer `4358efe` contains it; OpenHuman commits `3fc769828` + `29908675f`; 17 focused `running_subagents` tests green. Host retains durable projection, product metadata, RPC, and `RunQueue` fallback. | | WP-5 | `agent/progress_tracing.rs` and `progress_tracing/langfuse.rs` | C4 S2-S6 gates pass; journal projection is self-sufficient | BLOCKED | One-release shadow parity and C4 §5 gate | +| WP-5 | `agent/session_db/` (store, run ledger, types) | Generic session history; only host coupling was `config.workspace_dir` | UPSTREAMED | Now `tinyagents::session`; entry points take `&Path`. 34 tests moved intact; DB path and `session_db`/`run_ledger` RPC namespaces unchanged. Host keeps `schemas.rs` only | +| WP-5 | `agent/harness/session/transcript.rs` | No deletion: durable `session_raw` on-disk format, `.md` companion rendering, display read, and usage rollups are product surface | HOST-OWNED | 2026-07-28 design §4 Option A. `SessionTranscriptHistory` implements crate `ChatHistory` over it, so the harness talks to the trait while OpenHuman owns the format. Zero on-disk change. S4 landed the turn path on OpenHuman-side supertraits (`SessionHistory::append_turn` for writes, `SessionTranscriptRead::read_session` + `SessionHistoryLocator` for reads) — the crate trait cannot carry `request_id`/`turn_usage`/`TranscriptMeta`, nor return `tool_calls` losslessly. Measured ledger ≈ −15/+90 LOC; the spec's "~400 LOC removed" is struck | +| WP-5 | `agent/harness/session/turn_checkpoint.rs` | No deletion: built on `ChatMessage`, the versioned on-disk record WP-1 settled as host-owned | HOST-OWNED | Replacing it with crate `Message` would change existing users' data. Only design §4 Option B reopens this | +| WP-5 | #4249 JSONL↔store session mirror — `agent/session_import/live.rs`, `Agent::maybe_shadow_read_session_store` / `maybe_dual_write_session_store` (`session/turn/session_io.rs`), the `StoreRegistry` registration in `agent/tinyagents/mod.rs`, the two `session_dual_write` / `session_shadow_reads` `AgentConfig` flags, and `config/migrations/enable_session_shadow_reads.rs` (~565 prod LOC) | The one genuine parallel session-persistence implementation in the tree, over crate `Store`/`AppendStore` rather than `ChatHistory` | BLOCKED | Gated on #4249's own Phase-2 parity soak (#5396 flipped `session_shadow_reads` default-ON with a config migration). **Not** the 2026-07-28 design's S5 soak — that one compares free-function reads against trait reads. Retire as a #4249 phase-3 item once parity is declared | +| WP-5 | `agent/harness/session/migration.rs` | No deletion: zero host imports, but migrates *OpenHuman's* layout — hardcodes `session_raw`, `sessions`, `state/migrations/session_layout_v1.done`, keyed to release 0.53.4 | HOST-OWNED | Design §5 S1 check performed, not assumed: generic code for a host-specific format | Deletion totals are reconciled in WP-6 after all rows are terminal. The original projection is approximately 30k host LOC deleted and 12–15k generic diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index adf3b53163..f5126c853c 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -2441,7 +2441,7 @@ pub async fn bootstrap_core_runtime( // the finalizer never settled it. Stamp such rows `interrupted` so they stop // rendering as perpetual "running" timeline entries on thread reopen. if agent_enabled { - match crate::openhuman::agent::session_db::run_ledger::interrupt_orphaned_agent_runs(&cfg) { + match tinyagents::session::run_ledger::interrupt_orphaned_agent_runs(&cfg.workspace_dir) { Ok(0) => {} Ok(count) => log::info!("[runtime] settled {count} orphaned agent run(s) on startup"), Err(err) => log::warn!("[runtime] failed to settle orphaned agent runs: {err}"), diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 7edd067b32..4fe58fd547 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -50,6 +50,7 @@ impl AgentBuilder { memory_subdir: None, session_raw_subdir: None, session_parent_prefix: None, + session_history_locator: None, omit_profile: None, omit_memory_md: None, payload_summarizer: None, @@ -354,6 +355,24 @@ impl AgentBuilder { self } + /// Substitute the transcript backing store for this session. + /// + /// The one injection point for the S4 seam: the locator resolves both + /// resume reads (`latest_for_agent` / `root_for_thread`) **and** binds the + /// session's write handle (`open_stem`), so a fake supplied here takes the + /// whole turn path off the filesystem. Leave unset in production — `None` + /// resolves lazily to a + /// [`FileTranscriptLocator`][super::super::transcript_history::FileTranscriptLocator] + /// over the agent's current workspace, which is behaviourally identical to + /// the pre-S4 free-function calls. + pub(crate) fn with_session_history_locator( + mut self, + locator: std::sync::Arc, + ) -> Self { + self.session_history_locator = Some(locator); + self + } + /// Forward the target agent definition's `omit_profile` flag so /// [`Agent::build_system_prompt`] can decide whether to inject /// `PROFILE.md`. Only opt-in agents (welcome, orchestrator, the @@ -614,6 +633,8 @@ impl AgentBuilder { memory_subdir, session_raw_subdir, session_transcript_path: None, + session_history: None, + session_history_locator: self.session_history_locator, persisted_transcript_messages: Vec::new(), session_key: { let unix_ts = std::time::SystemTime::now() diff --git a/src/openhuman/agent/harness/session/mod.rs b/src/openhuman/agent/harness/session/mod.rs index 7ed56db905..717c58e50d 100644 --- a/src/openhuman/agent/harness/session/mod.rs +++ b/src/openhuman/agent/harness/session/mod.rs @@ -39,6 +39,7 @@ mod runtime; #[cfg(test)] mod tool_progress; pub(crate) mod transcript; +pub(crate) mod transcript_history; mod turn; mod turn_checkpoint; mod types; diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index 37bafca017..d97c882924 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -485,6 +485,14 @@ impl Agent { /// user message is appended later by [`Self::run_single`] / `turn`, so it is /// intentionally absent from the loaded prefix — no dedup is needed here (the /// on-disk transcript ends at the previous completed turn). + /// + /// Goes through the S4 seam like `try_load_session_transcript` (see its doc + /// comment for why the read is `read_session` and not + /// `ChatHistory::messages()`), via the locator's `root_for_thread` — the + /// lookup that resolves by `_meta.thread_id` across *root* transcripts + /// only. That disambiguation is why it is a locator method rather than + /// anything a stem-bound handle could offer: several transcripts share one + /// thread id (every sub-agent spawned within it does). pub fn seed_resume_from_thread_transcript(&mut self, thread_id: &str) -> bool { if !self.history.is_empty() || self.cached_transcript_messages.is_some() { log::debug!( @@ -497,25 +505,12 @@ impl Agent { } // The thread's conversation belongs to the THREAD, not the active - // profile. Resolve via the cross-dir finder, which scans the shared - // `session_raw/` AND every profile-scoped `session_raw-/` for this - // exact `thread_id` and returns the NEWEST match. So switching the active - // profile mid-thread (e.g. the Quick↔Reasoning toggle) continues the same - // conversation even when earlier turns were written under a different - // profile's subtree — a dedicated-memory personality, or a profile an - // earlier build wrongly scoped (#5351). - // - // Deliberately NOT own-dir-first (`in_dir(session_raw_subdir).or_else(…)`): - // that would let an *older* transcript in the agent's own dir shadow a - // *newer* one a sibling scoped dir holds for the same thread — dropping - // the most recent turns, and diverging from the transcript view + turn - // mirror, which both use this same newest-across-dirs resolver. The own - // dir is already included in the scan, so newest-wins is a superset. - // Keyed on `thread_id`, so it never bleeds an unrelated session across - // profiles; a blank id short-circuits to `None`. - let Some(path) = - super::transcript::find_root_transcript_for_thread(&self.workspace_dir, thread_id) - else { + // profile: the locator resolves cross-dir, newest-wins across the + // shared `session_raw/` and every profile-scoped `session_raw-/` + // (#5351), so switching profile mid-thread continues the same + // conversation. See `FileTranscriptLocator::root_for_thread` for why + // this must not be own-dir-first. + let Some(handle) = self.session_locator().root_for_thread(thread_id) else { log::debug!( "[web-channel] no root session_raw transcript for thread={thread_id} in any \ (shared or profile-scoped) session_raw dir — falling back to \ @@ -523,6 +518,7 @@ impl Agent { ); return false; }; + let path = handle.path().to_path_buf(); log::info!( "[web-channel] cold-boot resume — loading full-fidelity transcript for \ @@ -530,8 +526,18 @@ impl Agent { path.display() ); - match super::transcript::read_transcript(&path) { - Ok(session) => { + match handle.read_session() { + // `Ok(None)` (file vanished between discovery and read) folds into + // the same empty-transcript branch, so the prose-seeding fallback + // triggers identically. + Ok(None) => { + log::debug!( + "[web-channel] root transcript for thread={thread_id} is empty — \ + falling back to prose seeding" + ); + false + } + Ok(Some(session)) => { if session.messages.is_empty() { log::debug!( "[web-channel] root transcript for thread={thread_id} is empty — \ diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index 145720c561..dcf706984d 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -1896,3 +1896,246 @@ fn set_max_tool_iterations_survives_after_definition_backed_construction() { "post-construction override must win over the definition-resolved cap" ); } + +// ───────────────────────────────────────────────────────────────────── +// S4: the transcript seam is genuinely substitutable +// ───────────────────────────────────────────────────────────────────── + +/// A `SessionHistory` that keeps everything in memory and touches no file. +/// +/// The point of the fake is not that it is convenient — it is that it is +/// *possible*. Before the locator existed, `session_history` was an +/// `Arc` the turn path constructed inline, so nothing could ever be put +/// behind it; this fake failing to compile or failing to receive the turn is +/// the regression signal for that. +struct FakeSessionHistory { + path: std::path::PathBuf, + canned: Option, + appended: Mutex>>, +} + +impl crate::openhuman::agent::harness::session::transcript_history::SessionTranscriptRead + for FakeSessionHistory +{ + fn path(&self) -> &std::path::Path { + &self.path + } + + fn read_session( + &self, + ) -> Result> + { + Ok(self.canned.clone()) + } +} + +impl crate::openhuman::agent::harness::session::transcript_history::SessionHistory + for FakeSessionHistory +{ + fn append_turn( + &self, + turn: crate::openhuman::agent::harness::session::transcript_history::TranscriptTurn<'_>, + ) -> Result<()> { + self.appended.lock().push(turn.next.to_vec()); + Ok(()) + } +} + +#[async_trait] +impl tinyagents::harness::memory::ChatHistory for FakeSessionHistory { + async fn messages(&self, _thread_id: &str) -> tinyagents::Result> { + Ok(vec![]) + } + async fn append(&self, _thread_id: &str, _message: Message) -> tinyagents::Result<()> { + Ok(()) + } + async fn replace(&self, _thread_id: &str, _messages: Vec) -> tinyagents::Result<()> { + Ok(()) + } + async fn clear(&self, _thread_id: &str) -> tinyagents::Result<()> { + Ok(()) + } +} + +/// Serves one canned transcript for every lookup and one recording write +/// handle, so a whole session's transcript I/O can be observed off-disk. +struct FakeLocator { + handle: Arc, +} + +impl crate::openhuman::agent::harness::session::transcript_history::SessionHistoryLocator + for FakeLocator +{ + fn latest_for_agent( + &self, + _agent_name: &str, + ) -> Option< + Arc, + >{ + Some(self.handle.clone()) + } + + fn root_for_thread( + &self, + _thread_id: &str, + ) -> Option< + Arc, + >{ + Some(self.handle.clone()) + } + + fn open_stem( + &self, + _stem: &str, + _seed: crate::openhuman::agent::harness::session::transcript::TranscriptMeta, + ) -> Result< + Arc, + > { + Ok(self.handle.clone()) + } +} + +fn fake_transcript_meta( + thread_id: &str, +) -> crate::openhuman::agent::harness::session::transcript::TranscriptMeta { + crate::openhuman::agent::harness::session::transcript::TranscriptMeta { + agent_name: "faker".into(), + agent_id: None, + agent_type: Some("root".into()), + dispatcher: "native".into(), + provider: None, + model: None, + created: "2026-08-08T00:00:00Z".into(), + updated: "2026-08-08T00:00:00Z".into(), + turn_count: 1, + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + charged_amount_usd: 0.0, + thread_id: Some(thread_id.into()), + task_id: None, + } +} + +fn agent_with_fake_locator( + workspace: &std::path::Path, + canned: Option, +) -> (Agent, Arc) { + let handle = Arc::new(FakeSessionHistory { + path: workspace.join("session_raw").join("fake.jsonl"), + canned, + appended: Mutex::new(Vec::new()), + }); + let memory_cfg = crate::openhuman::config::MemoryConfig { + backend: "none".into(), + ..crate::openhuman::config::MemoryConfig::default() + }; + let mem: Arc = + Arc::from(crate::openhuman::memory::store::create_memory(&memory_cfg, workspace).unwrap()); + let agent = Agent::builder() + .chat_model(Arc::new(MockProvider { + responses: Mutex::new(vec![]), + })) + .tools(vec![Box::new(MockTool)]) + .memory(mem) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .agent_definition_name("faker") + .workspace_dir(workspace.to_path_buf()) + .with_session_history_locator(Arc::new(FakeLocator { + handle: handle.clone(), + })) + .build() + .expect("agent build should succeed"); + (agent, handle) +} + +/// Both resume reads and the turn write are served by the injected locator, +/// with **nothing written under the workspace**. That last assertion is the +/// whole point: it is the proof the `Arc` is a real seam rather than +/// decoration around a hardcoded filesystem call. +#[tokio::test] +async fn fake_locator_substitutes_the_whole_turn_path() { + let workspace = tempfile::TempDir::new().expect("temp workspace"); + let canned = crate::openhuman::agent::harness::session::transcript::SessionTranscript { + meta: fake_transcript_meta("thr_fake"), + messages: vec![ + crate::openhuman::agent::messages::ChatMessage::system("canned system"), + crate::openhuman::agent::messages::ChatMessage::user("canned question"), + crate::openhuman::agent::messages::ChatMessage::assistant("canned answer"), + ], + }; + let (mut agent, handle) = agent_with_fake_locator(workspace.path(), Some(canned)); + + // (1) The stem-keyed resume read. + agent.try_load_session_transcript(); + let cached = agent + .cached_transcript_messages + .as_ref() + .expect("resume prefix came from the fake locator"); + assert_eq!( + cached + .iter() + .map(|m| m.content.as_str()) + .collect::>(), + vec!["canned system", "canned question", "canned answer"] + ); + + // (2) The thread-keyed cold-boot read (cleared first — it no-ops on a warm + // agent by design). + agent.cached_transcript_messages = None; + assert!(agent.seed_resume_from_thread_transcript("thr_fake")); + assert_eq!( + agent + .cached_transcript_messages + .as_ref() + .expect("cold-boot prefix") + .len(), + 3 + ); + + // (3) The write. + let turn = vec![ + crate::openhuman::agent::messages::ChatMessage::user("live question"), + crate::openhuman::agent::messages::ChatMessage::assistant("live answer"), + ]; + agent.persist_session_transcript(&turn, 1, 2, 0, 0.0, None); + let appended = handle.appended.lock(); + assert_eq!(appended.len(), 1, "the turn reached the injected handle"); + assert_eq!( + appended[0] + .iter() + .map(|m| m.content.as_str()) + .collect::>(), + vec!["live question", "live answer"] + ); + assert_eq!( + agent.session_transcript_path.as_deref(), + Some(handle.path.as_path()), + "session_transcript_path is the bound handle's own path — they cannot drift" + ); + + drop(appended); + + // (4) Nothing touched the transcript filesystem. (The #4249 store mirror + // still runs — it is a separate, gated path this seam does not own — but it + // never writes `session_raw/`.) + assert!( + !workspace.path().join("session_raw").exists(), + "an injected locator must take the turn path entirely off disk" + ); +} + +/// A locator that finds nothing must leave the agent cold, so the caller's +/// prose-seeding fallback still fires. +#[test] +fn fake_locator_with_no_transcript_leaves_the_agent_cold() { + let workspace = tempfile::TempDir::new().expect("temp workspace"); + let (mut agent, _handle) = agent_with_fake_locator(workspace.path(), None); + + agent.try_load_session_transcript(); + assert!(agent.cached_transcript_messages.is_none()); + assert!( + !agent.seed_resume_from_thread_transcript("thr_fake"), + "an Ok(None) read must report false like a missing file did" + ); +} diff --git a/src/openhuman/agent/harness/session/transcript_history.rs b/src/openhuman/agent/harness/session/transcript_history.rs new file mode 100644 index 0000000000..88c5ec886d --- /dev/null +++ b/src/openhuman/agent/harness/session/transcript_history.rs @@ -0,0 +1,648 @@ +//! [`ChatHistory`] over OpenHuman's durable `session_raw` transcript. +//! +//! This is the seam chosen in `docs/specs/2026-07-28-agent-session-transcript-to-tinyagents-design.md` +//! §4 Option A: the harness talks to the crate's +//! [`tinyagents::harness::memory::ChatHistory`] trait, while OpenHuman keeps +//! ownership of the on-disk format. Nothing about `session_raw` moves, so +//! there is no on-disk change and no migration risk — the previous parallel +//! abstraction is what goes away. +//! +//! [`SessionTranscriptHistory`] is a thin handle over the free functions in +//! [`super::transcript`]. It holds no state beyond the transcript's identity, +//! so it is cheap to construct per turn and safe to share. +//! +//! # Why the trait's `thread_id` is not used for path resolution +//! +//! `ChatHistory` is keyed by `thread_id`, but a `session_raw` transcript is +//! keyed by its **stem** (`{unix_ts}_{agent_id}`, or `{parent_chain}__…` for a +//! sub-agent). These are deliberately different: several transcripts can share +//! one `_meta.thread_id` — every sub-agent spawned within a thread does — so +//! resolving a path from `thread_id` would be ambiguous and could interleave +//! two agents' histories into one file. +//! +//! The handle is therefore bound to one transcript at construction, and the +//! `thread_id` argument is accepted for trait conformance only. Callers that +//! genuinely want thread-level lookup use +//! [`super::transcript::find_root_transcript_for_thread`]. +//! +//! # Append-only, including `clear` +//! +//! Every mutation routes through +//! [`append_transcript_turn`][super::transcript::append_transcript_turn], which +//! never rewrites existing lines. A reduction in the logical message set +//! becomes a `{"kind":"compaction","replacement":[…]}` record rather than a +//! file rewrite. That includes [`SessionTranscriptHistory::clear`] — see its +//! doc comment for the semantics that were chosen and why. +//! +//! # Why the metadata-bearing write does not cross the crate trait +//! +//! S4 of the design doc is worded as "the turn path takes `Arc`", but that wording conflicts with S4's own exit criterion +//! ("`threads/transcript_view` projection output unchanged"). The crate trait +//! (`vendor/tinyagents/src/harness/memory/types.rs`) has exactly four methods, +//! and every one of them carries only a `thread_id: &str` plus `Message` / +//! `Vec`. Three things the turn path persists therefore have **no +//! channel**: +//! +//! - **`request_id`** — stamped on every line of a turn. It drives +//! `DisplayItem::TurnBoundary` (`threads/transcript_view/project.rs`, +//! `maybe_emit_turn_boundary`) and the `(request_id, ts)` root-turn segments +//! that anchor every `DisplayItem::Subagent`. Lose it and the transcript view +//! silently stops showing turn structure. +//! - **`turn_usage`** — attributed to the last assistant row. It carries +//! `model`, `iteration`, `ts`, `reasoning_content` and the native +//! `tool_calls`. The projection reads **every** `DisplayItem::ToolCall` off +//! `turn_usage.tool_calls`, so losing it does not degrade the tool rows, it +//! deletes them — after which each following `role:"tool"` line falls through +//! to the orphan branch. `Reasoning` items vanish with it, and +//! `AssistantMessage.{model,iteration}` / `interim` collapse. +//! - **`TranscriptMeta`'s cumulative fields** — `turn_count` and the four +//! token/cost rollups that `read_thread_usage_summary` (`threads/ops.rs`) +//! reports. The turn path computes these fresh each turn; +//! [`SessionTranscriptHistory::meta_for_write`] deliberately re-reads the +//! file's existing `_meta` instead, which is right for the generic trait path +//! but would freeze the rollups at the previous turn's values if the turn +//! path used it. +//! +//! So the turn path goes through [`SessionHistory::append_turn`] — an +//! OpenHuman-side supertrait of `ChatHistory` whose one method forwards the +//! same six arguments `append_transcript_turn` already takes. The indirection +//! is real (`Arc`), the on-disk bytes are unchanged by +//! construction, and `ChatHistory` stays in the bound so the handle is still a +//! genuine crate-side history for any future consumer. +//! +//! ## Two alternatives, rejected — recorded so they are not re-litigated +//! +//! 1. **Per-message `ChatHistory::append`.** One `append_transcript_turn` per +//! message means one `_meta` line and one full-file re-read per message: +//! an on-disk change *and* O(n²) I/O on a file that grows without bound. +//! 2. **Widening `ChatHistory` upstream.** It does not close the gap either. +//! The crate's `Usage` has no `cost_usd` / `context_window`; +//! `TranscriptMeta` is a cumulative *file header*, not turn provenance; and +//! the per-message tool-failure `extra_metadata` that +//! `message_to_chat_message` drops is untouchable by any turn-level record. +//! You would pay a tinyagents release and still need a +//! `serde_json::Value` escape hatch, for a trait that has no consumer inside +//! the vendored crate outside `harness/memory/`. +//! +//! # Why the READ half is [`SessionTranscriptRead`], not `ChatHistory::messages` +//! +//! Same shape of argument as the write, and equally settled — measured with a +//! round-trip probe, not assumed. `ChatHistory::messages` returns +//! `Vec`, and the turn path needs `Vec` back, so a +//! trait-mediated read has to pass through +//! [`message_to_chat_message`][crate::openhuman::agent::message_convert::message_to_chat_message]. +//! That converter maps `Message::Assistant` to `ChatMessage::assistant(msg.text())` +//! and **drops `a.tool_calls` entirely**. A persisted native tool round is +//! deliberately stored as the `{content, tool_calls}` / `{tool_call_id, content}` +//! envelope so the next turn re-parses it; flattening it orphans every following +//! `role:"tool"` row and produces the provider `400 An assistant message with +//! 'tool_calls' must be followed by tool messages`. It also blinds the +//! TAURI-RUST-7 trailing strip in +//! [`bound_cached_transcript_messages`][super::types::Agent::bound_cached_transcript_messages], +//! which sniffs that envelope out of `ChatMessage.content`. Two lesser losses +//! ride along and are inert on this path: the `openhuman_turn_usage` +//! `extra_metadata` (re-attached by `read_transcript`, never re-serialised from +//! the cached prefix) and `AssistantMessage.id` (no reader anywhere). +//! +//! So the read goes through [`SessionTranscriptRead::read_session`], which +//! returns the very [`SessionTranscript`] the free function returns, produced by +//! the same [`read_transcript`] call. Losslessness is **structural**: nothing +//! crosses `Message`, so `tool_calls`, `tool_call_id`, `failure`, +//! `reasoning_content` and the `_meta` header all survive by construction, and +//! compaction replay + `interrupted: true` partial skipping stay exactly where +//! the format owner performs them. +//! +//! # Why discovery is a separate object ([`SessionHistoryLocator`]) +//! +//! A handle is bound to one *file*. The turn path's two reads are *lookups*: +//! `(workspace, session_raw_subdir, agent name)` → newest match, and +//! `_meta.thread_id` → newest **root** transcript. `ChatHistory` has no +//! discovery concept at all (it is `thread_id`-keyed and returns messages, never +//! a location), so leaving discovery as free functions would keep the read half +//! hitting the filesystem no matter what handle was injected — i.e. the +//! `Arc` would stay decorative. The locator is therefore the single +//! injected object covering *both* reads and the session's own write handle. +//! +//! # Deliberately NOT done here — recorded with reasons +//! +//! - **The `impl ChatHistory` block below still has no production caller.** +//! Reads go through `read_session`, writes through `append_turn`. It is kept, +//! not deleted, because it is the crate-side seam Option A exists to +//! establish, and because it supplies the `Send + Sync + 'static` bounds the +//! shared `Arc` needs. The trigger that would delete it is +//! an explicit decision to drop `ChatHistory` from the [`SessionHistory`] +//! bound; that frees this file's `read`/`persisted`/`meta_for_write`/ +//! `write_logical_set`/`impl ChatHistory` (~150 lines) plus most of the test +//! module (~570 lines together). Decide it, don't rediscover it. +//! - **The spec's "Removes: ~400 LOC of parallel abstraction" is not delivered +//! and cannot be.** See the design doc's "Where '~400 LOC' came from" +//! subsection: the figure is §2.1's residual after Option B, i.e. exactly +//! `migration.rs` (373 LOC), which §5 S1 and the deletion ledger both keep +//! host-owned. Option A's measured ledger is ≈ −15 / +90 LOC here. +//! - **The #4249 JSONL↔store mirror is the one genuine parallel session +//! persistence** (`session_import/live.rs`, `maybe_shadow_read_session_store` +//! / `maybe_dual_write_session_store`, the `StoreRegistry` registration, two +//! `AgentConfig` flags, one config migration — ~565 prod LOC). It is not +//! touched here: it is gated on #4249's own Phase-2 parity soak and its +//! terminus (reads served from the store) points the opposite way from this +//! branch's non-negotiable zero-on-disk-change constraint. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use tinyagents::harness::memory::ChatHistory; +use tinyagents::harness::message::Message; +use tinyagents::{Result as TaResult, TinyAgentsError}; + +use crate::openhuman::agent::message_convert::{history_to_messages, message_to_chat_message}; +use crate::openhuman::agent::messages::ChatMessage; + +use super::transcript::{ + append_transcript_turn, find_latest_transcript_in_subdir, find_root_transcript_for_thread, + read_transcript, resolve_keyed_transcript_path, resolve_keyed_transcript_path_in_dir, + SessionTranscript, TranscriptMeta, TurnUsage, +}; + +/// One turn's worth of transcript write, borrowed. +/// +/// The fields mirror [`append_transcript_turn`]'s argument list one-for-one and +/// in order, so [`SessionHistory::append_turn`]'s forwarding is visually +/// checkable against the format's own signature. Nothing is transformed on the +/// way through; that is the entire correctness claim of this seam and +/// `append_turn_is_byte_identical_to_the_free_function` in the tests pins it. +/// +/// `prev` is a field rather than handle state on purpose: the turn path tracks +/// the previously-persisted logical set in memory on `Agent` +/// (`persisted_transcript_messages`) precisely so it never has to re-read a +/// growing file, and a disk re-read is not a faithful substitute — see +/// [`SessionTranscriptHistory::write_logical_set`]. +pub struct TranscriptTurn<'a> { + /// Logical message set already persisted, for the extension-vs-compaction diff. + pub prev: &'a [ChatMessage], + /// Logical message set after this turn. + pub next: &'a [ChatMessage], + /// `_meta` header to append after this turn's lines. + pub meta: &'a TranscriptMeta, + /// Usage + provenance attributed to the turn's last assistant row. + pub turn_usage: Option<&'a TurnUsage>, + /// Web-chat request id, stamped on every line of the turn. + pub request_id: Option<&'a str>, +} + +/// The seam the live turn path holds as `Arc`. +/// +/// `ChatHistory` is a supertrait rather than a sibling for two reasons: it +/// supplies the `Send + Sync + 'static` bounds the shared handle needs, and it +/// keeps the crate-side surface (S2/S3) live rather than orphaned. See this +/// module's header for why the turn write cannot simply *be* a `ChatHistory` +/// call. +/// +/// `append_turn` is deliberately **sync**: `persist_session_transcript` is a +/// sync `&mut self` method and the whole write chain under it is sync, so an +/// async method here would ripple `.await` through the turn loop for no gain. +pub(crate) trait SessionHistory: ChatHistory + SessionTranscriptRead { + /// Appends one turn, forwarding every argument to the format owner. + fn append_turn(&self, turn: TranscriptTurn<'_>) -> anyhow::Result<()>; +} + +/// The read half of a bound transcript — the seam the turn path's two resume +/// reads hold. +/// +/// Split out of [`SessionHistory`] rather than added as one more method on it, +/// for a reason that is not stylistic: a *discovered* transcript can still be a +/// legacy `.md` file (see [`SessionTranscriptHistory::opened_at`]), and +/// `append_transcript_turn` writes JSONL. Handing discovery results out as +/// `Arc` makes it impossible to `append_turn` into +/// one by construction, instead of by convention. +/// +/// Sync for the same reason [`SessionHistory::append_turn`] is: both callers are +/// sync `&mut self` methods on `Agent`. +pub(crate) trait SessionTranscriptRead: Send + Sync { + /// The transcript file this handle is bound to. + /// + /// The turn path still needs the concrete path after the read: + /// `maybe_shadow_read_session_store` takes `&Path`, and the dual-write + /// mirror derives its record key from `file_stem()`. + fn path(&self) -> &Path; + + /// The model-context replay of this transcript, `_meta` included, or + /// `Ok(None)` when the file does not exist. + /// + /// Exactly [`read_transcript`], so compaction records have already replaced + /// the accumulator and `interrupted: true` partials are already skipped — + /// §3.1's "single most important constraint". Returning the whole + /// [`SessionTranscript`] rather than messages alone is what lets the shadow + /// read keep working through this seam. + fn read_session(&self) -> anyhow::Result>; +} + +/// Resolves transcripts by the two keys the turn path actually has, and binds +/// this session's own write handle. +/// +/// One injected object covers the whole turn path: both resume reads and the +/// first-write bind. `Agent` holds it as `Option>` +/// and falls back to [`FileTranscriptLocator`] built from the *current* +/// `workspace_dir`/`session_raw_subdir` — lazily, never frozen at build time, +/// because tests reassign `agent.workspace_dir` after `build()` and a +/// build-time locator would silently keep pointing at the old directory. +pub(crate) trait SessionHistoryLocator: Send + Sync { + /// Newest transcript for `agent_name` in this session's raw subtree, + /// including the legacy `session_raw/DDMMYYYY/` + `.md` fallback. + fn latest_for_agent(&self, agent_name: &str) -> Option>; + + /// Newest **root** transcript whose `_meta.thread_id` matches. + /// + /// Root-only on purpose: several transcripts share one thread id (every + /// sub-agent spawned within it does), so a stem-keyed lookup would be + /// ambiguous. + fn root_for_thread(&self, thread_id: &str) -> Option>; + + /// Binds (creating on first write) this session's own write handle for + /// `stem`, with `seed` used only when no file exists yet. + fn open_stem( + &self, + stem: &str, + seed: TranscriptMeta, + ) -> anyhow::Result>; +} + +/// The default [`SessionHistoryLocator`]: real files under +/// `{workspace_dir}/{session_raw_subdir}`. +/// +/// Thin by design — each method wraps exactly one `transcript::` free function +/// and changes nothing about it, so swapping the turn path onto the locator is +/// behaviour-preserving. +pub(crate) struct FileTranscriptLocator { + workspace_dir: PathBuf, + session_raw_subdir: String, +} + +impl FileTranscriptLocator { + pub(crate) fn new( + workspace_dir: impl Into, + session_raw_subdir: impl Into, + ) -> Self { + Self { + workspace_dir: workspace_dir.into(), + session_raw_subdir: session_raw_subdir.into(), + } + } + + /// `{workspace_dir}/{session_raw_subdir}` — the profile-scoped raw dir. + fn raw_dir(&self) -> PathBuf { + self.workspace_dir.join(&self.session_raw_subdir) + } +} + +impl SessionHistoryLocator for FileTranscriptLocator { + fn latest_for_agent(&self, agent_name: &str) -> Option> { + let path = find_latest_transcript_in_subdir( + &self.workspace_dir, + &self.session_raw_subdir, + agent_name, + )?; + log::debug!( + "[transcript-history] locator latest_for_agent agent={agent_name} path={}", + path.display() + ); + Some(Arc::new(SessionTranscriptHistory::opened_at( + path, + seed_meta_for_discovered(agent_name), + ))) + } + + fn root_for_thread(&self, thread_id: &str) -> Option> { + // Cross-dir, newest-wins — NOT scoped to this locator's own + // `session_raw-/` (#5351). A thread's conversation belongs to the + // THREAD, not the active profile, so this scans the shared + // `session_raw/` and every profile-scoped sibling and takes the newest + // match. Switching profile mid-thread (the Quick/Reasoning toggle) then + // continues the same conversation even when earlier turns were written + // under another profile's subtree. + // + // Deliberately not own-dir-first: that lets an OLDER transcript in this + // agent's own dir shadow a NEWER one a sibling holds for the same + // thread, dropping recent turns and diverging from the transcript view + // and turn mirror, which both use this same resolver. The own dir is + // already in the scan, so newest-wins is a superset. + let path = find_root_transcript_for_thread(&self.workspace_dir, thread_id)?; + log::debug!( + "[transcript-history] locator root_for_thread thread={thread_id} path={}", + path.display() + ); + Some(Arc::new(SessionTranscriptHistory::opened_at( + path, + seed_meta_for_discovered(thread_id), + ))) + } + + fn open_stem( + &self, + stem: &str, + seed: TranscriptMeta, + ) -> anyhow::Result> { + // `new_in_dir` — never `new` — because `new` hardcodes + // `{workspace}/session_raw/`, and a dedicated-memory profile's sessions + // live in `session_raw-/`. + Ok(Arc::new(SessionTranscriptHistory::new_in_dir( + self.raw_dir(), + stem, + seed, + )?)) + } +} + +/// A placeholder `_meta` for a handle bound to an already-existing transcript. +/// +/// `seed_meta` is consulted only when the file is **absent**, and a discovered +/// path exists by definition, so this value is never written. It exists because +/// [`SessionTranscriptHistory`] is one type serving both roles; giving read-only +/// handles a `None` meta would mean an `Option` field every write path then has +/// to unwrap for no benefit. +fn seed_meta_for_discovered(agent_name: &str) -> TranscriptMeta { + TranscriptMeta { + agent_name: agent_name.to_string(), + agent_id: None, + agent_type: None, + dispatcher: String::new(), + provider: None, + model: None, + created: String::new(), + updated: String::new(), + turn_count: 0, + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + charged_amount_usd: 0.0, + thread_id: None, + task_id: None, + } +} + +/// A [`ChatHistory`] backed by one `session_raw/{stem}.jsonl` transcript. +/// +/// Construct with [`SessionTranscriptHistory::new`] (workspace-rooted, i.e. +/// `{workspace}/session_raw/`) or [`SessionTranscriptHistory::new_in_dir`] (an +/// explicit raw dir — **required** for a dedicated-memory profile, whose +/// sessions live in `session_raw-/`). The `seed_meta` is used only when the +/// transcript file does not exist yet; for an existing file the authoritative +/// cumulative `_meta` is read back from disk so turn counts and token rollups +/// keep accumulating rather than resetting. +pub struct SessionTranscriptHistory { + /// Fully-resolved transcript file, fixed at construction. + /// + /// Resolved eagerly rather than derived per call from a `(workspace, stem)` + /// pair: the old shape hardcoded `{workspace}/session_raw/`, which is the + /// **wrong directory** for a profile-scoped session and would have silently + /// cross-written into the shared profile's transcripts the moment this + /// handle was wired into the turn path. + path: PathBuf, + /// `_meta` used for the very first write, before a file exists. + seed_meta: TranscriptMeta, +} + +impl SessionTranscriptHistory { + /// Binds a history handle to `{workspace_dir}/session_raw/{stem}.jsonl`. + /// + /// Use [`Self::new_in_dir`] when the session is profile-scoped; this + /// convenience constructor always resolves under the shared `session_raw/`. + pub fn new( + workspace_dir: impl AsRef, + stem: &str, + seed_meta: TranscriptMeta, + ) -> anyhow::Result { + let path = resolve_keyed_transcript_path(workspace_dir.as_ref(), stem)?; + log::debug!( + "[transcript-history] bound stem={stem} path={}", + path.display() + ); + Ok(Self { path, seed_meta }) + } + + /// Binds a history handle to `{session_raw_dir}/{stem}.jsonl`. + /// + /// `session_raw_dir` is `{workspace}/{session_raw_subdir}` — `session_raw` + /// for the shared profile, `session_raw-` for a dedicated-memory one. + /// The turn path must use this constructor; see [`Self::path`]'s note. + pub fn new_in_dir( + session_raw_dir: impl AsRef, + stem: &str, + seed_meta: TranscriptMeta, + ) -> anyhow::Result { + let path = resolve_keyed_transcript_path_in_dir(session_raw_dir.as_ref(), stem)?; + log::debug!( + "[transcript-history] bound stem={stem} path={}", + path.display() + ); + Ok(Self { path, seed_meta }) + } + + /// Binds a handle to an **already-discovered** transcript file, verbatim. + /// + /// Deliberately does **not** go through `resolve_keyed_transcript_path*`, + /// which the two stem constructors above use. That helper `create_dir_all`s + /// its parent and forces a `.jsonl` extension — both wrong for a discovered + /// path: `find_latest_transcript_in_subdir` can still return a legacy `.md` + /// file (`read_transcript` routes by extension), and re-resolving would + /// mangle it into a sibling `.jsonl` that does not exist while creating + /// stray directories on a pure read. + /// + /// Hand the result out as `Arc`, not + /// `Arc` — see [`SessionTranscriptRead`]'s doc. + pub fn opened_at(path: PathBuf, seed_meta: TranscriptMeta) -> Self { + log::debug!( + "[transcript-history] opened discovered path={}", + path.display() + ); + Self { path, seed_meta } + } + + /// This handle's transcript file. + pub fn path(&self) -> &Path { + &self.path + } + + /// Reads the current transcript, or `None` when no file exists yet. + /// + /// A missing transcript is the normal first-turn state, not an error. + fn read(&self) -> TaResult> { + if !self.path.exists() { + return Ok(None); + } + read_transcript(&self.path).map(Some).map_err(memory_err) + } + + /// The logical (model-context) message set currently on disk. + /// + /// Routes through [`read_transcript`], so compaction records have already + /// replaced the accumulator and `interrupted: true` partials are skipped. + fn persisted(&self) -> TaResult> { + Ok(self.read()?.map(|t| t.messages).unwrap_or_default()) + } + + /// The `_meta` to write: the file's own cumulative meta when it exists, + /// otherwise this handle's seed. + /// + /// Correct for the generic `ChatHistory` path, which has no channel for a + /// caller-computed meta. The **turn path must never route through here** — + /// it computes `turn_count` and the four token/cost rollups fresh each turn, + /// and re-reading the file's `_meta` would freeze them at the previous + /// turn's values, silently breaking `read_thread_usage_summary`. + fn meta_for_write(&self) -> TaResult { + Ok(self + .read()? + .map(|t| t.meta) + .unwrap_or_else(|| self.seed_meta.clone())) + } + + /// Writes `next` as the new logical set, diffing against what is persisted. + /// + /// Routes through [`SessionHistory::append_turn`] so every write in this + /// module — trait-driven and turn-path alike — funnels through one call to + /// [`append_transcript_turn`], and the extension-vs-compaction decision + /// stays with the format owner rather than drifting here. + /// + /// The `self.persisted()` disk re-read is what the generic trait path has + /// to do, and is deliberately **not** what the turn path does. + /// [`read_transcript`] reconstructs `ChatMessage`s from line records: the + /// `failure` / `failure_detail` fields have been lifted out of + /// `extra_metadata` and turn-usage fields hoisted to top-level line fields. + /// Feeding that back in as `prev` would make `common_prefix_len` mismatch + /// at the first such message, so the writer would emit a full compaction + /// record — re-appending the entire message set — on every single turn. + fn write_logical_set(&self, next: &[ChatMessage]) -> TaResult<()> { + let prev = self.persisted()?; + let meta = self.meta_for_write()?; + self.append_turn(TranscriptTurn { + prev: &prev, + next, + meta: &meta, + turn_usage: None, + request_id: None, + }) + .map_err(memory_err) + } +} + +impl SessionTranscriptRead for SessionTranscriptHistory { + fn path(&self) -> &Path { + &self.path + } + + /// Same call the free-function readers make, on the same path, with the + /// same return type — so there is nothing left for the round trip to lose. + fn read_session(&self) -> anyhow::Result> { + if !self.path.exists() { + log::debug!( + "[transcript-history] read_session absent path={}", + self.path.display() + ); + return Ok(None); + } + let session = read_transcript(&self.path)?; + log::debug!( + "[transcript-history] read_session messages={} path={}", + session.messages.len(), + self.path.display() + ); + Ok(Some(session)) + } +} + +impl SessionHistory for SessionTranscriptHistory { + /// Pure forwarder: every argument reaches [`append_transcript_turn`] + /// untouched, so the bytes this writes are identical to what the free + /// function would have written at the call site. + fn append_turn(&self, turn: TranscriptTurn<'_>) -> anyhow::Result<()> { + log::debug!( + "[transcript-history] append_turn prev={} next={} usage={} request_id={:?} path={}", + turn.prev.len(), + turn.next.len(), + turn.turn_usage.is_some(), + turn.request_id, + self.path.display() + ); + append_transcript_turn( + &self.path, + turn.prev, + turn.next, + turn.meta, + turn.turn_usage, + turn.request_id, + ) + } +} + +#[async_trait] +impl ChatHistory for SessionTranscriptHistory { + /// Returns the **model-context** replay of this transcript. + /// + /// This is deliberately the same path the resume flow uses, not the raw + /// line set: compaction records replace the accumulator and interrupted + /// partials are dropped, so a resumed context never carries a truncated + /// answer. Use + /// [`read_transcript_display`][super::transcript::read_transcript_display] + /// when rendering history for a human instead. + /// + /// An absent transcript yields an empty `Vec`, per the trait contract. + async fn messages(&self, _thread_id: &str) -> TaResult> { + Ok(history_to_messages(&self.persisted()?)) + } + + /// Appends one message to the end of the transcript. + /// + /// Extending the persisted set writes only the new tail line. + async fn append(&self, _thread_id: &str, message: Message) -> TaResult<()> { + let mut next = self.persisted()?; + next.push(message_to_chat_message(&message)); + self.write_logical_set(&next) + } + + /// Replaces the logical message set with `messages`. + /// + /// This maps onto the compaction-record path, **not** a file rewrite: when + /// `messages` is no longer an extension of what is on disk, + /// [`append_transcript_turn`] appends a single + /// `{"kind":"compaction","replacement":[…]}` record carrying the full + /// reduced set and leaves every earlier line in place. The trait's default + /// implementation (clear-then-append) would destroy that history, which is + /// why this override exists. + async fn replace(&self, _thread_id: &str, messages: Vec) -> TaResult<()> { + let next: Vec = messages.iter().map(message_to_chat_message).collect(); + self.write_logical_set(&next) + } + + /// Empties the **model context** while preserving the transcript on disk. + /// + /// Semantics chosen (S3 requires this be explicit): `clear` appends a + /// compaction record with an empty `replacement`. Afterwards + /// [`messages`][Self::messages] returns empty, but every prior line — and + /// so the display read, usage rollups, and audit trail — survives. + /// + /// The two rejected alternatives, recorded so this is not re-litigated: + /// truncating the file breaks the append-only invariant that the whole + /// format rests on, and starting a fresh stem would silently orphan the + /// session's history from its thread. A no-op on an absent transcript, per + /// the trait contract. + async fn clear(&self, _thread_id: &str) -> TaResult<()> { + if !self.path.exists() { + return Ok(()); + } + self.write_logical_set(&[]) + } +} + +/// Maps a transcript I/O failure into the crate's error type. +/// +/// `ChatHistory` is a `harness::memory` surface, so its failures classify as +/// [`TinyAgentsError::Memory`]. The `anyhow` context chain is flattened into +/// the message via `{:#}` so the underlying cause is not lost. +fn memory_err(err: anyhow::Error) -> TinyAgentsError { + TinyAgentsError::Memory(format!("session transcript: {err:#}")) +} + +#[cfg(test)] +#[path = "transcript_history_tests.rs"] +mod tests; diff --git a/src/openhuman/agent/harness/session/transcript_history_tests.rs b/src/openhuman/agent/harness/session/transcript_history_tests.rs new file mode 100644 index 0000000000..6592c2392d --- /dev/null +++ b/src/openhuman/agent/harness/session/transcript_history_tests.rs @@ -0,0 +1,680 @@ +//! Tests for the [`ChatHistory`] seam over the durable transcript. +//! +//! These pin the three properties S3 of the design doc calls out as hard +//! requirements, because each one is a place where a plausible-looking +//! implementation would silently corrupt a user's transcript: +//! +//! 1. `messages()` reads the **model-context** replay, not the raw line set. +//! 2. `replace()` compacts rather than rewriting, so history survives. +//! 3. `clear()` empties the context without destroying the file. + +use tempfile::TempDir; + +use super::*; +use crate::openhuman::agent::harness::session::transcript::read_transcript_display; + +/// Stem every test writes under; the file lands at +/// `{workspace}/session_raw/{STEM}.jsonl`. +const STEM: &str = "1760000000_tester"; + +fn meta() -> TranscriptMeta { + TranscriptMeta { + agent_name: "tester".into(), + agent_id: Some("tester".into()), + agent_type: Some("root".into()), + dispatcher: "native".into(), + provider: None, + model: None, + created: "2026-08-07T10:00:00Z".into(), + updated: "2026-08-07T10:00:00Z".into(), + turn_count: 0, + input_tokens: 0, + output_tokens: 0, + cached_input_tokens: 0, + charged_amount_usd: 0.0, + thread_id: Some("thread-1".into()), + task_id: None, + } +} + +fn history(dir: &TempDir) -> SessionTranscriptHistory { + SessionTranscriptHistory::new(dir.path(), STEM, meta()).unwrap() +} + +fn user(text: &str) -> Message { + Message::User(tinyagents::harness::message::UserMessage { + content: vec![tinyagents::harness::message::ContentBlock::Text( + text.to_string(), + )], + }) +} + +/// Visible text of each message, for order-sensitive assertions. +fn texts(messages: &[Message]) -> Vec { + messages.iter().map(Message::text).collect() +} + +#[tokio::test] +async fn messages_on_absent_transcript_is_empty_not_an_error() { + let dir = TempDir::new().unwrap(); + assert!(history(&dir).messages("thread-1").await.unwrap().is_empty()); +} + +#[tokio::test] +async fn append_extends_and_reads_back_in_order() { + let dir = TempDir::new().unwrap(); + let h = history(&dir); + + h.append("thread-1", user("one")).await.unwrap(); + h.append("thread-1", user("two")).await.unwrap(); + + let got = h.messages("thread-1").await.unwrap(); + assert_eq!(texts(&got), vec!["one", "two"]); +} + +/// S3 requirement 1: `messages()` must be the model-context replay. +/// +/// After a reduction, the raw file still holds the pre-compaction lines. A +/// reader that returned the raw line set would hand the model a context that +/// includes text the compaction was meant to drop. +#[tokio::test] +async fn messages_replays_compaction_rather_than_returning_raw_lines() { + let dir = TempDir::new().unwrap(); + let h = history(&dir); + + h.append("thread-1", user("first")).await.unwrap(); + h.append("thread-1", user("second")).await.unwrap(); + h.append("thread-1", user("third")).await.unwrap(); + + // Reduce to a set that is not a prefix extension → compaction record. + h.replace("thread-1", vec![user("summary")]).await.unwrap(); + + // The model-context read sees only the replacement. + assert_eq!( + texts(&h.messages("thread-1").await.unwrap()), + vec!["summary"] + ); + + // ...while the file itself still carries the superseded lines, proving the + // reduction was a compaction record and not a rewrite. + let path = h.path(); + let display = read_transcript_display(path).unwrap(); + let rendered = format!("{display:?}"); + assert!( + rendered.contains("first") && rendered.contains("second"), + "pre-compaction lines must survive on disk; display read was: {rendered}" + ); + + // And the seam agrees with the format's own model-context reader. + assert_eq!( + texts(&h.messages("thread-1").await.unwrap()), + read_transcript(path) + .unwrap() + .messages + .iter() + .map(|m| m.content.clone()) + .collect::>() + ); +} + +/// S3 requirement 2: `replace()` must not rewrite the file. +/// +/// The trait's default `replace` is clear-then-append; if that default were +/// ever inherited here it would destroy the append-only history. This asserts +/// the file only ever grows. +#[tokio::test] +async fn replace_appends_a_compaction_record_and_never_shrinks_the_file() { + let dir = TempDir::new().unwrap(); + let h = history(&dir); + + h.append("thread-1", user("alpha")).await.unwrap(); + h.append("thread-1", user("beta")).await.unwrap(); + + let path = h.path(); + let before = std::fs::read_to_string(path).unwrap(); + + h.replace("thread-1", vec![user("condensed")]) + .await + .unwrap(); + + let after = std::fs::read_to_string(path).unwrap(); + assert!( + after.starts_with(&before), + "replace must append; earlier bytes were modified" + ); + assert!( + after.contains("\"kind\":\"compaction\""), + "replace must write a compaction record, got: {after}" + ); +} + +/// S3 requirement 3: `clear()` semantics are explicit and non-destructive. +#[tokio::test] +async fn clear_empties_the_context_but_preserves_the_file() { + let dir = TempDir::new().unwrap(); + let h = history(&dir); + + h.append("thread-1", user("kept on disk")).await.unwrap(); + let path = h.path(); + let before = std::fs::read_to_string(path).unwrap(); + + h.clear("thread-1").await.unwrap(); + + assert!(h.messages("thread-1").await.unwrap().is_empty()); + assert!(path.exists(), "clear must not delete the transcript"); + + let after = std::fs::read_to_string(path).unwrap(); + assert!( + after.starts_with(&before), + "clear must append, not truncate" + ); + assert!( + after.contains("kept on disk"), + "clear must preserve prior lines for the display read" + ); +} + +#[tokio::test] +async fn clear_on_absent_transcript_is_a_noop_not_an_error() { + let dir = TempDir::new().unwrap(); + history(&dir).clear("thread-1").await.unwrap(); +} + +/// Appending after a compaction continues from the replacement set, not from +/// the superseded lines — otherwise dropped context would resurrect itself. +#[tokio::test] +async fn append_after_compaction_extends_the_replacement_set() { + let dir = TempDir::new().unwrap(); + let h = history(&dir); + + h.append("thread-1", user("old")).await.unwrap(); + h.replace("thread-1", vec![user("summary")]).await.unwrap(); + h.append("thread-1", user("new")).await.unwrap(); + + assert_eq!( + texts(&h.messages("thread-1").await.unwrap()), + vec!["summary", "new"] + ); +} + +/// An existing transcript's cumulative `_meta` wins over the handle's seed, so +/// reopening a session does not reset its turn/token rollups to zero. +#[tokio::test] +async fn existing_meta_is_preferred_over_the_seed() { + let dir = TempDir::new().unwrap(); + history(&dir).append("thread-1", user("one")).await.unwrap(); + + let mut stale_seed = meta(); + stale_seed.turn_count = 999; + stale_seed.agent_name = "wrong".into(); + let reopened = SessionTranscriptHistory::new(dir.path(), STEM, stale_seed).unwrap(); + reopened.append("thread-1", user("two")).await.unwrap(); + + let persisted = read_transcript(reopened.path()).unwrap(); + assert_eq!(persisted.meta.agent_name, "tester"); + assert_ne!(persisted.meta.turn_count, 999); +} + +// ── S4: the `SessionHistory` write seam ────────────────────────────── + +fn chat(role: &str, content: &str) -> ChatMessage { + ChatMessage { + id: None, + role: role.into(), + content: content.into(), + extra_metadata: None, + } +} + +/// A turn's worth of provenance, with fixed timestamps so byte comparison is +/// not clock-dependent. +fn turn_usage() -> TurnUsage { + TurnUsage { + provider: "anthropic".into(), + model: "claude-x".into(), + usage: crate::openhuman::agent::harness::session::transcript::MessageUsage { + input: 20, + output: 8, + cached_input: 0, + context_window: 200_000, + cost_usd: 0.002, + }, + ts: "2026-08-07T10:00:05Z".into(), + reasoning_content: Some("thinking".into()), + tool_calls: vec![crate::openhuman::inference::provider::ToolCall { + id: "call-1".into(), + name: "get_weather".into(), + arguments: r#"{"city":"NYC"}"#.into(), + extra_content: None, + }], + iteration: 2, + } +} + +/// The core S4 correctness claim: `append_turn` is a **pure forwarder**. +/// +/// The turn path stopped calling `append_transcript_turn` directly and now goes +/// through the handle. That is only safe if the handle changes nothing, so this +/// writes the same turn twice — once each way — and compares the files byte for +/// byte. Cheaper and stricter than re-projecting the result: it fails on any +/// transformation at all, not just ones the projection happens to notice. +#[test] +fn append_turn_is_byte_identical_to_the_free_function() { + let direct_dir = TempDir::new().unwrap(); + let seam_dir = TempDir::new().unwrap(); + + let messages = vec![ + chat("user", "what's the weather?"), + chat("assistant", "72F and sunny."), + ]; + let usage = turn_usage(); + + let direct_path = resolve_keyed_transcript_path(direct_dir.path(), STEM).unwrap(); + append_transcript_turn( + &direct_path, + &[], + &messages, + &meta(), + Some(&usage), + Some("req-1"), + ) + .unwrap(); + + let seam = SessionTranscriptHistory::new(seam_dir.path(), STEM, meta()).unwrap(); + seam.append_turn(TranscriptTurn { + prev: &[], + next: &messages, + meta: &meta(), + turn_usage: Some(&usage), + request_id: Some("req-1"), + }) + .unwrap(); + + assert_eq!( + std::fs::read(&direct_path).unwrap(), + std::fs::read(seam.path()).unwrap(), + "append_turn must forward every argument unchanged" + ); +} + +/// `new_in_dir` addresses a profile-scoped raw dir. +/// +/// `new` hardcodes `{workspace}/session_raw/`, which is the wrong directory for +/// a dedicated-memory profile (`session_raw-/`). Before this constructor +/// existed, wiring the handle into the turn path would have silently written a +/// profile session into the shared profile's transcripts. +#[test] +fn new_in_dir_writes_into_the_profile_scoped_directory() { + let dir = TempDir::new().unwrap(); + let profile_dir = dir.path().join("session_raw-1"); + + let h = SessionTranscriptHistory::new_in_dir(&profile_dir, STEM, meta()).unwrap(); + h.append_turn(TranscriptTurn { + prev: &[], + next: &[chat("user", "profile scoped")], + meta: &meta(), + turn_usage: None, + request_id: None, + }) + .unwrap(); + + assert_eq!( + h.path(), + profile_dir.join(format!("{STEM}.jsonl")), + "handle must be bound to the profile-scoped dir" + ); + assert!(h.path().exists()); + assert!( + !dir.path() + .join("session_raw") + .join(format!("{STEM}.jsonl")) + .exists(), + "nothing may be written into the shared profile's session_raw/" + ); + assert_eq!( + read_transcript(h.path()).unwrap().messages[0].content, + "profile scoped" + ); +} + +/// The executable form of this module's "why the write does not cross the crate +/// trait" note: the same logical message set, written through `append_turn` +/// versus through `ChatHistory::replace`, produces display lines that differ in +/// exactly the three fields the trait cannot carry. +/// +/// The trait-path assertions are not a bug being pinned — `replace` genuinely +/// has nowhere to put this data. They are here so that anyone tempted to route +/// the turn path through `ChatHistory` sees the cost first. +#[tokio::test] +async fn trait_path_loses_the_provenance_that_append_turn_preserves() { + let usage = turn_usage(); + let messages = vec![chat("assistant", "72F and sunny.")]; + + // Seam path: request_id + turn_usage reach the line. + let seam_dir = TempDir::new().unwrap(); + let seam = history(&seam_dir); + seam.append_turn(TranscriptTurn { + prev: &[], + next: &messages, + meta: &meta(), + turn_usage: Some(&usage), + request_id: Some("req-1"), + }) + .unwrap(); + let seam_line = first_display_message(seam.path()); + assert_eq!(seam_line.request_id.as_deref(), Some("req-1")); + let seam_usage = seam_line.turn_usage.expect("turn_usage persisted"); + assert_eq!(seam_usage.model, "claude-x"); + assert_eq!(seam_usage.iteration, 2); + assert_eq!(seam_usage.tool_calls.len(), 1); + + // Trait path: the same messages, none of the provenance. + let trait_dir = TempDir::new().unwrap(); + let trait_history = history(&trait_dir); + trait_history + .replace( + "thread-1", + vec![Message::Assistant( + tinyagents::harness::message::AssistantMessage { + id: None, + content: vec![tinyagents::harness::message::ContentBlock::Text( + "72F and sunny.".into(), + )], + tool_calls: vec![], + usage: None, + }, + )], + ) + .await + .unwrap(); + let trait_line = first_display_message(trait_history.path()); + assert!( + trait_line.request_id.is_none(), + "ChatHistory has no channel for request_id — no turn boundary" + ); + assert!( + trait_line.turn_usage.is_none(), + "ChatHistory has no channel for turn_usage — no model/iteration/tool calls" + ); +} + +/// First `role != "system"` display message line of a transcript. +fn first_display_message( + path: &Path, +) -> crate::openhuman::agent::harness::session::transcript::DisplayMessage { + read_transcript_display(path) + .unwrap() + .records + .into_iter() + .find_map(|r| match r { + crate::openhuman::agent::harness::session::transcript::DisplayRecord::Message(m) + if m.message.role != "system" => + { + Some(m) + } + _ => None, + }) + .expect("a display message line") +} + +// ───────────────────────────────────────────────────────────────────── +// S4 read half: the locator + `read_session` +// ───────────────────────────────────────────────────────────────────── + +/// The `_meta`/`messages` pair rendered exhaustively. +/// +/// `SessionTranscript` cannot derive `PartialEq` here — that would mean editing +/// `transcript.rs`, and this branch's zero-on-disk-change rule keeps that file +/// untouched. `ChatMessage`'s `id` and `extra_metadata` are `skip_serializing`, +/// so a JSON comparison would silently ignore exactly the fields most at risk; +/// `Debug` prints every field, so it is the stricter check. +fn transcript_fingerprint(t: &SessionTranscript) -> String { + format!("{:?}|{:?}", t.meta, t.messages) +} + +fn locator(dir: &TempDir) -> FileTranscriptLocator { + FileTranscriptLocator::new(dir.path(), "session_raw") +} + +/// A tool round persisted the way the turn path persists it: the assistant +/// carries the native `{content, tool_calls}` envelope and the tool row carries +/// the matching `tool_call_id`. +fn native_tool_round() -> Vec { + vec![ + ChatMessage::system("system prompt"), + ChatMessage::user("what is the weather"), + ChatMessage::assistant( + serde_json::json!({ + "content": "calling get_weather", + // The flat `{id, name, arguments}` shape + // `NativeToolDispatcher::to_provider_messages` persists — the + // one `parse_native_assistant_envelope` accepts. + "tool_calls": [{ + "id": "call-1", + "name": "get_weather", + "arguments": "{\"city\":\"SF\"}" + }] + }) + .to_string(), + ), + ChatMessage::tool( + serde_json::json!({"tool_call_id": "call-1", "content": "72F and sunny"}).to_string(), + ), + ChatMessage::assistant("It is 72F and sunny."), + ] +} + +/// Writes a transcript exercising every replay rule the read must preserve: a +/// plain extension turn, a **compaction** (a reduction, not a prefix), an +/// `interrupted: true` partial, and a failure-annotated tool row. +fn write_torture_transcript(dir: &TempDir) -> PathBuf { + let path = resolve_keyed_transcript_path(dir.path(), STEM).unwrap(); + + let first = native_tool_round(); + append_transcript_turn(&path, &[], &first, &meta(), None, Some("req-1")).unwrap(); + + // A reduction, so the writer must emit a compaction record rather than a + // tail append. Replaying it wrongly is the corruption §3.1 calls the single + // most important constraint in the design. + let mut failed_tool = ChatMessage::tool( + serde_json::json!({"tool_call_id": "call-2", "content": "boom"}).to_string(), + ); + crate::openhuman::agent::harness::session::transcript::attach_tool_failure_metadata( + &mut failed_tool, + Some("exit status 1"), + ); + let reduced = vec![ + ChatMessage::system("system prompt"), + ChatMessage::assistant("[summary] asked about weather"), + failed_tool, + ChatMessage::assistant("Sorry, that failed."), + ]; + append_transcript_turn(&path, &first, &reduced, &meta(), None, Some("req-2")).unwrap(); + + // Display-only line the model-context replay must skip. + crate::openhuman::agent::harness::session::transcript::append_interrupted_partial( + &path, + "half a sent", + Some("req-3"), + Some(1), + None, + ) + .unwrap(); + + path +} + +/// The locator's read must be the free function's read — same struct, same +/// call, no `Message` round trip. Compaction replay and interrupted-partial +/// skipping therefore come along for free rather than being re-implemented. +#[test] +fn locator_read_is_equivalent_to_the_free_function() { + let dir = TempDir::new().unwrap(); + let path = write_torture_transcript(&dir); + + let direct = read_transcript(&path).unwrap(); + let through_seam = locator(&dir) + .latest_for_agent("tester") + .expect("locator discovers the transcript") + .read_session() + .unwrap() + .expect("file exists"); + + assert_eq!( + transcript_fingerprint(&through_seam), + transcript_fingerprint(&direct), + "read_session must return exactly what read_transcript returns" + ); + // Guard the fixture itself: an equivalence that compared two empty replays + // would pass for the wrong reason. + let roles_and_text: Vec<(&str, String)> = direct + .messages + .iter() + .map(|m| { + // The tool row is JSON, and a serde round trip may reorder its + // keys; compare it parsed so the assertion pins content, not + // serialisation order. + let text = serde_json::from_str::(&m.content) + .map(|v| v["content"].as_str().unwrap_or_default().to_string()) + .unwrap_or_else(|_| m.content.clone()); + (m.role.as_str(), text) + }) + .collect(); + assert_eq!( + roles_and_text, + vec![ + ("system", "system prompt".to_string()), + ("assistant", "[summary] asked about weather".to_string()), + ("tool", "boom".to_string()), + ("assistant", "Sorry, that failed.".to_string()), + ], + "the fixture must actually exercise the compaction + interrupted skip" + ); +} + +/// The cold-boot lookup keyed on `_meta.thread_id` goes through the same seam. +#[test] +fn locator_root_for_thread_reads_the_matching_transcript() { + let dir = TempDir::new().unwrap(); + let path = write_torture_transcript(&dir); + + let through_seam = locator(&dir) + .root_for_thread("thread-1") + .expect("locator resolves by _meta.thread_id") + .read_session() + .unwrap() + .expect("file exists"); + + assert_eq!( + transcript_fingerprint(&through_seam), + transcript_fingerprint(&read_transcript(&path).unwrap()) + ); + assert!(locator(&dir).root_for_thread("thread-absent").is_none()); +} + +/// `opened_at` binds a discovered path verbatim, so a legacy `.md` transcript +/// still resolves. Re-resolving through `resolve_keyed_transcript_path*` — the +/// obvious-looking alternative — would rewrite the extension to `.jsonl` and +/// hand back a path that does not exist. +#[test] +fn md_legacy_path_resolves_through_the_locator() { + let dir = TempDir::new().unwrap(); + let raw = dir.path().join("session_raw"); + std::fs::create_dir_all(&raw).unwrap(); + let md = raw.join(format!("{STEM}.md")); + std::fs::write( + &md, + "\n\n\ + \nlegacy question\n\n", + ) + .unwrap(); + + let handle = locator(&dir) + .latest_for_agent("tester") + .expect("locator finds the legacy .md"); + assert_eq!( + handle.path(), + md, + "the discovered path must be used verbatim" + ); + + let session = handle.read_session().unwrap().expect("legacy file exists"); + assert_eq!( + session + .messages + .iter() + .map(|m| m.content.as_str()) + .collect::>(), + vec!["legacy question"] + ); +} + +/// A read handle bound to a file that vanished is `Ok(None)`, not an error — +/// the callers fold it into their existing "nothing to resume from" branch. +#[test] +fn read_session_on_absent_file_is_none_not_an_error() { + let dir = TempDir::new().unwrap(); + let handle = SessionTranscriptHistory::opened_at( + dir.path().join("session_raw").join("nope.jsonl"), + meta(), + ); + assert!(handle.read_session().unwrap().is_none()); +} + +/// **The mutation gate for the read half.** +/// +/// Routing the read through `ChatHistory::messages()` and converting back with +/// `message_to_chat_message` flattens the assistant's native `tool_calls` +/// envelope into prose, orphaning the following `role:"tool"` row — the +/// provider `400 An assistant message with 'tool_calls' must be followed by +/// tool messages`. This test asserts both halves: what the seam preserves, and +/// that the rejected route really does lose it. Swap `read_session` for +/// `messages()` in `try_load_session_transcript` and this fails. +#[tokio::test] +async fn resumed_native_tool_round_keeps_tool_calls() { + let dir = TempDir::new().unwrap(); + let path = resolve_keyed_transcript_path(dir.path(), STEM).unwrap(); + let round = native_tool_round(); + append_transcript_turn(&path, &[], &round, &meta(), None, None).unwrap(); + + let through_seam = locator(&dir) + .latest_for_agent("tester") + .unwrap() + .read_session() + .unwrap() + .unwrap() + .messages; + + let assistant = through_seam + .iter() + .find(|m| m.role == "assistant" && m.content.contains("tool_calls")) + .expect("the assistant envelope survived the seam"); + let envelope: serde_json::Value = serde_json::from_str(&assistant.content).unwrap(); + assert_eq!(envelope["tool_calls"][0]["id"], "call-1"); + let tool_row = through_seam + .iter() + .find(|m| m.role == "tool") + .expect("the tool result survived"); + let tool_json: serde_json::Value = serde_json::from_str(&tool_row.content).unwrap(); + assert_eq!( + tool_json["tool_call_id"], "call-1", + "the tool result must still correlate to the assistant's call" + ); + + // The rejected route, run for real so the rejection stays evidence-backed. + let lossy: Vec = SessionTranscriptHistory::new(dir.path(), STEM, meta()) + .unwrap() + .messages("thread-1") + .await + .unwrap() + .iter() + .map(message_to_chat_message) + .collect(); + assert!( + !lossy + .iter() + .any(|m| m.role == "assistant" && m.content.contains("tool_calls")), + "ChatHistory::messages() is expected to drop the tool_calls envelope — \ + if this ever stops being true, revisit the read seam's rationale" + ); +} diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index f47a5bd1c5..626f702937 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -1,6 +1,9 @@ //! Session persistence: transcript loading, checkpointing, and background tasks. use super::super::transcript; +use super::super::transcript_history::{ + FileTranscriptLocator, SessionHistoryLocator, TranscriptTurn, +}; use super::super::types::Agent; use crate::openhuman::agent::context::ARCHIVIST_EXTRACTION_PROMPT; use crate::openhuman::agent::harness; @@ -20,60 +23,110 @@ impl Agent { /// Try to load a previous session transcript for KV cache resume. /// /// Best-effort: failures are logged and silently ignored. + /// + /// # How this reaches the transcript (S4) + /// + /// Both halves of the turn path now go through the seam: writes through + /// [`SessionHistory::append_turn`][super::super::transcript_history::SessionHistory::append_turn], + /// reads through + /// [`SessionHistoryLocator`][super::super::transcript_history::SessionHistoryLocator] + /// + [`SessionTranscriptRead::read_session`][super::super::transcript_history::SessionTranscriptRead::read_session]. + /// + /// The read is **not** `ChatHistory::messages()`, and that is settled, not + /// pending: `messages()` returns `Vec`, and converting back with + /// `message_to_chat_message` flattens `Assistant.tool_calls` into plain + /// text. That is precisely what + /// [`bound_cached_transcript_messages`][Agent::bound_cached_transcript_messages]' + /// TAURI-RUST-7 trailing strip inspects, and re-sending a flattened prefix + /// to a native provider is the `400 assistant message with 'tool_calls' + /// must be followed by tool messages` failure that strip exists to prevent. + /// `read_session` returns the whole [`SessionTranscript`][super::super::transcript::SessionTranscript] + /// instead — the same struct the free function returns, from the same + /// `read_transcript` call — so compaction replay, `interrupted: true` + /// partial skipping and the `_meta` header + /// [`maybe_shadow_read_session_store`][Agent::maybe_shadow_read_session_store] + /// needs all survive by construction. + /// + /// Discovery lives on the locator because it is a *lookup*, not a read: + /// this function's key is `(workspace, session_raw_subdir, agent name)` + /// (newest match, with a legacy `session_raw/DDMMYYYY/` fallback) and the + /// cold-boot sibling + /// [`seed_resume_from_thread_transcript`][Agent::seed_resume_from_thread_transcript] + /// keys off `_meta.thread_id`. Neither is a stem, and `ChatHistory` has no + /// discovery concept at all. pub(in super::super) fn try_load_session_transcript(&mut self) { - match transcript::find_latest_transcript_in_subdir( - &self.workspace_dir, - &self.session_raw_subdir, - &self.agent_definition_name, - ) { - Some(path) => { - log::info!( - "[transcript] found previous transcript path={}", - path.display() - ); - match transcript::read_transcript(&path) { - Ok(session) => { - if session.messages.is_empty() { - log::debug!( - "[transcript] previous transcript is empty — skipping resume" - ); - return; - } - let loaded_count = session.messages.len(); - log::info!("[transcript] loaded {} messages for resume", loaded_count); - // Best-effort store-backed shadow read (issue #4249, - // 04.2 phase 2). Observes + logs divergence only; the - // legacy transcript just loaded stays authoritative and - // is what feeds the resume below. Gated OFF by default. - self.maybe_shadow_read_session_store(&path, &session); - let bounded = self.bound_cached_transcript_messages(session.messages); - if bounded.len() < loaded_count { - log::warn!( - "[transcript] resume prefix trimmed from {} to {} messages (max_history_messages={})", - loaded_count, - bounded.len(), - self.config.max_history_messages - ); - } - self.cached_transcript_messages = Some(bounded); - } - Err(err) => { - log::warn!( - "[transcript] failed to parse previous transcript {}: {err}", - path.display() - ); - } + let Some(handle) = self + .session_locator() + .latest_for_agent(&self.agent_definition_name) + else { + log::debug!( + "[transcript] no previous transcript found for agent={}", + self.agent_definition_name + ); + return; + }; + let path = handle.path().to_path_buf(); + log::info!( + "[transcript] found previous transcript path={}", + path.display() + ); + match handle.read_session() { + // `Ok(None)` (file vanished between discovery and read) folds into + // the same "nothing to resume from" branch as an empty transcript, + // so the caller's behaviour is unchanged either way. + Ok(None) => { + log::debug!("[transcript] previous transcript is empty — skipping resume"); + } + Ok(Some(session)) => { + if session.messages.is_empty() { + log::debug!("[transcript] previous transcript is empty — skipping resume"); + return; + } + let loaded_count = session.messages.len(); + log::info!("[transcript] loaded {} messages for resume", loaded_count); + // Best-effort store-backed shadow read (issue #4249, + // 04.2 phase 2). Observes + logs divergence only; the + // legacy transcript just loaded stays authoritative and + // is what feeds the resume below. Gated OFF by default. + self.maybe_shadow_read_session_store(&path, &session); + let bounded = self.bound_cached_transcript_messages(session.messages); + if bounded.len() < loaded_count { + log::warn!( + "[transcript] resume prefix trimmed from {} to {} messages (max_history_messages={})", + loaded_count, + bounded.len(), + self.config.max_history_messages + ); } + self.cached_transcript_messages = Some(bounded); } - None => { - log::debug!( - "[transcript] no previous transcript found for agent={}", - self.agent_definition_name + Err(err) => { + log::warn!( + "[transcript] failed to parse previous transcript {}: {err}", + path.display() ); } } } + /// The transcript locator for this session — the injected one, or a + /// [`FileTranscriptLocator`] built from the agent's **current** workspace. + /// + /// Built per call rather than cached: `workspace_dir` and + /// `session_raw_subdir` are reassignable after `build()` (tests do exactly + /// that), and a locator frozen at build time would silently keep resolving + /// against the directory the agent no longer uses. The construction is two + /// clones of small strings — cheaper than the `read_dir` it precedes. + pub(in super::super) fn session_locator(&self) -> std::sync::Arc { + match &self.session_history_locator { + Some(locator) => locator.clone(), + None => std::sync::Arc::new(FileTranscriptLocator::new( + self.workspace_dir.clone(), + self.session_raw_subdir.clone(), + )), + } + } + /// Ask the provider for a short wrap-up message with native tools /// **disabled** so the model returns prose rather than another tool call. /// Buffers text deltas and forwards them to the progress sink (when @@ -497,35 +550,11 @@ impl Agent { charged_amount_usd: f64, turn_usage: Option<&transcript::TurnUsage>, ) { - // Resolve the transcript path on first write. The stem is - // `{parent_prefix}__{session_key}` for sub-agents (producing a - // flat hierarchical filename) or just `{session_key}` for a - // root session. Prefix chaining is already done by the - // sub-agent runner when it populates `session_parent_prefix`. - if self.session_transcript_path.is_none() { - let stem = match &self.session_parent_prefix { - Some(prefix) => format!("{}__{}", prefix, self.session_key), - None => self.session_key.clone(), - }; - let session_raw_dir = self.workspace_dir.join(&self.session_raw_subdir); - match transcript::resolve_keyed_transcript_path_in_dir(&session_raw_dir, &stem) { - Ok(path) => { - log::info!( - "[transcript] new session transcript path={}", - path.display() - ); - self.session_transcript_path = Some(path); - } - Err(err) => { - log::warn!("[transcript] failed to resolve transcript path: {err}"); - return; - } - } - } - - let path = self.session_transcript_path.as_ref().unwrap(); let now = chrono::Utc::now().to_rfc3339(); + // This turn's `_meta`. Built before the path/handle binding below so it + // can double as the handle's `seed_meta`; it depends only on agent + // state and this turn's figures, never on the resolved path. let meta = transcript::TranscriptMeta { agent_name: self.agent_definition_name.clone(), agent_id: Some(self.agent_definition_id.clone()), @@ -552,22 +581,73 @@ impl Agent { task_id: None, }; + // Bind the write seam on first write. The stem is + // `{parent_prefix}__{session_key}` for sub-agents (producing a + // flat hierarchical filename) or just `{session_key}` for a + // root session. Prefix chaining is already done by the + // sub-agent runner when it populates `session_parent_prefix`. + // + // Path resolution is the locator's job, not this function's: it used to + // be duplicated here (a `resolve_keyed_transcript_path_in_dir` call + // that had to stay in lockstep with the identical one inside the + // handle's constructor). One call now yields both, and + // `session_transcript_path` is simply the bound handle's own path — so + // they cannot drift. The seed meta only matters when the file is absent + // and the caller supplies none; the turn path always passes its own + // freshly-computed meta below, so it never takes effect. + if self.session_transcript_path.is_none() { + let stem = match &self.session_parent_prefix { + Some(prefix) => format!("{}__{}", prefix, self.session_key), + None => self.session_key.clone(), + }; + match self.session_locator().open_stem(&stem, meta.clone()) { + Ok(history) => { + log::info!( + "[transcript] new session transcript path={}", + history.path().display() + ); + self.session_transcript_path = Some(history.path().to_path_buf()); + self.session_history = Some(history); + } + Err(err) => { + log::warn!("[transcript] failed to bind session history: {err:#}"); + self.session_transcript_path = None; + return; + } + } + } + + let path = self.session_transcript_path.clone().unwrap(); + // Cloned out of `self` before the write so the later `&mut self` + // dual-write does not conflict with a live borrow of the handle. + let Some(history) = self.session_history.clone() else { + log::warn!("[transcript] no session history bound; skipping append"); + return; + }; + // Append-only write (Phase A, transcript-derived view): diff this turn's // logical messages against the previously-persisted set tracked in // memory. A pure extension appends only the new tail; a context // reduction appends a `compaction` record. The file is never rewritten, // so pre-compaction history survives on disk for the display projection. // `request_id` (web-chat only) stamps a turn boundary on each line. + // + // This goes through `SessionHistory::append_turn` rather than + // `transcript::append_transcript_turn` directly (S4). The handle is a + // pure forwarder of exactly these six values — it must be, because the + // crate's `ChatHistory` methods carry no channel for `request_id`, + // `turn_usage` or a caller-computed `TranscriptMeta`, and dropping any + // of them silently guts the transcript-view projection. See the header + // of `transcript_history.rs` for the full argument. let prev = std::mem::take(&mut self.persisted_transcript_messages); let request_id = crate::openhuman::agent::turn_origin::current_request_id(); - match transcript::append_transcript_turn( - path, - &prev, - messages, - &meta, + match history.append_turn(TranscriptTurn { + prev: &prev, + next: messages, + meta: &meta, turn_usage, - request_id.as_deref(), - ) { + request_id: request_id.as_deref(), + }) { Ok(()) => { // Track the new persisted logical set for the next turn's diff. self.persisted_transcript_messages = messages.to_vec(); @@ -576,7 +656,7 @@ impl Agent { // (`OPENHUMAN_SESSION_DUAL_WRITE` is a kill switch). Only runs // after the legacy JSONL append above succeeds; the legacy path // is primary and untouched (issue #4249, 04.1). - self.maybe_dual_write_session_store(path, messages, &meta, turn_usage); + self.maybe_dual_write_session_store(&path, messages, &meta, turn_usage); } Err(err) => { // Restore the tracked state so a transient failure doesn't make diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index bad6188d35..1c33ca5d61 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -167,6 +167,39 @@ pub struct Agent { /// Set on first write, reused for subsequent **appends** within the /// same session. pub(super) session_transcript_path: Option, + /// The transcript-write seam for this session, bound to the same file as + /// `session_transcript_path` on first write. + /// + /// This is the S4 indirection: the turn path appends through + /// [`SessionHistory::append_turn`][super::transcript_history::SessionHistory::append_turn] + /// rather than calling the format's free function directly. + /// + /// It is `Arc` rather than the concrete handle so the turn loop is + /// written against the seam instead of the implementation. It is now + /// genuinely substitutable: the handle is produced by + /// [`SessionHistoryLocator::open_stem`][super::transcript_history::SessionHistoryLocator::open_stem] + /// on the locator in `session_history_locator`, so injecting a locator + /// replaces this session's writes as well as both of its resume reads. + /// + /// `session_transcript_path` stays alongside it rather than being folded + /// into the handle: the dual-write mirror needs the concrete `&Path` for + /// `file_stem()`, and several tests assert on it directly. + pub(super) session_history: + Option>, + /// Injected transcript locator, or `None` to use real files. + /// + /// The single injection point for the whole transcript seam: it resolves + /// both resume reads (`latest_for_agent`, `root_for_thread`) and binds this + /// session's write handle (`open_stem`). `None` is the production default + /// and is resolved *lazily* by + /// [`Agent::session_locator`][Self::session_locator] into a + /// [`FileTranscriptLocator`][super::transcript_history::FileTranscriptLocator] + /// over the **current** `workspace_dir` / `session_raw_subdir` — never + /// captured at build time, because callers (tests especially) reassign + /// `workspace_dir` after `build()` and a frozen locator would silently keep + /// reading the old directory. + pub(super) session_history_locator: + Option>, /// The logical message set most recently persisted to /// `session_transcript_path`, tracked in memory so the append-only writer /// can diff each turn's messages against it (pure extension → append tail; @@ -426,6 +459,12 @@ pub struct AgentBuilder { /// flat in `session_raw/DDMMYYYY/{session_key}.jsonl`. Populated /// by the sub-agent runner so nested delegations produce a tree. pub(super) session_parent_prefix: Option, + /// Forwarded to [`Agent::session_history_locator`]. `None` (default) means + /// real files; set it with + /// [`with_session_history_locator`][super::builder::AgentBuilder::with_session_history_locator] + /// to substitute the transcript backing store for the whole turn path. + pub(super) session_history_locator: + Option>, /// Forwarded to [`Agent::omit_profile`] at `build()` time. Mirrors the /// target definition's `omit_profile` flag; `None` means "fall back /// to the safe default" (omit). diff --git a/src/openhuman/agent/orchestration/agent_teams/mod.rs b/src/openhuman/agent/orchestration/agent_teams/mod.rs index 7de046e65e..d944f4fbdc 100644 --- a/src/openhuman/agent/orchestration/agent_teams/mod.rs +++ b/src/openhuman/agent/orchestration/agent_teams/mod.rs @@ -3,7 +3,7 @@ //! A first-class, restart-survivable model for a lead agent coordinating a team //! of worker agents: teams, members, dependency-aware tasks with race-safe //! atomic claiming, and teammate messaging. All durable state lives in -//! `session_db::run_ledger` (the `agent_teams` / `agent_team_members` / +//! `tinyagents::session::run_ledger` (the `agent_teams` / `agent_team_members` / //! `agent_team_tasks` tables, plus the shared run-event log for messages), //! never in the main chat context — so a coordination session can be listed, //! inspected, and resumed. diff --git a/src/openhuman/agent/orchestration/agent_teams/ops.rs b/src/openhuman/agent/orchestration/agent_teams/ops.rs index 08cfc58550..f6d84a341e 100644 --- a/src/openhuman/agent/orchestration/agent_teams/ops.rs +++ b/src/openhuman/agent/orchestration/agent_teams/ops.rs @@ -1,6 +1,6 @@ //! Business logic for durable agent-team coordination (#3374). //! -//! Thin orchestration over `session_db::run_ledger`: create teams + members, +//! Thin orchestration over `tinyagents::session::run_ledger`: create teams + members, //! assign dependency-aware tasks (with self/unknown/cycle validation reusing //! the same Kahn's-algorithm shape as `workflow_runs`), atomically claim tasks, //! and exchange teammate messages. Messaging rides the run-ledger event stream @@ -13,13 +13,13 @@ use chrono::Utc; use serde_json::json; use uuid::Uuid; -use crate::openhuman::agent::session_db::run_ledger::{ +use crate::openhuman::config::Config; +use tinyagents::session::run_ledger::{ self, AgentTeam, AgentTeamListRequest, AgentTeamListResponse, AgentTeamMemberStatus, AgentTeamMemberUpsert, AgentTeamStatus, AgentTeamTask, AgentTeamTaskStatus, AgentTeamTaskUpsert, AgentTeamUpsert, ClaimOutcome, CompletionOutcome, RunEvent, RunEventAppend, RunEventListRequest, }; -use crate::openhuman::config::Config; use super::types::{MemberShutdown, TeamError, TeamView}; @@ -59,7 +59,7 @@ pub fn create_team( let team_id = format!("team-{}", Uuid::new_v4().simple()); run_ledger::upsert_agent_team( - config, + &config.workspace_dir, AgentTeamUpsert { id: team_id.clone(), parent_thread_id: parent_thread_id.map(str::to_string), @@ -73,7 +73,7 @@ pub fn create_team( for member in members { run_ledger::upsert_agent_team_member( - config, + &config.workspace_dir, AgentTeamMemberUpsert { id: format!("member-{}", Uuid::new_v4().simple()), team_id: team_id.clone(), @@ -99,13 +99,16 @@ pub fn list_teams( request: &AgentTeamListRequest, ) -> Result { log::debug!("{LOG_PREFIX} list_teams.entry status={:?}", request.status); - run_ledger::list_agent_teams(config, request) + Ok(run_ledger::list_agent_teams( + &config.workspace_dir, + request, + )?) } /// Build the aggregate [`TeamView`] for a team id; `None` if the team is absent. pub fn get_team(config: &Config, team_id: &str) -> Result> { log::debug!("{LOG_PREFIX} get_team.entry id={team_id}"); - match run_ledger::get_agent_team(config, team_id)? { + match run_ledger::get_agent_team(&config.workspace_dir, team_id)? { Some(_) => Ok(Some(team_view(config, team_id)?)), None => { log::debug!("{LOG_PREFIX} get_team.exit id={team_id} found=false"); @@ -133,15 +136,15 @@ pub fn assign_task( depends_on.len() ); - let team = run_ledger::get_agent_team(config, team_id)? + let team = run_ledger::get_agent_team(&config.workspace_dir, team_id)? .ok_or_else(|| anyhow!("unknown team: {team_id}"))?; let _ = team; - let existing = run_ledger::list_agent_team_tasks(config, team_id)?; + let existing = run_ledger::list_agent_team_tasks(&config.workspace_dir, team_id)?; let task_id = format!("task-{}", Uuid::new_v4().simple()); if let Some(owner) = owner_member_id { - let members = run_ledger::list_agent_team_members(config, team_id)?; + let members = run_ledger::list_agent_team_members(&config.workspace_dir, team_id)?; if !members.iter().any(|m| m.id == owner) { return Err(anyhow!(TeamError::UnknownMember { member_id: owner.to_string(), @@ -153,7 +156,7 @@ pub fn assign_task( let order_index = existing.len() as i64; let task = run_ledger::upsert_agent_team_task( - config, + &config.workspace_dir, AgentTeamTaskUpsert { id: task_id.clone(), team_id: team_id.to_string(), @@ -183,13 +186,19 @@ pub fn claim_task( claim_token: &str, ) -> Result { log::debug!("{LOG_PREFIX} claim_task.entry team={team_id} task={task_id} member={member_id}"); - let members = run_ledger::list_agent_team_members(config, team_id)?; + let members = run_ledger::list_agent_team_members(&config.workspace_dir, team_id)?; if !members.iter().any(|m| m.id == member_id) { return Err(anyhow!(TeamError::UnknownMember { member_id: member_id.to_string(), })); } - run_ledger::claim_agent_team_task(config, team_id, task_id, member_id, claim_token) + Ok(run_ledger::claim_agent_team_task( + &config.workspace_dir, + team_id, + task_id, + member_id, + claim_token, + )?) } /// Sentinel `from` value for a message that originates from the team lead / the @@ -222,11 +231,11 @@ pub fn message_member( // `to = None`) skips both member checks below, so without this guard an // unknown `team_id` would still append an orphan `team_message` event to a // non-existent team's run ledger. - if run_ledger::get_agent_team(config, team_id)?.is_none() { + if run_ledger::get_agent_team(&config.workspace_dir, team_id)?.is_none() { return Err(anyhow!("unknown team: {team_id}")); } - let members = run_ledger::list_agent_team_members(config, team_id)?; + let members = run_ledger::list_agent_team_members(&config.workspace_dir, team_id)?; if let Some(from) = from_member_id { if !members.iter().any(|m| m.id == from) { return Err(anyhow!(TeamError::UnknownMember { @@ -244,7 +253,7 @@ pub fn message_member( let from_value = from_member_id.unwrap_or(LEAD_SENDER); let event = run_ledger::append_run_event( - config, + &config.workspace_dir, RunEventAppend { run_id: team_id.to_string(), event_type: TEAM_MESSAGE_EVENT.to_string(), @@ -267,7 +276,7 @@ pub fn message_member( pub fn list_messages(config: &Config, team_id: &str, limit: Option) -> Result> { log::debug!("{LOG_PREFIX} list_messages.entry team={team_id}"); let response = run_ledger::list_recent_run_events( - config, + &config.workspace_dir, &RunEventListRequest { run_id: team_id.to_string(), after_sequence: None, @@ -289,10 +298,10 @@ pub fn list_messages(config: &Config, team_id: &str, limit: Option) -> Resu /// Mark a team closed. pub fn close_team(config: &Config, team_id: &str, summary: Option<&str>) -> Result { log::debug!("{LOG_PREFIX} close_team.entry team={team_id}"); - let existing = run_ledger::get_agent_team(config, team_id)? + let existing = run_ledger::get_agent_team(&config.workspace_dir, team_id)? .ok_or_else(|| anyhow!("unknown team: {team_id}"))?; let team = run_ledger::upsert_agent_team( - config, + &config.workspace_dir, AgentTeamUpsert { id: team_id.to_string(), parent_thread_id: existing.parent_thread_id.clone(), @@ -324,14 +333,14 @@ pub fn complete_task( log::debug!( "{LOG_PREFIX} complete_task.entry team={team_id} task={task_id} member={member_id}" ); - let members = run_ledger::list_agent_team_members(config, team_id)?; + let members = run_ledger::list_agent_team_members(&config.workspace_dir, team_id)?; if !members.iter().any(|m| m.id == member_id) { return Err(anyhow!(TeamError::UnknownMember { member_id: member_id.to_string(), })); } let outcome = run_ledger::complete_agent_team_task( - config, + &config.workspace_dir, team_id, task_id, member_id, @@ -349,11 +358,12 @@ pub fn complete_task( pub fn shutdown_member(config: &Config, team_id: &str, member_id: &str) -> Result { log::debug!("{LOG_PREFIX} shutdown_member.entry team={team_id} member={member_id}"); let (member, released_task_ids) = - run_ledger::shutdown_agent_team_member(config, team_id, member_id)?.ok_or_else(|| { - anyhow!(TeamError::UnknownMember { - member_id: member_id.to_string(), - }) - })?; + run_ledger::shutdown_agent_team_member(&config.workspace_dir, team_id, member_id)? + .ok_or_else(|| { + anyhow!(TeamError::UnknownMember { + member_id: member_id.to_string(), + }) + })?; log::debug!( "{LOG_PREFIX} shutdown_member.exit team={team_id} member={member_id} released={}", released_task_ids.len() @@ -365,10 +375,10 @@ pub fn shutdown_member(config: &Config, team_id: &str, member_id: &str) -> Resul } fn team_view(config: &Config, team_id: &str) -> Result { - let team = run_ledger::get_agent_team(config, team_id)? + let team = run_ledger::get_agent_team(&config.workspace_dir, team_id)? .ok_or_else(|| anyhow!("team missing after creation: {team_id}"))?; - let members = run_ledger::list_agent_team_members(config, team_id)?; - let tasks = run_ledger::list_agent_team_tasks(config, team_id)?; + let members = run_ledger::list_agent_team_members(&config.workspace_dir, team_id)?; + let tasks = run_ledger::list_agent_team_tasks(&config.workspace_dir, team_id)?; Ok(TeamView { team, members, @@ -550,7 +560,7 @@ mod tests { // Cycle: try to make A depend_on B (A already an upstream of B). // Re-upserting A with depends_on [B] would close the loop; assign_task // only creates new tasks, so emulate the cycle check directly. - let existing = run_ledger::list_agent_team_tasks(&config, &team_id).unwrap(); + let existing = run_ledger::list_agent_team_tasks(&config.workspace_dir, &team_id).unwrap(); assert!(has_task_cycle(&a.id, &[b.id.clone()], &existing)); } @@ -694,7 +704,7 @@ mod tests { } other => panic!("expected GateFailed, got {other:?}"), } - let mid = run_ledger::get_agent_team_task(&config, &task.id) + let mid = run_ledger::get_agent_team_task(&config.workspace_dir, &task.id) .unwrap() .unwrap(); assert_eq!(mid.status, AgentTeamTaskStatus::InProgress); @@ -829,7 +839,7 @@ mod tests { assert_eq!(result.member.member_status, AgentTeamMemberStatus::Stopped); // Task is back to todo and unclaimed → another teammate could claim it. - let released = run_ledger::get_agent_team_task(&config, &task.id) + let released = run_ledger::get_agent_team_task(&config.workspace_dir, &task.id) .unwrap() .unwrap(); assert_eq!(released.status, AgentTeamTaskStatus::Todo); diff --git a/src/openhuman/agent/orchestration/agent_teams/runtime.rs b/src/openhuman/agent/orchestration/agent_teams/runtime.rs index f9631419ce..28052daa87 100644 --- a/src/openhuman/agent/orchestration/agent_teams/runtime.rs +++ b/src/openhuman/agent/orchestration/agent_teams/runtime.rs @@ -35,11 +35,11 @@ use crate::openhuman::agent::orchestration::parent_context::with_root_parent; use crate::openhuman::agent::orchestration::{ AgentOrchestrationSession, AgentStatus, SpawnAgentRequest, WaitAgentOptions, }; -use crate::openhuman::agent::session_db::run_ledger::{ +use crate::openhuman::config::Config; +use tinyagents::session::run_ledger::{ self, AgentTeamMemberStatus, AgentTeamTask, AgentTeamTaskStatus, ClaimOutcome, RunEvent, RunEventAppend, RunEventListRequest, }; -use crate::openhuman::config::Config; use super::types::{StartMemberOutcome, TeamError}; @@ -83,7 +83,7 @@ pub async fn start_member_run( "[agent_team_runtime] start.entry team={team_id} member={member_id} task={task_id:?}" ); - let member = run_ledger::get_agent_team_member(config, member_id)? + let member = run_ledger::get_agent_team_member(&config.workspace_dir, member_id)? .filter(|m| m.team_id == team_id) .ok_or_else(|| { anyhow!(TeamError::UnknownMember { @@ -107,7 +107,7 @@ pub async fn start_member_run( // Resolve the target task: an explicit id, or the member's next claimable // ready task (unowned or owned-by-this-member, dependencies all done). - let tasks = run_ledger::list_agent_team_tasks(config, team_id)?; + let tasks = run_ledger::list_agent_team_tasks(&config.workspace_dir, team_id)?; let target = match task_id { Some(tid) => match tasks.iter().find(|t| t.id == tid) { Some(t) => t.clone(), @@ -122,18 +122,23 @@ pub async fn start_member_run( // The team-run id doubles as the claim token (CAS guard) and the member's // worker/run pointer surfaced to the UI. let run_id = format!("teamrun-{}", uuid::Uuid::new_v4().simple()); - let claimed = - match run_ledger::claim_agent_team_task(config, team_id, &target.id, member_id, &run_id)? { - ClaimOutcome::Claimed(task) => *task, - ClaimOutcome::AlreadyClaimed => return Ok(StartMemberOutcome::AlreadyClaimed), - ClaimOutcome::Blocked { unmet } => return Ok(StartMemberOutcome::Blocked { unmet }), - ClaimOutcome::UnknownTask => return Ok(StartMemberOutcome::UnknownTask), - }; + let claimed = match run_ledger::claim_agent_team_task( + &config.workspace_dir, + team_id, + &target.id, + member_id, + &run_id, + )? { + ClaimOutcome::Claimed(task) => *task, + ClaimOutcome::AlreadyClaimed => return Ok(StartMemberOutcome::AlreadyClaimed), + ClaimOutcome::Blocked { unmet } => return Ok(StartMemberOutcome::Blocked { unmet }), + ClaimOutcome::UnknownTask => return Ok(StartMemberOutcome::UnknownTask), + }; // Mark active synchronously so the polling UI reflects the running member // before the (async) worker even starts. run_ledger::mark_agent_team_member_running( - config, + &config.workspace_dir, team_id, member_id, &claimed.id, @@ -209,8 +214,8 @@ async fn run_member_loop( "[agent_team_runtime] loop.failed team={team_id} member={member_id} task={} err={err}", task.id ); - let _ = run_ledger::release_agent_team_task(config, team_id, &task.id); - let _ = run_ledger::mark_agent_team_member_idle(config, team_id, member_id); + let _ = run_ledger::release_agent_team_task(&config.workspace_dir, team_id, &task.id); + let _ = run_ledger::mark_agent_team_member_idle(&config.workspace_dir, team_id, member_id); record_failure_event(config, team_id, member_id, &task.id, &err.to_string()); } } @@ -332,13 +337,22 @@ async fn drive_member( )] }; let outcome = run_ledger::complete_agent_team_task( - &config, &team_id, &task_id, &member_id, &evidence, false, + &config.workspace_dir, + &team_id, + &task_id, + &member_id, + &evidence, + false, )?; log::debug!( target: LOG_TARGET, "[agent_team_runtime] drive.completed team={team_id} member={member_id} task={task_id} outcome={outcome:?}" ); - run_ledger::mark_agent_team_member_idle(&config, &team_id, &member_id)?; + run_ledger::mark_agent_team_member_idle( + &config.workspace_dir, + &team_id, + &member_id, + )?; Ok(()) } } @@ -362,8 +376,12 @@ async fn drive_member( target: LOG_TARGET, "[agent_team_runtime] drive.worker_failed team={team_id} member={member_id} task={task_id} reason={reason}" ); - run_ledger::release_agent_team_task(&config, &team_id, &task_id)?; - run_ledger::mark_agent_team_member_idle(&config, &team_id, &member_id)?; + run_ledger::release_agent_team_task(&config.workspace_dir, &team_id, &task_id)?; + run_ledger::mark_agent_team_member_idle( + &config.workspace_dir, + &team_id, + &member_id, + )?; record_failure_event(&config, &team_id, &member_id, &task_id, &reason); Ok(()) } @@ -432,7 +450,7 @@ fn drain_run_events(config: &Config, team_id: &str) -> Result> { let mut after: Option = None; loop { let response = run_ledger::list_recent_run_events( - config, + &config.workspace_dir, &RunEventListRequest { run_id: team_id.to_string(), after_sequence: after, @@ -485,7 +503,7 @@ fn deliver_pending_messages( if !contents.is_empty() { run_ledger::append_run_event( - config, + &config.workspace_dir, RunEventAppend { run_id: team_id.to_string(), event_type: MESSAGE_DELIVERED_EVENT.to_string(), @@ -504,7 +522,7 @@ fn record_failure_event( reason: &str, ) { let _ = run_ledger::append_run_event( - config, + &config.workspace_dir, RunEventAppend { run_id: team_id.to_string(), event_type: MEMBER_FAILED_EVENT.to_string(), diff --git a/src/openhuman/agent/orchestration/agent_teams/runtime_tests.rs b/src/openhuman/agent/orchestration/agent_teams/runtime_tests.rs index 074d660fc3..88b232b729 100644 --- a/src/openhuman/agent/orchestration/agent_teams/runtime_tests.rs +++ b/src/openhuman/agent/orchestration/agent_teams/runtime_tests.rs @@ -18,14 +18,14 @@ use super::*; use crate::openhuman::agent::context::prompt::ToolCallFormat; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::harness::fork_context::{with_parent_context, ParentExecutionContext}; -use crate::openhuman::agent::session_db::run_ledger::{ - self, AgentTeamMemberStatus, AgentTeamMemberUpsert, AgentTeamStatus, AgentTeamTaskStatus, - AgentTeamTaskUpsert, AgentTeamUpsert, -}; use crate::openhuman::config::{AgentConfig, Config}; use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}; use crate::openhuman::tools::{Tool, ToolSpec}; use tinyagents::harness::model::{ChatModel, ModelRequest, ModelResponse}; +use tinyagents::session::run_ledger::{ + self, AgentTeamMemberStatus, AgentTeamMemberUpsert, AgentTeamStatus, AgentTeamTaskStatus, + AgentTeamTaskUpsert, AgentTeamUpsert, +}; // ── Mocks (mirror workflow_runs::engine_tests) ────────────────────────────── @@ -147,7 +147,7 @@ fn test_config() -> (tempfile::TempDir, Config) { fn seed_team(config: &Config, team_id: &str) { run_ledger::upsert_agent_team( - config, + &config.workspace_dir, AgentTeamUpsert { id: team_id.into(), parent_thread_id: None, @@ -163,7 +163,7 @@ fn seed_team(config: &Config, team_id: &str) { fn seed_member(config: &Config, team_id: &str, member_id: &str, agent_id: Option<&str>) { run_ledger::upsert_agent_team_member( - config, + &config.workspace_dir, AgentTeamMemberUpsert { id: member_id.into(), team_id: team_id.into(), @@ -188,7 +188,7 @@ fn seed_task( depends_on: Vec, ) { run_ledger::upsert_agent_team_task( - config, + &config.workspace_dir, AgentTeamTaskUpsert { id: task_id.into(), team_id: team_id.into(), @@ -225,9 +225,10 @@ async fn drive_member_completes_task_with_worker_output_as_evidence() { vec![], ); // Claim + mark running, mirroring what start_member_run does pre-spawn. - run_ledger::claim_agent_team_task(&config, "team-1", "task-a", "m1", "teamrun-x").unwrap(); + run_ledger::claim_agent_team_task(&config.workspace_dir, "team-1", "task-a", "m1", "teamrun-x") + .unwrap(); run_ledger::mark_agent_team_member_running( - &config, + &config.workspace_dir, "team-1", "m1", "task-a", @@ -235,7 +236,7 @@ async fn drive_member_completes_task_with_worker_output_as_evidence() { "teamrun-x", ) .unwrap(); - let task = run_ledger::get_agent_team_task(&config, "task-a") + let task = run_ledger::get_agent_team_task(&config.workspace_dir, "task-a") .unwrap() .unwrap(); @@ -258,7 +259,7 @@ async fn drive_member_completes_task_with_worker_output_as_evidence() { .await .expect("drive_member ok"); - let done = run_ledger::get_agent_team_task(&config, "task-a") + let done = run_ledger::get_agent_team_task(&config.workspace_dir, "task-a") .unwrap() .unwrap(); assert_eq!(done.status, AgentTeamTaskStatus::Done); @@ -266,7 +267,7 @@ async fn drive_member_completes_task_with_worker_output_as_evidence() { assert_eq!(done.evidence.len(), 1, "worker output captured as evidence"); assert!(done.evidence[0].contains("teamrun-x")); - let member = run_ledger::get_agent_team_member(&config, "m1") + let member = run_ledger::get_agent_team_member(&config.workspace_dir, "m1") .unwrap() .unwrap(); assert_eq!(member.member_status, AgentTeamMemberStatus::Idle); @@ -292,9 +293,10 @@ async fn run_member_loop_drives_member_under_ambient_parent() { None, vec![], ); - run_ledger::claim_agent_team_task(&config, "team-1", "task-a", "m1", "teamrun-y").unwrap(); + run_ledger::claim_agent_team_task(&config.workspace_dir, "team-1", "task-a", "m1", "teamrun-y") + .unwrap(); run_ledger::mark_agent_team_member_running( - &config, + &config.workspace_dir, "team-1", "m1", "task-a", @@ -302,7 +304,7 @@ async fn run_member_loop_drives_member_under_ambient_parent() { "teamrun-y", ) .unwrap(); - let task = run_ledger::get_agent_team_task(&config, "task-a") + let task = run_ledger::get_agent_team_task(&config.workspace_dir, "task-a") .unwrap() .unwrap(); @@ -324,7 +326,7 @@ async fn run_member_loop_drives_member_under_ambient_parent() { }) .await; - let done = run_ledger::get_agent_team_task(&config, "task-a") + let done = run_ledger::get_agent_team_task(&config.workspace_dir, "task-a") .unwrap() .unwrap(); assert_eq!( @@ -348,9 +350,10 @@ async fn drive_member_releases_task_when_worker_fails() { None, vec![], ); - run_ledger::claim_agent_team_task(&config, "team-1", "task-a", "m1", "teamrun-x").unwrap(); + run_ledger::claim_agent_team_task(&config.workspace_dir, "team-1", "task-a", "m1", "teamrun-x") + .unwrap(); run_ledger::mark_agent_team_member_running( - &config, + &config.workspace_dir, "team-1", "m1", "task-a", @@ -358,7 +361,7 @@ async fn drive_member_releases_task_when_worker_fails() { "teamrun-x", ) .unwrap(); - let task = run_ledger::get_agent_team_task(&config, "task-a") + let task = run_ledger::get_agent_team_task(&config.workspace_dir, "task-a") .unwrap() .unwrap(); @@ -382,13 +385,13 @@ async fn drive_member_releases_task_when_worker_fails() { .expect("drive_member handles worker failure without erroring"); // Task released back to todo, claim cleared → reclaimable. - let released = run_ledger::get_agent_team_task(&config, "task-a") + let released = run_ledger::get_agent_team_task(&config.workspace_dir, "task-a") .unwrap() .unwrap(); assert_eq!(released.status, AgentTeamTaskStatus::Todo); assert_eq!(released.claimed_by_member_id, None); - let member = run_ledger::get_agent_team_member(&config, "m1") + let member = run_ledger::get_agent_team_member(&config.workspace_dir, "m1") .unwrap() .unwrap(); assert_eq!(member.member_status, AgentTeamMemberStatus::Idle); @@ -443,7 +446,8 @@ async fn start_member_run_reports_already_claimed() { vec![], ); // m2 already holds task-a. - run_ledger::claim_agent_team_task(&config, "team-1", "task-a", "m2", "tok").unwrap(); + run_ledger::claim_agent_team_task(&config.workspace_dir, "team-1", "task-a", "m2", "tok") + .unwrap(); let outcome = start_member_run(&config, "team-1", "m1", Some("task-a"), None) .await @@ -486,7 +490,7 @@ async fn start_member_run_rejects_already_active_member_without_side_effects() { seed_team(&config, "team-1"); // A member already mid-run (active), plus a fresh claimable task. run_ledger::upsert_agent_team_member( - &config, + &config.workspace_dir, AgentTeamMemberUpsert { id: "m1".into(), team_id: "team-1".into(), @@ -516,12 +520,12 @@ async fn start_member_run_rejects_already_active_member_without_side_effects() { // No claim happened — the free task is untouched and the member still points // at its original run (no clobbered pointer). - let task = run_ledger::get_agent_team_task(&config, "t-free") + let task = run_ledger::get_agent_team_task(&config.workspace_dir, "t-free") .unwrap() .expect("task exists"); assert_eq!(task.status, AgentTeamTaskStatus::Todo); assert!(task.claimed_by_member_id.is_none()); - let member = run_ledger::get_agent_team_member(&config, "m1") + let member = run_ledger::get_agent_team_member(&config.workspace_dir, "m1") .unwrap() .expect("member exists"); assert_eq!(member.current_task_id.as_deref(), Some("t-running")); @@ -631,7 +635,7 @@ fn deliver_pending_messages_pages_past_first_event_page() { // Push the sequence far past the old 100-row cap with unrelated events. for i in 0..150 { run_ledger::append_run_event( - &config, + &config.workspace_dir, run_ledger::RunEventAppend { run_id: "team-1".into(), event_type: "noise".into(), diff --git a/src/openhuman/agent/orchestration/agent_teams/schemas.rs b/src/openhuman/agent/orchestration/agent_teams/schemas.rs index 630359dbbc..19ade9e389 100644 --- a/src/openhuman/agent/orchestration/agent_teams/schemas.rs +++ b/src/openhuman/agent/orchestration/agent_teams/schemas.rs @@ -11,9 +11,9 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::openhuman::agent::session_db::run_ledger::AgentTeamListRequest; use crate::openhuman::config::rpc as config_rpc; use crate::rpc::RpcOutcome; +use tinyagents::session::run_ledger::AgentTeamListRequest; use super::ops::{self, NewMember}; use super::runtime; diff --git a/src/openhuman/agent/orchestration/agent_teams/types.rs b/src/openhuman/agent/orchestration/agent_teams/types.rs index 7ccf83a48f..e1a5cb0180 100644 --- a/src/openhuman/agent/orchestration/agent_teams/types.rs +++ b/src/openhuman/agent/orchestration/agent_teams/types.rs @@ -1,13 +1,13 @@ //! Aggregate + validation types for durable agent-team coordination (#3374). //! //! The durable row types ([`AgentTeam`], [`AgentTeamMember`], [`AgentTeamTask`], -//! [`ClaimOutcome`]) live in `session_db::run_ledger`. This module adds the +//! [`ClaimOutcome`]) live in `tinyagents::session::run_ledger`. This module adds the //! read-aggregate view returned by the controllers and the validation error //! surface used by `ops::assign_task`. use serde::Serialize; -use crate::openhuman::agent::session_db::run_ledger::{AgentTeam, AgentTeamMember, AgentTeamTask}; +use tinyagents::session::run_ledger::{AgentTeam, AgentTeamMember, AgentTeamTask}; /// A team plus its members and tasks — the shape returned by `get`. #[derive(Debug, Clone, PartialEq, Serialize)] diff --git a/src/openhuman/agent/orchestration/command_center/control.rs b/src/openhuman/agent/orchestration/command_center/control.rs index d414e215a0..016061bce7 100644 --- a/src/openhuman/agent/orchestration/command_center/control.rs +++ b/src/openhuman/agent/orchestration/command_center/control.rs @@ -2,7 +2,7 @@ //! //! The read-only projection in [`super::ops`] shows what background agent work //! is in flight; these verbs let a reviewer *act* on a single row. Each verb is -//! a durable transition on the run ledger (`session_db::run_ledger`): +//! a durable transition on the run ledger (`tinyagents::session::run_ledger`): //! //! - **stop** — cancel a non-terminal run (→ `cancelled`). //! - **retry** — re-queue a finished-with-error run (`failed` / `cancelled` / @@ -23,16 +23,16 @@ //! unit-tested without a database, mirroring [`super::ops::build_view`]. //! //! [`AgentOrchestrationSession`]: crate::openhuman::agent::orchestration::ops::AgentOrchestrationSession -//! [`transition_agent_run_status`]: crate::openhuman::agent::session_db::run_ledger::transition_agent_run_status +//! [`transition_agent_run_status`]: tinyagents::session::run_ledger::transition_agent_run_status use chrono::{DateTime, Utc}; use serde_json::json; use thiserror::Error; -use crate::openhuman::agent::session_db::run_ledger::{ +use crate::openhuman::config::Config; +use tinyagents::session::run_ledger::{ append_run_event, get_agent_run, transition_agent_run_status, AgentRunStatus, RunEventAppend, }; -use crate::openhuman::config::Config; use super::ops::project_row; use super::types::AgentWorkRow; @@ -101,6 +101,18 @@ pub enum ControlError { Storage(#[from] anyhow::Error), } +/// Lets `?` carry a run-ledger failure straight into [`ControlError`]. +/// +/// The ledger lives in `tinyagents` and speaks `TinyAgentsError`, while this +/// module's callers speak `anyhow`. Without this the `#[from] anyhow::Error` +/// variant does not apply, because `TinyAgentsError` is a distinct type — so +/// every ledger call in this file would need its own `map_err`. +impl From for ControlError { + fn from(err: tinyagents::TinyAgentsError) -> Self { + Self::Storage(err.into()) + } +} + /// The durable status a verb moves a run to, plus the event type to record. /// /// `error` / `completed_at` handling is verb-specific and applied in @@ -205,7 +217,7 @@ pub fn apply_control( return Err(ControlError::MessageRequired(verb.as_str())); } - let run = get_agent_run(config, run_id)? + let run = get_agent_run(&config.workspace_dir, run_id)? .ok_or_else(|| ControlError::RunNotFound(run_id.to_string()))?; let from_status = run.status; let plan = plan_transition(from_status, verb)?; @@ -222,7 +234,7 @@ pub fn apply_control( }; let updated = transition_agent_run_status( - config, + &config.workspace_dir, run_id, plan.target_status, next_error.as_deref(), @@ -232,7 +244,7 @@ pub fn apply_control( // Record the action on the run's durable timeline. append_run_event( - config, + &config.workspace_dir, RunEventAppend { run_id: run_id.to_string(), event_type: plan.event_type.to_string(), @@ -259,11 +271,11 @@ pub fn apply_control( #[cfg(test)] mod tests { use super::*; - use crate::openhuman::agent::session_db::run_ledger::{ - list_recent_run_events, upsert_agent_run, AgentRunKind, AgentRunUpsert, RunEventListRequest, - }; use serde_json::json; use tempfile::TempDir; + use tinyagents::session::run_ledger::{ + list_recent_run_events, upsert_agent_run, AgentRunKind, AgentRunUpsert, RunEventListRequest, + }; fn test_config(dir: &TempDir) -> Config { let mut config = Config::default(); @@ -274,7 +286,7 @@ mod tests { fn seed_run(config: &Config, id: &str, status: AgentRunStatus) { upsert_agent_run( - config, + &config.workspace_dir, AgentRunUpsert { id: id.to_string(), kind: AgentRunKind::Subagent, @@ -426,7 +438,7 @@ mod tests { assert_eq!(row.error.as_deref(), Some("manual")); let events = list_recent_run_events( - &config, + &config.workspace_dir, &RunEventListRequest { run_id: "run-1".into(), after_sequence: None, @@ -480,7 +492,9 @@ mod tests { apply_control(&config, "run-1", ControlVerb::Continue, Some(" "), None).unwrap_err(); assert!(matches!(err, ControlError::MessageRequired("continue"))); // Status untouched. - let run = get_agent_run(&config, "run-1").unwrap().unwrap(); + let run = get_agent_run(&config.workspace_dir, "run-1") + .unwrap() + .unwrap(); assert_eq!(run.status, AgentRunStatus::AwaitingUser); } @@ -501,7 +515,7 @@ mod tests { assert_eq!(row.status, "completed"); let events = list_recent_run_events( - &config, + &config.workspace_dir, &RunEventListRequest { run_id: "run-1".into(), after_sequence: None, diff --git a/src/openhuman/agent/orchestration/command_center/mod.rs b/src/openhuman/agent/orchestration/command_center/mod.rs index d1e6aa0447..8ca682166d 100644 --- a/src/openhuman/agent/orchestration/command_center/mod.rs +++ b/src/openhuman/agent/orchestration/command_center/mod.rs @@ -1,7 +1,7 @@ //! Background agent command center (issue #3373). //! //! A read-only product surface over the durable run ledger -//! (`session_db::run_ledger`): it lists recent background agent runs grouped by +//! (`tinyagents::session::run_ledger`): it lists recent background agent runs grouped by //! a normalized status model (needs-input / working / completed / failed / //! stopped) so users can see what is in flight, what is blocked on them, and //! what finished. Live run state already persists to the ledger via the spawn diff --git a/src/openhuman/agent/orchestration/command_center/ops.rs b/src/openhuman/agent/orchestration/command_center/ops.rs index 199b43464c..4d6523d371 100644 --- a/src/openhuman/agent/orchestration/command_center/ops.rs +++ b/src/openhuman/agent/orchestration/command_center/ops.rs @@ -1,7 +1,7 @@ //! Read-only command-center projection over the durable run ledger. //! //! [`list_agent_work`] fetches recent background agent runs from -//! `session_db::run_ledger` and projects them into a [`CommandCenterView`] +//! `tinyagents::session::run_ledger` and projects them into a [`CommandCenterView`] //! grouped by normalized [`AgentWorkBucket`]. The projection is split so the //! pure grouping logic ([`build_view`]) is unit-testable without a database, //! while [`list_agent_work`] owns the one ledger read. @@ -9,10 +9,10 @@ use anyhow::Result; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; -use crate::openhuman::agent::session_db::run_ledger::{ +use crate::openhuman::config::Config; +use tinyagents::session::run_ledger::{ list_agent_runs, AgentRun, AgentRunListRequest, AgentRunStatus, }; -use crate::openhuman::config::Config; use super::types::{AgentWorkBucket, AgentWorkRow, CommandCenterGroup, CommandCenterView}; @@ -55,7 +55,7 @@ pub fn list_agent_work(config: &Config, limit: Option) -> Result AgentRun { AgentRun { id: id.to_string(), - kind: crate::openhuman::agent::session_db::run_ledger::AgentRunKind::Subagent, + kind: tinyagents::session::run_ledger::AgentRunKind::Subagent, parent_run_id: None, parent_thread_id: Some("thread-1".to_string()), agent_id: Some("researcher".to_string()), diff --git a/src/openhuman/agent/orchestration/command_center/types.rs b/src/openhuman/agent/orchestration/command_center/types.rs index e83cbe0697..a3ea72dc31 100644 --- a/src/openhuman/agent/orchestration/command_center/types.rs +++ b/src/openhuman/agent/orchestration/command_center/types.rs @@ -1,6 +1,6 @@ //! Command-center view types for the background agent surface (issue #3373). //! -//! The durable run ledger (`session_db::run_ledger`) stores fine-grained +//! The durable run ledger (`tinyagents::session::run_ledger`) stores fine-grained //! `AgentRunStatus` values for every background agent run. The background //! agent command center groups that work into five user-facing buckets so a //! reviewer can see, at a glance, what needs input, what is still working, and @@ -57,7 +57,7 @@ impl AgentWorkBucket { /// Kept deliberately lean — transcripts and checkpoints stay in the ledger / /// thread stores and are fetched on demand when a user opens a row. /// -/// [`AgentRun`]: crate::openhuman::agent::session_db::run_ledger::AgentRun +/// [`AgentRun`]: tinyagents::session::run_ledger::AgentRun #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentWorkRow { diff --git a/src/openhuman/agent/orchestration/run_ledger_finalize.rs b/src/openhuman/agent/orchestration/run_ledger_finalize.rs index 168c4f9d4c..a518e181a9 100644 --- a/src/openhuman/agent/orchestration/run_ledger_finalize.rs +++ b/src/openhuman/agent/orchestration/run_ledger_finalize.rs @@ -30,10 +30,8 @@ use std::sync::Arc; use async_trait::async_trait; use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler}; -use crate::openhuman::agent::session_db::run_ledger::{ - transition_agent_run_status, AgentRunStatus, -}; use crate::openhuman::config::Config; +use tinyagents::session::run_ledger::{transition_agent_run_status, AgentRunStatus}; const LOG_PREFIX: &str = "[run_ledger][finalize]"; @@ -75,8 +73,14 @@ impl EventHandler for RunLedgerFinalizeSubscriber { // EventHandler "must not block" contract. let config = self.config.clone(); let result = tokio::task::spawn_blocking(move || { - transition_agent_run_status(&config, &task_id, status, error.as_deref(), completed_at) - .map(|run| (task_id, run)) + transition_agent_run_status( + &config.workspace_dir, + &task_id, + status, + error.as_deref(), + completed_at, + ) + .map(|run| (task_id, run)) }) .await; diff --git a/src/openhuman/agent/orchestration/run_ledger_finalize_tests.rs b/src/openhuman/agent/orchestration/run_ledger_finalize_tests.rs index 88e4e274a1..f1c2279d9b 100644 --- a/src/openhuman/agent/orchestration/run_ledger_finalize_tests.rs +++ b/src/openhuman/agent/orchestration/run_ledger_finalize_tests.rs @@ -6,7 +6,7 @@ use serde_json::json; use tempfile::TempDir; use crate::core::event_bus::EventHandler; -use crate::openhuman::agent::session_db::run_ledger::{ +use tinyagents::session::run_ledger::{ get_agent_run, upsert_agent_run, AgentRunKind, AgentRunStatus, AgentRunUpsert, }; @@ -19,7 +19,7 @@ fn test_config(dir: &TempDir) -> Config { fn seed_running(config: &Config, id: &str) { upsert_agent_run( - config, + &config.workspace_dir, AgentRunUpsert { id: id.into(), kind: AgentRunKind::Subagent, @@ -62,7 +62,7 @@ async fn settles_running_run_on_subagent_completed() { }) .await; - let run = get_agent_run(&config, "sub-1") + let run = get_agent_run(&config.workspace_dir, "sub-1") .unwrap() .expect("run present"); assert_eq!(run.status, AgentRunStatus::Completed); @@ -86,7 +86,7 @@ async fn settles_running_run_on_subagent_failed_with_error() { }) .await; - let run = get_agent_run(&config, "sub-2") + let run = get_agent_run(&config.workspace_dir, "sub-2") .unwrap() .expect("run present"); assert_eq!(run.status, AgentRunStatus::Failed); @@ -110,7 +110,7 @@ async fn settles_running_run_on_subagent_awaiting_user() { }) .await; - let run = get_agent_run(&config, "sub-3") + let run = get_agent_run(&config.workspace_dir, "sub-3") .unwrap() .expect("run present"); assert_eq!(run.status, AgentRunStatus::AwaitingUser); @@ -136,5 +136,7 @@ async fn ignores_unrelated_events_and_missing_runs() { iterations: 1, }) .await; - assert!(get_agent_run(&config, "ghost").unwrap().is_none()); + assert!(get_agent_run(&config.workspace_dir, "ghost") + .unwrap() + .is_none()); } diff --git a/src/openhuman/agent/orchestration/workflow_runs/engine.rs b/src/openhuman/agent/orchestration/workflow_runs/engine.rs index 7d03286bda..34439a9583 100644 --- a/src/openhuman/agent/orchestration/workflow_runs/engine.rs +++ b/src/openhuman/agent/orchestration/workflow_runs/engine.rs @@ -50,10 +50,10 @@ use tinyagents::graph::parallel::{map_reduce, FailurePolicy, ParallelOptions}; use tinyagents::{CancellationToken, TinyAgentsError}; use crate::openhuman::agent::orchestration::parent_context::with_root_parent; -use crate::openhuman::agent::session_db::run_ledger::{ +use crate::openhuman::config::Config; +use tinyagents::session::run_ledger::{ get_workflow_run, upsert_workflow_run, WorkflowRun, WorkflowRunStatus, WorkflowRunUpsert, }; -use crate::openhuman::config::Config; use super::ops::definition_by_id; use super::types::{WorkflowDefinition, WorkflowPhase}; @@ -169,7 +169,7 @@ pub async fn start_workflow_run( let phase_states = init_phase_states(&definition); let run = upsert_workflow_run( - config, + &config.workspace_dir, WorkflowRunUpsert { id: run_id.clone(), definition_id: definition.id.clone(), @@ -221,7 +221,7 @@ pub async fn start_workflow_run( /// terminal or unknown run is a no-op that returns the current row. pub async fn stop_workflow_run(config: &Config, id: &str) -> Result> { log::debug!(target: LOG_TARGET, "[workflow_run_engine] stop.entry run={id}"); - let Some(run) = get_workflow_run(config, id)? else { + let Some(run) = get_workflow_run(&config.workspace_dir, id)? else { log::debug!(target: LOG_TARGET, "[workflow_run_engine] stop.unknown run={id}"); return Ok(None); }; @@ -250,7 +250,7 @@ pub async fn stop_workflow_run(config: &Config, id: &str) -> Result Result Result { log::debug!(target: LOG_TARGET, "[workflow_run_engine] resume.entry run={id}"); - let run = get_workflow_run(config, id)?.ok_or_else(|| anyhow!("unknown workflow run: {id}"))?; + let run = get_workflow_run(&config.workspace_dir, id)? + .ok_or_else(|| anyhow!("unknown workflow run: {id}"))?; if matches!(run.status, WorkflowRunStatus::Completed) { return Err(anyhow!("workflow run {id} is already completed")); @@ -294,7 +295,7 @@ pub async fn resume_workflow_run(config: &Config, id: &str) -> Result Result { // Reload so we read the latest phase_states (and a resume picks up persisted // progress). - let run = get_workflow_run(config, run_id)? + let run = get_workflow_run(&config.workspace_dir, run_id)? .ok_or_else(|| anyhow!("workflow run {run_id} vanished mid-loop"))?; let phase_states = run.phase_states.clone(); let child_run_ids = run.child_run_ids.clone(); @@ -494,7 +495,7 @@ pub(super) async fn execute_phase( }; // Reload so the phase state we mutate + persist is the latest projection. - let run = get_workflow_run(config, run_id)? + let run = get_workflow_run(&config.workspace_dir, run_id)? .ok_or_else(|| anyhow!("workflow run {run_id} vanished mid-phase"))?; let mut phase_states = run.phase_states.clone(); let mut child_run_ids = run.child_run_ids.clone(); @@ -984,7 +985,7 @@ fn persist( terminal: bool, ) -> Result { upsert_workflow_run( - config, + &config.workspace_dir, WorkflowRunUpsert { id: run.id.clone(), definition_id: run.definition_id.clone(), diff --git a/src/openhuman/agent/orchestration/workflow_runs/engine_tests.rs b/src/openhuman/agent/orchestration/workflow_runs/engine_tests.rs index 38197dd05f..389b39b6c1 100644 --- a/src/openhuman/agent/orchestration/workflow_runs/engine_tests.rs +++ b/src/openhuman/agent/orchestration/workflow_runs/engine_tests.rs @@ -27,13 +27,11 @@ use super::super::graph::drive_phases; use crate::openhuman::agent::context::prompt::ToolCallFormat; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::harness::fork_context::{with_parent_context, ParentExecutionContext}; -use crate::openhuman::agent::session_db::run_ledger::{ - get_workflow_run, upsert_workflow_run, WorkflowRunUpsert, -}; use crate::openhuman::config::{AgentConfig, Config}; use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}; use crate::openhuman::tools::{Tool, ToolSpec}; use tinyagents::harness::model::{ChatModel, ModelProfile, ModelRequest, ModelResponse}; +use tinyagents::session::run_ledger::{get_workflow_run, upsert_workflow_run, WorkflowRunUpsert}; use super::super::types::{WorkflowDefinition, WorkflowPhase, WorkflowSafetyTier}; @@ -212,7 +210,7 @@ fn test_config() -> (tempfile::TempDir, Config) { fn seed_run(config: &Config, definition: &WorkflowDefinition, input: Value) -> String { let id = format!("wfrun-test-{}", uuid::Uuid::new_v4()); upsert_workflow_run( - config, + &config.workspace_dir, WorkflowRunUpsert { id: id.clone(), definition_id: definition.id.clone(), @@ -264,11 +262,17 @@ fn linear_def(concurrency: u32, max_children: u32, parallel_in_b: usize) -> Work } fn status_of(config: &Config, id: &str) -> WorkflowRunStatus { - get_workflow_run(config, id).unwrap().unwrap().status + get_workflow_run(&config.workspace_dir, id) + .unwrap() + .unwrap() + .status } fn phase_states(config: &Config, id: &str) -> Value { - get_workflow_run(config, id).unwrap().unwrap().phase_states + get_workflow_run(&config.workspace_dir, id) + .unwrap() + .unwrap() + .phase_states } // ── Tests ─────────────────────────────────────────────────────────────────── @@ -309,7 +313,10 @@ async fn unit_phases_execute_in_dependency_order() { // Summary comes from the last phase's output (no phase literally named // 'synthesize', so the fallback picks phase c). - let summary = get_workflow_run(&config, &id).unwrap().unwrap().summary; + let summary = get_workflow_run(&config.workspace_dir, &id) + .unwrap() + .unwrap() + .summary; assert!( summary .as_deref() @@ -397,7 +404,10 @@ async fn unit_max_children_hard_cap_fails_run() { .expect("drive_phases returns Ok with terminal Failed state"); assert_eq!(status_of(&config, &id), WorkflowRunStatus::Failed); - let summary = get_workflow_run(&config, &id).unwrap().unwrap().summary; + let summary = get_workflow_run(&config.workspace_dir, &id) + .unwrap() + .unwrap() + .summary; assert!( summary .as_deref() @@ -502,13 +512,15 @@ async fn unit_resume_skips_completed_phases() { ); // Mark phase 'a' already completed (simulating a prior partial run). - let run = get_workflow_run(&config, &id).unwrap().unwrap(); + let run = get_workflow_run(&config.workspace_dir, &id) + .unwrap() + .unwrap(); let mut states = run.phase_states.clone(); states["a"]["status"] = json!("completed"); states["a"]["outputs"] = json!([{ "orchestrationId": "x", "agentId": "code_executor", "output": "A_DONE" }]); upsert_workflow_run( - &config, + &config.workspace_dir, WorkflowRunUpsert { id: id.clone(), definition_id: run.definition_id.clone(), diff --git a/src/openhuman/agent/orchestration/workflow_runs/graph.rs b/src/openhuman/agent/orchestration/workflow_runs/graph.rs index 909a5770be..6c7e3946d3 100644 --- a/src/openhuman/agent/orchestration/workflow_runs/graph.rs +++ b/src/openhuman/agent/orchestration/workflow_runs/graph.rs @@ -25,8 +25,8 @@ use tinyagents::graph::{ ClosureStateReducer, Command, CompiledGraph, GraphBuilder, NodeContext, NodeResult, }; -use crate::openhuman::agent::session_db::run_ledger::get_workflow_run; use crate::openhuman::config::Config; +use tinyagents::session::run_ledger::get_workflow_run; use super::engine::{execute_phase, select_next_phase, PhaseExecOutcome, PhaseSelection}; use super::types::{WorkflowDefinition, WorkflowPhase}; @@ -179,7 +179,7 @@ pub(super) async fn drive_phases( // provider. Production runs omit it (agents use their configured provider); // deterministic mock-backend tests set it so children resolve to the // injected mock provider. - let model_override = get_workflow_run(config, run_id)? + let model_override = get_workflow_run(&config.workspace_dir, run_id)? .and_then(|r| { r.input .get("modelOverride") diff --git a/src/openhuman/agent/orchestration/workflow_runs/mod.rs b/src/openhuman/agent/orchestration/workflow_runs/mod.rs index 827bbdd192..22974e5cb9 100644 --- a/src/openhuman/agent/orchestration/workflow_runs/mod.rs +++ b/src/openhuman/agent/orchestration/workflow_runs/mod.rs @@ -2,7 +2,7 @@ //! //! A first-class, repeatable multi-agent orchestration model: a declarative //! [`WorkflowDefinition`] (phase graph) coordinates many child agents, and each -//! run's durable state lives in `session_db::run_ledger` (the `workflow_runs` +//! run's durable state lives in `tinyagents::session::run_ledger` (the `workflow_runs` //! table) rather than the main chat context, so runs can be listed, inspected, //! and — once the engine lands — stopped and resumed. //! diff --git a/src/openhuman/agent/orchestration/workflow_runs/ops.rs b/src/openhuman/agent/orchestration/workflow_runs/ops.rs index 0ebc867ec1..ab2a0b1cf7 100644 --- a/src/openhuman/agent/orchestration/workflow_runs/ops.rs +++ b/src/openhuman/agent/orchestration/workflow_runs/ops.rs @@ -2,7 +2,7 @@ //! //! PR1 scope: expose the builtin [`WorkflowDefinition`]s, validate them //! (structure + agent existence), and read durable [`WorkflowRun`]s from -//! `session_db::run_ledger`. No execution engine yet — starting / stopping / +//! `tinyagents::session::run_ledger`. No execution engine yet — starting / stopping / //! resuming runs lands in a follow-up PR. use std::collections::{HashMap, HashSet, VecDeque}; @@ -10,11 +10,11 @@ use std::collections::{HashMap, HashSet, VecDeque}; use anyhow::Result; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; -use crate::openhuman::agent::session_db::run_ledger::{ +use crate::openhuman::config::Config; +use tinyagents::session::run_ledger::{ get_workflow_run, list_workflow_runs, WorkflowRun, WorkflowRunListRequest, WorkflowRunListResponse, }; -use crate::openhuman::config::Config; use super::types::{ DefinitionError, WorkflowDefinition, WorkflowDefinitionListResponse, WorkflowPhase, @@ -261,13 +261,13 @@ pub fn list_runs( request.definition_id, request.status ); - list_workflow_runs(config, request) + Ok(list_workflow_runs(&config.workspace_dir, request)?) } /// Get one durable workflow run by id (delegates to the run ledger). pub fn get_run(config: &Config, id: &str) -> Result> { log::debug!(target: "workflow_run", "[workflow_run] get_run.entry id={id}"); - get_workflow_run(config, id) + Ok(get_workflow_run(&config.workspace_dir, id)?) } #[cfg(test)] diff --git a/src/openhuman/agent/orchestration/workflow_runs/schemas.rs b/src/openhuman/agent/orchestration/workflow_runs/schemas.rs index 38f71215b1..56353f80f1 100644 --- a/src/openhuman/agent/orchestration/workflow_runs/schemas.rs +++ b/src/openhuman/agent/orchestration/workflow_runs/schemas.rs @@ -9,9 +9,9 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::openhuman::agent::session_db::run_ledger::WorkflowRunListRequest; use crate::openhuman::config::rpc as config_rpc; use crate::rpc::RpcOutcome; +use tinyagents::session::run_ledger::WorkflowRunListRequest; /// Controller schemas exposed by the workflow-runs module. pub fn all_controller_schemas() -> Vec { diff --git a/src/openhuman/agent/orchestration/workflow_runs/types.rs b/src/openhuman/agent/orchestration/workflow_runs/types.rs index 60c128ce78..b8bc7d93bc 100644 --- a/src/openhuman/agent/orchestration/workflow_runs/types.rs +++ b/src/openhuman/agent/orchestration/workflow_runs/types.rs @@ -8,7 +8,7 @@ //! execution. //! //! This PR ships the definition model + the read surface (list definitions, -//! list/get durable runs from `session_db::run_ledger`). The live execution +//! list/get durable runs from `tinyagents::session::run_ledger`). The live execution //! engine is deferred to a follow-up. use serde::Serialize; diff --git a/src/openhuman/agent/progress_tracing.rs b/src/openhuman/agent/progress_tracing.rs index 706536a8b3..88d3bb0f03 100644 --- a/src/openhuman/agent/progress_tracing.rs +++ b/src/openhuman/agent/progress_tracing.rs @@ -1458,7 +1458,7 @@ pub(crate) async fn export_run_trace_from_journal( config: &Config, trace_ctx: &TraceContext, observations: &[tinyagents::harness::observability::AgentObservation], - run_telemetry: Option<&crate::openhuman::agent::session_db::run_ledger::RunTelemetry>, + run_telemetry: Option<&tinyagents::session::run_ledger::RunTelemetry>, live_spans: &[TraceSpan], ) { if observations.is_empty() && live_spans.is_empty() { diff --git a/src/openhuman/agent/progress_tracing/langfuse.rs b/src/openhuman/agent/progress_tracing/langfuse.rs index 423cb3fd2f..15cba50e7f 100644 --- a/src/openhuman/agent/progress_tracing/langfuse.rs +++ b/src/openhuman/agent/progress_tracing/langfuse.rs @@ -26,9 +26,9 @@ use tinyagents::harness::observability::{AgentObservation, LangfuseClient, Langf use crate::api::config::effective_backend_api_url; use crate::api::jwt::bearer_authorization_value; -use crate::openhuman::agent::session_db::run_ledger::RunTelemetry; use crate::openhuman::config::Config; use crate::openhuman::security::credentials::session_support::require_live_session_token; +use tinyagents::session::run_ledger::RunTelemetry; use super::{SpanStatus, TraceContext, TraceSpan}; diff --git a/src/openhuman/agent/session_db/mod.rs b/src/openhuman/agent/session_db/mod.rs index 6281ad98b8..110c190e00 100644 --- a/src/openhuman/agent/session_db/mod.rs +++ b/src/openhuman/agent/session_db/mod.rs @@ -1,29 +1,25 @@ -//! Durable agent session database. +//! JSON-RPC surface for the durable agent session database. //! -//! SQLite-backed store (WAL + FTS5) for sessions, messages, tool calls, -//! cost metadata, and parent/child lineage. Complements the existing -//! `session_raw/*.jsonl` transcript files — those remain the source of -//! truth for KV-cache resume; this module provides queryable indexing, -//! cross-session search, and orchestration recovery. +//! The store itself — sessions, messages, tool calls, cost metadata, +//! parent/child lineage, and the run ledger — lives in +//! [`tinyagents::session`]. Only the controller schemas and +//! their handlers stay here, because the RPC envelope, config resolution, and +//! `RpcOutcome` shape are host concerns the runtime crate has no business +//! knowing about. //! -//! Database path: `{workspace}/session_db/sessions.db`. +//! Call the store directly (`tinyagents::session::…`) rather +//! than through this module; it deliberately re-exports no storage API. +//! +//! Every store entry point takes the workspace root, so handlers pass +//! `config.workspace_dir`. The database path is +//! `{workspace}/session_db/sessions.db` — unchanged by the move, so existing +//! installs keep their history. +//! +//! The `session_db` and `run_ledger` RPC namespaces are unchanged. -mod ops; -pub mod run_ledger; mod schemas; -mod store; -pub mod types; -pub use ops::{ - get_session, list_sessions, record_message, record_session_end, record_session_start, - record_tool_call, search_sessions, -}; pub use schemas::{ all_controller_schemas as all_session_db_controller_schemas, all_registered_controllers as all_session_db_registered_controllers, }; -pub use store::with_connection; -pub use types::{ - SessionMessage, SessionRecord, SessionSearchParams, SessionSearchResult, SessionStatus, - SessionToolCall, -}; diff --git a/src/openhuman/agent/session_db/ops.rs b/src/openhuman/agent/session_db/ops.rs deleted file mode 100644 index 956cba957d..0000000000 --- a/src/openhuman/agent/session_db/ops.rs +++ /dev/null @@ -1,598 +0,0 @@ -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use rusqlite::{params, Connection}; - -use crate::openhuman::config::Config; - -use super::store::with_connection; -use super::types::{ - SessionMessage, SessionRecord, SessionSearchParams, SessionSearchResult, SessionStatus, - SessionToolCall, -}; - -const MAX_TOOL_OUTPUT_BYTES: usize = 32 * 1024; - -pub fn record_session_start( - config: &Config, - id: &str, - agent_definition_id: &str, - agent_definition_name: &str, - session_key: &str, - parent_session_id: Option<&str>, - thread_id: Option<&str>, - source_channel: Option<&str>, - model: Option<&str>, - transcript_path: Option<&str>, -) -> Result { - let now = Utc::now(); - log::debug!( - "[session_db] record_session_start id={id} agent={agent_definition_id} \ - parent={} thread={} channel={}", - parent_session_id.unwrap_or("-"), - thread_id.unwrap_or("-"), - source_channel.unwrap_or("-"), - ); - - with_connection(config, |conn| { - conn.execute( - "INSERT INTO sessions ( - id, agent_definition_id, agent_definition_name, session_key, - parent_session_id, thread_id, source_channel, status, model, - transcript_path, started_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'running', ?8, ?9, ?10)", - params![ - id, - agent_definition_id, - agent_definition_name, - session_key, - parent_session_id, - thread_id, - source_channel, - model, - transcript_path, - now.to_rfc3339(), - ], - ) - .context("failed to insert session")?; - - index_fts_session(conn, id, agent_definition_name)?; - Ok(()) - })?; - - get_session(config, id) -} - -pub fn record_session_end( - config: &Config, - id: &str, - status: SessionStatus, - turn_count: u32, - input_tokens: u64, - output_tokens: u64, - cached_input_tokens: u64, - cost_usd: f64, -) -> Result { - let now = Utc::now(); - log::debug!( - "[session_db] record_session_end id={id} status={} turns={turn_count} \ - tokens_in={input_tokens} tokens_out={output_tokens} cost=${cost_usd:.6}", - status.as_str(), - ); - - with_connection(config, |conn| { - conn.execute( - "UPDATE sessions SET - status = ?1, turn_count = ?2, input_tokens = ?3, - output_tokens = ?4, cached_input_tokens = ?5, - cost_usd = ?6, ended_at = ?7 - WHERE id = ?8", - params![ - status.as_str(), - turn_count, - input_tokens as i64, - output_tokens as i64, - cached_input_tokens as i64, - cost_usd, - now.to_rfc3339(), - id, - ], - ) - .context("failed to update session end")?; - Ok(()) - })?; - - get_session(config, id) -} - -pub fn record_message( - config: &Config, - session_id: &str, - role: &str, - content: &str, - model: Option<&str>, - input_tokens: Option, - output_tokens: Option, - cost_usd: Option, -) -> Result { - let now = Utc::now(); - log::trace!( - "[session_db] record_message session={session_id} role={role} len={}", - content.len() - ); - - with_connection(config, |conn| { - conn.execute( - "INSERT INTO session_messages ( - session_id, role, content, model, - input_tokens, output_tokens, cost_usd, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - session_id, - role, - content, - model, - input_tokens.map(|v| v as i64), - output_tokens.map(|v| v as i64), - cost_usd, - now.to_rfc3339(), - ], - ) - .context("failed to insert session message")?; - - let msg_id = conn.last_insert_rowid(); - - index_fts_content(conn, session_id, content)?; - - Ok(msg_id) - }) -} - -pub fn record_tool_call( - config: &Config, - session_id: &str, - message_id: Option, - tool_name: &str, - tool_input: Option<&str>, - tool_output: Option<&str>, - status: &str, - duration_ms: Option, -) -> Result { - let now = Utc::now(); - log::trace!( - "[session_db] record_tool_call session={session_id} tool={tool_name} status={status}" - ); - - let bounded_output = tool_output.map(|o| { - if o.len() <= MAX_TOOL_OUTPUT_BYTES { - o.to_string() - } else { - let mut cutoff = MAX_TOOL_OUTPUT_BYTES; - while cutoff > 0 && !o.is_char_boundary(cutoff) { - cutoff -= 1; - } - let mut truncated = o[..cutoff].to_string(); - truncated.push_str("\n...[truncated]"); - truncated - } - }); - - with_connection(config, |conn| { - conn.execute( - "INSERT INTO session_tool_calls ( - session_id, message_id, tool_name, tool_input, - tool_output, status, duration_ms, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - session_id, - message_id, - tool_name, - tool_input, - bounded_output, - status, - duration_ms, - now.to_rfc3339(), - ], - ) - .context("failed to insert tool call")?; - - index_fts_tool(conn, session_id, tool_name)?; - - Ok(conn.last_insert_rowid()) - }) -} - -pub fn get_session(config: &Config, id: &str) -> Result { - with_connection(config, |conn| { - let mut stmt = conn.prepare( - "SELECT id, agent_definition_id, agent_definition_name, session_key, - parent_session_id, thread_id, source_channel, status, model, - turn_count, input_tokens, output_tokens, cached_input_tokens, - cost_usd, transcript_path, started_at, ended_at - FROM sessions WHERE id = ?1", - )?; - - let mut rows = stmt.query(params![id])?; - if let Some(row) = rows.next()? { - map_session_row(row).map_err(Into::into) - } else { - anyhow::bail!("session '{id}' not found") - } - }) -} - -pub fn list_sessions( - config: &Config, - limit: Option, - offset: Option, - status: Option<&str>, - parent_id: Option<&str>, -) -> Result { - log::debug!( - "[session_db] list_sessions limit={} offset={} status={} parent={}", - limit.unwrap_or(50), - offset.unwrap_or(0), - status.unwrap_or("-"), - parent_id.unwrap_or("-"), - ); - - with_connection(config, |conn| { - let mut where_clauses: Vec = Vec::new(); - let mut param_values: Vec> = Vec::new(); - - if let Some(s) = status { - param_values.push(Box::new(s.to_string())); - where_clauses.push(format!("status = ?{}", param_values.len())); - } - if let Some(p) = parent_id { - param_values.push(Box::new(p.to_string())); - where_clauses.push(format!("parent_session_id = ?{}", param_values.len())); - } - - let where_sql = if where_clauses.is_empty() { - String::new() - } else { - format!("WHERE {}", where_clauses.join(" AND ")) - }; - - let lim = limit.unwrap_or(50).min(500) as i64; - let off = offset.unwrap_or(0) as i64; - - let count_sql = format!("SELECT COUNT(*) FROM sessions {where_sql}"); - let total: u64 = { - let mut stmt = conn.prepare(&count_sql)?; - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|b| b.as_ref()).collect(); - stmt.query_row(params_ref.as_slice(), |r| r.get::<_, i64>(0))? as u64 - }; - - param_values.push(Box::new(lim)); - let lim_idx = param_values.len(); - param_values.push(Box::new(off)); - let off_idx = param_values.len(); - - let query_sql = format!( - "SELECT id, agent_definition_id, agent_definition_name, session_key, - parent_session_id, thread_id, source_channel, status, model, - turn_count, input_tokens, output_tokens, cached_input_tokens, - cost_usd, transcript_path, started_at, ended_at - FROM sessions {where_sql} - ORDER BY started_at DESC - LIMIT ?{lim_idx} OFFSET ?{off_idx}", - ); - - let mut stmt = conn.prepare(&query_sql)?; - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|b| b.as_ref()).collect(); - let rows = stmt.query_map(params_ref.as_slice(), map_session_row)?; - - let mut sessions = Vec::new(); - for row in rows { - sessions.push(row?); - } - - Ok(SessionSearchResult { sessions, total }) - }) -} - -pub fn search_sessions( - config: &Config, - params: &SessionSearchParams, -) -> Result { - log::debug!( - "[session_db] search_sessions query={} agent={} tool={} channel={} thread={}", - params.query.as_deref().unwrap_or("-"), - params.agent_id.as_deref().unwrap_or("-"), - params.tool_name.as_deref().unwrap_or("-"), - params.source_channel.as_deref().unwrap_or("-"), - params.thread_id.as_deref().unwrap_or("-"), - ); - - with_connection(config, |conn| search_sessions_inner(conn, params)) -} - -fn search_sessions_inner( - conn: &Connection, - params: &SessionSearchParams, -) -> Result { - let lim = params.limit.unwrap_or(50).min(500) as i64; - let off = params.offset.unwrap_or(0) as i64; - - let mut where_clauses: Vec = Vec::new(); - let mut param_values: Vec> = Vec::new(); - - if let Some(ref q) = params.query { - if !q.trim().is_empty() { - param_values.push(Box::new(q.clone())); - where_clauses.push(format!( - "s.id IN (SELECT session_id FROM sessions_fts WHERE sessions_fts MATCH ?{})", - param_values.len() - )); - } - } - - if let Some(ref agent) = params.agent_id { - param_values.push(Box::new(agent.clone())); - where_clauses.push(format!("s.agent_definition_id = ?{}", param_values.len())); - } - - if let Some(ref tool) = params.tool_name { - param_values.push(Box::new(tool.clone())); - where_clauses.push(format!( - "s.id IN (SELECT DISTINCT session_id FROM session_tool_calls WHERE tool_name = ?{})", - param_values.len() - )); - } - - if let Some(ref channel) = params.source_channel { - param_values.push(Box::new(channel.clone())); - where_clauses.push(format!("s.source_channel = ?{}", param_values.len())); - } - - if let Some(ref parent) = params.parent_session_id { - param_values.push(Box::new(parent.clone())); - where_clauses.push(format!("s.parent_session_id = ?{}", param_values.len())); - } - - if let Some(ref status) = params.status { - param_values.push(Box::new(status.clone())); - where_clauses.push(format!("s.status = ?{}", param_values.len())); - } - - if let Some(ref tid) = params.thread_id { - param_values.push(Box::new(tid.clone())); - where_clauses.push(format!("s.thread_id = ?{}", param_values.len())); - } - - let where_sql = if where_clauses.is_empty() { - String::new() - } else { - format!("WHERE {}", where_clauses.join(" AND ")) - }; - - let count_sql = format!("SELECT COUNT(*) FROM sessions s {where_sql}"); - let total: u64 = { - let mut stmt = conn.prepare(&count_sql)?; - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|b| b.as_ref()).collect(); - stmt.query_row(params_ref.as_slice(), |r| r.get::<_, i64>(0))? as u64 - }; - - param_values.push(Box::new(lim)); - let lim_idx = param_values.len(); - param_values.push(Box::new(off)); - let off_idx = param_values.len(); - - let query = format!( - "SELECT s.id, s.agent_definition_id, s.agent_definition_name, s.session_key, - s.parent_session_id, s.thread_id, s.source_channel, s.status, s.model, - s.turn_count, s.input_tokens, s.output_tokens, s.cached_input_tokens, - s.cost_usd, s.transcript_path, s.started_at, s.ended_at - FROM sessions s {where_sql} - ORDER BY s.started_at DESC - LIMIT ?{lim_idx} OFFSET ?{off_idx}", - ); - - let mut stmt = conn.prepare(&query)?; - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - param_values.iter().map(|b| b.as_ref()).collect(); - let rows = stmt.query_map(params_ref.as_slice(), map_session_row)?; - - let mut sessions = Vec::new(); - for row in rows { - sessions.push(row?); - } - - Ok(SessionSearchResult { sessions, total }) -} - -pub fn list_messages( - config: &Config, - session_id: &str, - limit: Option, -) -> Result> { - with_connection(config, |conn| { - let lim = limit.unwrap_or(200).min(1000) as i64; - let mut stmt = conn.prepare( - "SELECT id, session_id, role, content, model, - input_tokens, output_tokens, cost_usd, created_at - FROM session_messages - WHERE session_id = ?1 - ORDER BY id ASC - LIMIT ?2", - )?; - - let rows = stmt.query_map(params![session_id, lim], |row| { - Ok(SessionMessage { - id: row.get(0)?, - session_id: row.get(1)?, - role: row.get(2)?, - content: row.get(3)?, - model: row.get(4)?, - input_tokens: row.get::<_, Option>(5)?.map(|v| v as u64), - output_tokens: row.get::<_, Option>(6)?.map(|v| v as u64), - cost_usd: row.get(7)?, - created_at: parse_rfc3339(&row.get::<_, String>(8)?) - .map_err(sql_conversion_error)?, - }) - })?; - - let mut messages = Vec::new(); - for row in rows { - messages.push(row?); - } - Ok(messages) - }) -} - -pub fn list_tool_calls( - config: &Config, - session_id: &str, - limit: Option, -) -> Result> { - with_connection(config, |conn| { - let lim = limit.unwrap_or(200).min(1000) as i64; - let mut stmt = conn.prepare( - "SELECT id, session_id, message_id, tool_name, tool_input, - tool_output, status, duration_ms, created_at - FROM session_tool_calls - WHERE session_id = ?1 - ORDER BY id ASC - LIMIT ?2", - )?; - - let rows = stmt.query_map(params![session_id, lim], |row| { - Ok(SessionToolCall { - id: row.get(0)?, - session_id: row.get(1)?, - message_id: row.get(2)?, - tool_name: row.get(3)?, - tool_input: row.get(4)?, - tool_output: row.get(5)?, - status: row.get(6)?, - duration_ms: row.get(7)?, - created_at: parse_rfc3339(&row.get::<_, String>(8)?) - .map_err(sql_conversion_error)?, - }) - })?; - - let mut tool_calls = Vec::new(); - for row in rows { - tool_calls.push(row?); - } - Ok(tool_calls) - }) -} - -pub fn list_children(config: &Config, session_id: &str) -> Result> { - with_connection(config, |conn| { - let mut stmt = conn.prepare( - "SELECT id, agent_definition_id, agent_definition_name, session_key, - parent_session_id, thread_id, source_channel, status, model, - turn_count, input_tokens, output_tokens, cached_input_tokens, - cost_usd, transcript_path, started_at, ended_at - FROM sessions - WHERE parent_session_id = ?1 - ORDER BY started_at ASC", - )?; - - let rows = stmt.query_map(params![session_id], map_session_row)?; - let mut children = Vec::new(); - for row in rows { - children.push(row?); - } - Ok(children) - }) -} - -pub fn mark_interrupted(config: &Config) -> Result { - log::debug!("[session_db] mark_interrupted — marking all running sessions as interrupted"); - with_connection(config, |conn| { - let now = Utc::now(); - let changed = conn.execute( - "UPDATE sessions SET status = 'interrupted', ended_at = ?1 - WHERE status = 'running'", - params![now.to_rfc3339()], - )?; - if changed > 0 { - log::info!("[session_db] marked {changed} running session(s) as interrupted"); - } - Ok(changed) - }) -} - -fn index_fts_session(conn: &Connection, session_id: &str, agent_name: &str) -> Result<()> { - conn.execute( - "INSERT INTO sessions_fts (session_id, agent_definition_name, content, tool_name) - VALUES (?1, ?2, '', '')", - params![session_id, agent_name], - ) - .context("failed to index session in FTS")?; - Ok(()) -} - -fn index_fts_content(conn: &Connection, session_id: &str, content: &str) -> Result<()> { - let snippet = if content.len() > 2000 { - &content[..2000] - } else { - content - }; - conn.execute( - "INSERT INTO sessions_fts (session_id, agent_definition_name, content, tool_name) - VALUES (?1, '', ?2, '')", - params![session_id, snippet], - ) - .context("failed to index content in FTS")?; - Ok(()) -} - -fn index_fts_tool(conn: &Connection, session_id: &str, tool_name: &str) -> Result<()> { - conn.execute( - "INSERT INTO sessions_fts (session_id, agent_definition_name, content, tool_name) - VALUES (?1, '', '', ?2)", - params![session_id, tool_name], - ) - .context("failed to index tool call in FTS")?; - Ok(()) -} - -fn map_session_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let started_at_raw: String = row.get(15)?; - let ended_at_raw: Option = row.get(16)?; - - Ok(SessionRecord { - id: row.get(0)?, - agent_definition_id: row.get(1)?, - agent_definition_name: row.get(2)?, - session_key: row.get(3)?, - parent_session_id: row.get(4)?, - thread_id: row.get(5)?, - source_channel: row.get(6)?, - status: SessionStatus::parse(&row.get::<_, String>(7)?), - model: row.get(8)?, - turn_count: row.get::<_, i64>(9)? as u32, - input_tokens: row.get::<_, i64>(10)? as u64, - output_tokens: row.get::<_, i64>(11)? as u64, - cached_input_tokens: row.get::<_, i64>(12)? as u64, - cost_usd: row.get(13)?, - transcript_path: row.get(14)?, - started_at: parse_rfc3339(&started_at_raw).map_err(sql_conversion_error)?, - ended_at: match ended_at_raw { - Some(raw) => Some(parse_rfc3339(&raw).map_err(sql_conversion_error)?), - None => None, - }, - }) -} - -fn parse_rfc3339(raw: &str) -> Result> { - let parsed = DateTime::parse_from_rfc3339(raw) - .with_context(|| format!("invalid RFC3339 timestamp in session DB: {raw}"))?; - Ok(parsed.with_timezone(&Utc)) -} - -fn sql_conversion_error(err: anyhow::Error) -> rusqlite::Error { - rusqlite::Error::ToSqlConversionFailure(err.into()) -} - -#[cfg(test)] -#[path = "ops_tests.rs"] -mod tests; diff --git a/src/openhuman/agent/session_db/ops_tests.rs b/src/openhuman/agent/session_db/ops_tests.rs deleted file mode 100644 index 02389e40c1..0000000000 --- a/src/openhuman/agent/session_db/ops_tests.rs +++ /dev/null @@ -1,349 +0,0 @@ -use super::*; -use crate::openhuman::agent::session_db::store::with_memory_connection; -use crate::openhuman::agent::session_db::types::SessionSearchParams; - -fn insert_test_session(conn: &Connection, id: &str, agent_id: &str, key: &str) { - let now = Utc::now(); - conn.execute( - "INSERT INTO sessions ( - id, agent_definition_id, agent_definition_name, session_key, - status, started_at - ) VALUES (?1, ?2, ?3, ?4, 'running', ?5)", - params![id, agent_id, agent_id, key, now.to_rfc3339()], - ) - .unwrap(); - index_fts_session(conn, id, agent_id).unwrap(); -} - -fn insert_test_session_with_parent( - conn: &Connection, - id: &str, - agent_id: &str, - key: &str, - parent_id: &str, -) { - let now = Utc::now(); - conn.execute( - "INSERT INTO sessions ( - id, agent_definition_id, agent_definition_name, session_key, - parent_session_id, status, started_at - ) VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6)", - params![id, agent_id, agent_id, key, parent_id, now.to_rfc3339()], - ) - .unwrap(); - index_fts_session(conn, id, agent_id).unwrap(); -} - -#[test] -fn map_session_row_roundtrip() { - with_memory_connection(|conn| { - insert_test_session(conn, "sess-1", "orchestrator", "1700000000_orchestrator"); - - let mut stmt = conn.prepare( - "SELECT id, agent_definition_id, agent_definition_name, session_key, - parent_session_id, thread_id, source_channel, status, model, - turn_count, input_tokens, output_tokens, cached_input_tokens, - cost_usd, transcript_path, started_at, ended_at - FROM sessions WHERE id = 'sess-1'", - )?; - let session = stmt.query_row([], map_session_row)?; - - assert_eq!(session.id, "sess-1"); - assert_eq!(session.agent_definition_id, "orchestrator"); - assert_eq!(session.session_key, "1700000000_orchestrator"); - assert_eq!(session.status, SessionStatus::Running); - assert!(session.parent_session_id.is_none()); - assert!(session.ended_at.is_none()); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn search_by_agent_id() { - with_memory_connection(|conn| { - insert_test_session(conn, "a1", "orchestrator", "key1"); - insert_test_session(conn, "a2", "researcher", "key2"); - insert_test_session(conn, "a3", "orchestrator", "key3"); - - let params = SessionSearchParams { - agent_id: Some("orchestrator".to_string()), - ..Default::default() - }; - - let result = search_sessions_inner(conn, ¶ms)?; - assert_eq!(result.total, 2); - assert_eq!(result.sessions.len(), 2); - assert!(result - .sessions - .iter() - .all(|s| s.agent_definition_id == "orchestrator")); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn search_by_fts_query() { - with_memory_connection(|conn| { - insert_test_session(conn, "b1", "orchestrator", "key1"); - insert_test_session(conn, "b2", "researcher", "key2"); - - conn.execute( - "INSERT INTO session_messages (session_id, role, content, created_at) - VALUES ('b1', 'user', 'Fix the login bug in authentication', ?1)", - params![Utc::now().to_rfc3339()], - )?; - index_fts_content(conn, "b1", "Fix the login bug in authentication")?; - - conn.execute( - "INSERT INTO session_messages (session_id, role, content, created_at) - VALUES ('b2', 'user', 'Deploy the new feature to production', ?1)", - params![Utc::now().to_rfc3339()], - )?; - index_fts_content(conn, "b2", "Deploy the new feature to production")?; - - let params = SessionSearchParams { - query: Some("login".to_string()), - ..Default::default() - }; - - let result = search_sessions_inner(conn, ¶ms)?; - assert_eq!(result.total, 1); - assert_eq!(result.sessions[0].id, "b1"); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn search_by_tool_name() { - with_memory_connection(|conn| { - insert_test_session(conn, "c1", "orchestrator", "key1"); - insert_test_session(conn, "c2", "researcher", "key2"); - - conn.execute( - "INSERT INTO session_tool_calls (session_id, tool_name, status, created_at) - VALUES ('c1', 'shell', 'ok', ?1)", - params![Utc::now().to_rfc3339()], - )?; - conn.execute( - "INSERT INTO session_tool_calls (session_id, tool_name, status, created_at) - VALUES ('c2', 'file_read', 'ok', ?1)", - params![Utc::now().to_rfc3339()], - )?; - - let params = SessionSearchParams { - tool_name: Some("shell".to_string()), - ..Default::default() - }; - - let result = search_sessions_inner(conn, ¶ms)?; - assert_eq!(result.total, 1); - assert_eq!(result.sessions[0].id, "c1"); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn search_by_parent_session() { - with_memory_connection(|conn| { - insert_test_session(conn, "parent-1", "orchestrator", "key1"); - insert_test_session_with_parent(conn, "child-1", "researcher", "key2", "parent-1"); - insert_test_session_with_parent(conn, "child-2", "coder", "key3", "parent-1"); - insert_test_session(conn, "unrelated", "other", "key4"); - - let params = SessionSearchParams { - parent_session_id: Some("parent-1".to_string()), - ..Default::default() - }; - - let result = search_sessions_inner(conn, ¶ms)?; - assert_eq!(result.total, 2); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn search_pagination() { - with_memory_connection(|conn| { - for i in 0..10 { - insert_test_session(conn, &format!("p{i}"), "agent", &format!("key{i}")); - } - - let params = SessionSearchParams { - limit: Some(3), - offset: Some(0), - ..Default::default() - }; - let result = search_sessions_inner(conn, ¶ms)?; - assert_eq!(result.total, 10); - assert_eq!(result.sessions.len(), 3); - - let params2 = SessionSearchParams { - limit: Some(3), - offset: Some(3), - ..Default::default() - }; - let result2 = search_sessions_inner(conn, ¶ms2)?; - assert_eq!(result2.total, 10); - assert_eq!(result2.sessions.len(), 3); - assert_ne!(result.sessions[0].id, result2.sessions[0].id); - - Ok(()) - }) - .unwrap(); -} - -#[test] -fn search_empty_results() { - with_memory_connection(|conn| { - let params = SessionSearchParams { - agent_id: Some("nonexistent".to_string()), - ..Default::default() - }; - let result = search_sessions_inner(conn, ¶ms)?; - assert_eq!(result.total, 0); - assert!(result.sessions.is_empty()); - Ok(()) - }) - .unwrap(); -} - -#[test] -fn tool_output_truncation() { - with_memory_connection(|conn| { - let session_id = "trunc-sess"; - insert_test_session(conn, session_id, "agent", "key"); - - let large_output = "x".repeat(MAX_TOOL_OUTPUT_BYTES + 1000); - let bounded = if large_output.len() <= MAX_TOOL_OUTPUT_BYTES { - large_output.clone() - } else { - let mut cutoff = MAX_TOOL_OUTPUT_BYTES; - while cutoff > 0 && !large_output.is_char_boundary(cutoff) { - cutoff -= 1; - } - let mut truncated = large_output[..cutoff].to_string(); - truncated.push_str("\n...[truncated]"); - truncated - }; - - conn.execute( - "INSERT INTO session_tool_calls (session_id, tool_name, tool_output, status, created_at) - VALUES (?1, 'test', ?2, 'ok', ?3)", - params![session_id, bounded, Utc::now().to_rfc3339()], - )?; - - let stored: String = conn.query_row( - "SELECT tool_output FROM session_tool_calls WHERE session_id = ?1", - params![session_id], - |r| r.get(0), - )?; - assert!(stored.len() <= MAX_TOOL_OUTPUT_BYTES + 20); - assert!(stored.ends_with("[truncated]")); - - Ok(()) - }) - .unwrap(); -} - -#[test] -fn mark_interrupted_updates_running() { - with_memory_connection(|conn| { - insert_test_session(conn, "run1", "agent", "key1"); - insert_test_session(conn, "run2", "agent", "key2"); - conn.execute( - "UPDATE sessions SET status = 'completed' WHERE id = 'run2'", - [], - )?; - - let now = Utc::now(); - let changed = conn.execute( - "UPDATE sessions SET status = 'interrupted', ended_at = ?1 - WHERE status = 'running'", - params![now.to_rfc3339()], - )?; - assert_eq!(changed, 1); - - let status: String = - conn.query_row("SELECT status FROM sessions WHERE id = 'run1'", [], |r| { - r.get(0) - })?; - assert_eq!(status, "interrupted"); - - let status2: String = - conn.query_row("SELECT status FROM sessions WHERE id = 'run2'", [], |r| { - r.get(0) - })?; - assert_eq!(status2, "completed"); - - Ok(()) - }) - .unwrap(); -} - -#[test] -fn session_end_updates_cost_fields() { - with_memory_connection(|conn| { - insert_test_session(conn, "cost-sess", "agent", "key"); - - let now = Utc::now(); - conn.execute( - "UPDATE sessions SET - status = 'completed', turn_count = 5, input_tokens = 10000, - output_tokens = 2000, cached_input_tokens = 8000, - cost_usd = 0.0345, ended_at = ?1 - WHERE id = 'cost-sess'", - params![now.to_rfc3339()], - )?; - - let mut stmt = conn.prepare( - "SELECT id, agent_definition_id, agent_definition_name, session_key, - parent_session_id, thread_id, source_channel, status, model, - turn_count, input_tokens, output_tokens, cached_input_tokens, - cost_usd, transcript_path, started_at, ended_at - FROM sessions WHERE id = 'cost-sess'", - )?; - let session = stmt.query_row([], map_session_row)?; - - assert_eq!(session.status, SessionStatus::Completed); - assert_eq!(session.turn_count, 5); - assert_eq!(session.input_tokens, 10000); - assert_eq!(session.output_tokens, 2000); - assert_eq!(session.cached_input_tokens, 8000); - assert!((session.cost_usd - 0.0345).abs() < f64::EPSILON); - assert!(session.ended_at.is_some()); - - Ok(()) - }) - .unwrap(); -} - -#[test] -fn combined_filters() { - with_memory_connection(|conn| { - insert_test_session(conn, "cf1", "orchestrator", "key1"); - insert_test_session(conn, "cf2", "orchestrator", "key2"); - insert_test_session(conn, "cf3", "researcher", "key3"); - - conn.execute( - "UPDATE sessions SET status = 'completed' WHERE id = 'cf1'", - [], - )?; - - let params = SessionSearchParams { - agent_id: Some("orchestrator".to_string()), - status: Some("completed".to_string()), - ..Default::default() - }; - - let result = search_sessions_inner(conn, ¶ms)?; - assert_eq!(result.total, 1); - assert_eq!(result.sessions[0].id, "cf1"); - Ok(()) - }) - .unwrap(); -} diff --git a/src/openhuman/agent/session_db/run_ledger/mod.rs b/src/openhuman/agent/session_db/run_ledger/mod.rs deleted file mode 100644 index 310bbc36d5..0000000000 --- a/src/openhuman/agent/session_db/run_ledger/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Durable run ledger for agent and workflow execution state. -//! -//! This submodule extends `session_db` with a queryable, restart-survivable -//! ledger for background agent/workflow runs. Conversation transcripts remain -//! in the thread/session stores; this ledger stores compact run metadata, -//! child lineage, events, telemetry, and checkpoint references. - -pub mod ops; -pub mod store; -pub mod types; - -pub use ops::{ - append_run_event, claim_agent_team_task, complete_agent_team_task, get_agent_run, - get_agent_team, get_agent_team_member, get_agent_team_task, get_workflow_run, - interrupt_orphaned_agent_runs, list_agent_runs, list_agent_team_members, list_agent_team_tasks, - list_agent_teams, list_recent_run_events, list_workflow_runs, mark_agent_team_member_idle, - mark_agent_team_member_running, release_agent_team_task, shutdown_agent_team_member, - transition_agent_run_status, upsert_agent_run, upsert_agent_team, upsert_agent_team_member, - upsert_agent_team_task, upsert_run_telemetry, upsert_workflow_run, -}; -pub use types::{ - AgentRun, AgentRunKind, AgentRunListRequest, AgentRunListResponse, AgentRunStatus, - AgentRunUpsert, AgentTeam, AgentTeamListRequest, AgentTeamListResponse, AgentTeamMember, - AgentTeamMemberStatus, AgentTeamMemberUpsert, AgentTeamStatus, AgentTeamTask, - AgentTeamTaskStatus, AgentTeamTaskUpsert, AgentTeamUpsert, ClaimOutcome, CompletionOutcome, - RunEvent, RunEventAppend, RunEventListRequest, RunEventListResponse, RunTelemetry, - RunTelemetryUpsert, WorkflowRun, WorkflowRunListRequest, WorkflowRunListResponse, - WorkflowRunStatus, WorkflowRunUpsert, -}; diff --git a/src/openhuman/agent/session_db/run_ledger/ops.rs b/src/openhuman/agent/session_db/run_ledger/ops.rs deleted file mode 100644 index 3c72cf8e8b..0000000000 --- a/src/openhuman/agent/session_db/run_ledger/ops.rs +++ /dev/null @@ -1,1915 +0,0 @@ -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use rusqlite::{params, Connection, OptionalExtension}; -use serde_json::{json, Value}; - -use crate::openhuman::config::Config; - -use super::store::init_run_ledger_schema; -use super::types::{ - AgentRun, AgentRunListRequest, AgentRunListResponse, AgentRunStatus, AgentRunUpsert, AgentTeam, - AgentTeamListRequest, AgentTeamListResponse, AgentTeamMember, AgentTeamMemberStatus, - AgentTeamMemberUpsert, AgentTeamStatus, AgentTeamTask, AgentTeamTaskStatus, - AgentTeamTaskUpsert, AgentTeamUpsert, ClaimOutcome, CompletionOutcome, RunEvent, - RunEventAppend, RunEventListRequest, RunEventListResponse, RunTelemetry, RunTelemetryUpsert, - WorkflowRun, WorkflowRunListRequest, WorkflowRunListResponse, WorkflowRunUpsert, -}; - -const LOG_PREFIX: &str = "[session_db:run_ledger]"; - -pub fn upsert_agent_run(config: &Config, upsert: AgentRunUpsert) -> Result { - let now = Utc::now(); - let started_at = upsert.started_at.unwrap_or(now); - let updated_at = now; - let metadata_json = - serde_json::to_string(&upsert.metadata).context("serialize agent run metadata")?; - let checkpoint_json = upsert - .checkpoint - .as_ref() - .map(serde_json::to_string) - .transpose() - .context("serialize agent run checkpoint")?; - - log::debug!( - "{LOG_PREFIX} upsert_agent_run id={} kind={} status={} parent={} thread={}", - upsert.id, - upsert.kind.as_str(), - upsert.status.as_str(), - upsert.parent_run_id.as_deref().unwrap_or("-"), - upsert.parent_thread_id.as_deref().unwrap_or("-") - ); - - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - conn.execute( - "INSERT INTO agent_runs ( - id, kind, parent_run_id, parent_thread_id, agent_id, status, - prompt_ref, worker_thread_id, task_board_id, task_card_id, - checkpoint_path, checkpoint_json, summary, error, metadata_json, - started_at, updated_at, completed_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) - ON CONFLICT(id) DO UPDATE SET - kind = CASE - WHEN agent_runs.kind = 'worker_thread' AND excluded.kind = 'subagent' THEN agent_runs.kind - ELSE excluded.kind - END, - parent_run_id = COALESCE(excluded.parent_run_id, agent_runs.parent_run_id), - parent_thread_id = COALESCE(excluded.parent_thread_id, agent_runs.parent_thread_id), - agent_id = COALESCE(excluded.agent_id, agent_runs.agent_id), - status = excluded.status, - prompt_ref = COALESCE(excluded.prompt_ref, agent_runs.prompt_ref), - worker_thread_id = COALESCE(excluded.worker_thread_id, agent_runs.worker_thread_id), - task_board_id = COALESCE(excluded.task_board_id, agent_runs.task_board_id), - task_card_id = COALESCE(excluded.task_card_id, agent_runs.task_card_id), - checkpoint_path = COALESCE(excluded.checkpoint_path, agent_runs.checkpoint_path), - checkpoint_json = COALESCE(excluded.checkpoint_json, agent_runs.checkpoint_json), - summary = COALESCE(excluded.summary, agent_runs.summary), - error = COALESCE(excluded.error, agent_runs.error), - metadata_json = CASE - WHEN excluded.metadata_json = '{}' THEN agent_runs.metadata_json - ELSE excluded.metadata_json - END, - updated_at = excluded.updated_at, - completed_at = COALESCE(excluded.completed_at, agent_runs.completed_at)", - params![ - upsert.id, - upsert.kind.as_str(), - upsert.parent_run_id, - upsert.parent_thread_id, - upsert.agent_id, - upsert.status.as_str(), - upsert.prompt_ref, - upsert.worker_thread_id, - upsert.task_board_id, - upsert.task_card_id, - upsert.checkpoint_path, - checkpoint_json, - upsert.summary, - upsert.error, - metadata_json, - started_at.to_rfc3339(), - updated_at.to_rfc3339(), - upsert.completed_at.map(|dt| dt.to_rfc3339()), - ], - ) - .context("upsert agent run")?; - Ok(()) - })?; - - get_agent_run(config, &upsert.id)?.context("agent run missing after upsert") -} - -pub fn upsert_workflow_run(config: &Config, upsert: WorkflowRunUpsert) -> Result { - let now = Utc::now(); - let started_at = upsert.started_at.unwrap_or(now); - let input_json = serde_json::to_string(&upsert.input).context("serialize workflow input")?; - let phase_states_json = - serde_json::to_string(&upsert.phase_states).context("serialize workflow phase states")?; - let child_run_ids_json = - serde_json::to_string(&upsert.child_run_ids).context("serialize child run ids")?; - - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - conn.execute( - "INSERT INTO workflow_runs ( - id, definition_id, parent_thread_id, input_json, phase_states_json, - child_run_ids_json, status, summary, started_at, updated_at, completed_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - ON CONFLICT(id) DO UPDATE SET - definition_id = excluded.definition_id, - parent_thread_id = COALESCE(excluded.parent_thread_id, workflow_runs.parent_thread_id), - input_json = excluded.input_json, - phase_states_json = excluded.phase_states_json, - child_run_ids_json = excluded.child_run_ids_json, - status = excluded.status, - summary = COALESCE(excluded.summary, workflow_runs.summary), - updated_at = excluded.updated_at, - completed_at = COALESCE(excluded.completed_at, workflow_runs.completed_at)", - params![ - upsert.id, - upsert.definition_id, - upsert.parent_thread_id, - input_json, - phase_states_json, - child_run_ids_json, - upsert.status.as_str(), - upsert.summary, - started_at.to_rfc3339(), - now.to_rfc3339(), - upsert.completed_at.map(|dt| dt.to_rfc3339()), - ], - ) - .context("upsert workflow run")?; - Ok(()) - })?; - - get_workflow_run(config, &upsert.id)?.context("workflow run missing after upsert") -} - -pub fn append_run_event(config: &Config, event: RunEventAppend) -> Result { - let now = Utc::now(); - let payload_json = serde_json::to_string(&event.payload).context("serialize run event")?; - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let next_sequence: i64 = conn.query_row( - "SELECT COALESCE(MAX(sequence), 0) + 1 FROM run_events WHERE run_id = ?1", - params![event.run_id], - |row| row.get(0), - )?; - conn.execute( - "INSERT INTO run_events (run_id, sequence, event_type, payload_json, timestamp) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - event.run_id, - next_sequence, - event.event_type, - payload_json, - now.to_rfc3339(), - ], - ) - .context("append run event")?; - Ok(RunEvent { - run_id: event.run_id, - sequence: next_sequence as u64, - event_type: event.event_type, - payload: serde_json::from_str(&payload_json).unwrap_or_else(|_| json!({})), - timestamp: now, - }) - }) -} - -pub fn upsert_run_telemetry(config: &Config, upsert: RunTelemetryUpsert) -> Result { - let now = Utc::now(); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - conn.execute( - "INSERT INTO run_telemetry ( - run_id, input_tokens, output_tokens, cached_input_tokens, cost_usd, - elapsed_ms, tool_count, model, provider, error, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - ON CONFLICT(run_id) DO UPDATE SET - input_tokens = COALESCE(excluded.input_tokens, run_telemetry.input_tokens), - output_tokens = COALESCE(excluded.output_tokens, run_telemetry.output_tokens), - cached_input_tokens = COALESCE(excluded.cached_input_tokens, run_telemetry.cached_input_tokens), - cost_usd = COALESCE(excluded.cost_usd, run_telemetry.cost_usd), - elapsed_ms = COALESCE(excluded.elapsed_ms, run_telemetry.elapsed_ms), - tool_count = COALESCE(excluded.tool_count, run_telemetry.tool_count), - model = COALESCE(excluded.model, run_telemetry.model), - provider = COALESCE(excluded.provider, run_telemetry.provider), - error = COALESCE(excluded.error, run_telemetry.error), - updated_at = excluded.updated_at", - params![ - upsert.run_id, - upsert.input_tokens.map(|v| v as i64), - upsert.output_tokens.map(|v| v as i64), - upsert.cached_input_tokens.map(|v| v as i64), - upsert.cost_usd, - upsert.elapsed_ms.map(|v| v as i64), - upsert.tool_count.map(|v| v as i64), - upsert.model, - upsert.provider, - upsert.error, - now.to_rfc3339(), - ], - ) - .context("upsert run telemetry")?; - get_run_telemetry_inner(conn, &upsert.run_id) - }) -} - -pub fn get_agent_run(config: &Config, id: &str) -> Result> { - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - get_agent_run_inner(conn, id) - }) -} - -/// Apply a durable status transition to a single agent run. -/// -/// Unlike [`upsert_agent_run`] — whose `ON CONFLICT` clause `COALESCE`s the -/// `error` and `completed_at` columns and can therefore only ever *set* them — -/// this is a direct `UPDATE` that can both set and *clear* both columns. That -/// is required by control verbs such as "retry", which moves a failed run back -/// to `pending` and must drop the stale failure reason and completion time. -/// -/// `status` is always written. `error` and `completed_at` are written verbatim, -/// so passing `None` clears the column. `updated_at` is bumped to now. Returns -/// the freshly-read run, or `None` when no row matched `id` (e.g. it was -/// deleted between a prior read and this write). -pub fn transition_agent_run_status( - config: &Config, - id: &str, - status: AgentRunStatus, - error: Option<&str>, - completed_at: Option>, -) -> Result> { - let now = Utc::now(); - log::debug!( - "{LOG_PREFIX} transition_agent_run_status id={id} status={} has_error={} has_completed_at={}", - status.as_str(), - error.is_some(), - completed_at.is_some() - ); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let rows_affected = conn - .execute( - "UPDATE agent_runs - SET status = ?1, error = ?2, completed_at = ?3, updated_at = ?4 - WHERE id = ?5", - params![ - status.as_str(), - error, - completed_at.map(|dt| dt.to_rfc3339()), - now.to_rfc3339(), - id, - ], - ) - .context("transition agent run status")?; - if rows_affected == 0 { - log::debug!("{LOG_PREFIX} transition_agent_run_status.miss id={id}"); - return Ok(None); - } - get_agent_run_inner(conn, id) - }) -} - -/// Settle non-terminal `agent_runs` rows left behind by a previous process. -/// -/// A freshly-booted core has no in-flight subagents — any detached run task -/// from a prior process is gone with that process. So a row still marked -/// `running` (or `pending`) at startup is, by definition, orphaned: its driver -/// died without firing a terminal `DomainEvent::Subagent{Completed,Failed}`, so -/// the [`register_run_ledger_finalize_subscriber`] never settled it. Without -/// this sweep those rows render as perpetual "running" timeline entries on every -/// thread reopen. -/// -/// We stamp them `interrupted` (outcome unknown — mirrors the turn-state -/// `mark_all_interrupted` recovery) and set `completed_at`. `awaiting_user` / -/// `paused` are intentionally left untouched: those are resumable states a user -/// may still continue. -/// -/// [`register_run_ledger_finalize_subscriber`]: crate::openhuman::agent::orchestration::run_ledger_finalize::register_run_ledger_finalize_subscriber -pub fn interrupt_orphaned_agent_runs(config: &Config) -> Result { - let now = Utc::now(); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let rows_affected = conn - .execute( - "UPDATE agent_runs - SET status = ?1, completed_at = COALESCE(completed_at, ?2), updated_at = ?2 - WHERE status IN ('running', 'pending')", - params![AgentRunStatus::Interrupted.as_str(), now.to_rfc3339()], - ) - .context("interrupt orphaned agent runs")?; - if rows_affected > 0 { - log::info!("{LOG_PREFIX} interrupted {rows_affected} orphaned agent run(s) on startup"); - } - Ok(rows_affected) - }) -} - -pub fn list_agent_runs( - config: &Config, - request: &AgentRunListRequest, -) -> Result { - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let mut where_clauses = Vec::new(); - let mut values: Vec> = Vec::new(); - - if let Some(status) = request.status.as_deref().filter(|s| !s.trim().is_empty()) { - values.push(Box::new(status.to_string())); - where_clauses.push(format!("status = ?{}", values.len())); - } - if let Some(kind) = request.kind.as_deref().filter(|s| !s.trim().is_empty()) { - values.push(Box::new(kind.to_string())); - where_clauses.push(format!("kind = ?{}", values.len())); - } - if let Some(parent) = request - .parent_run_id - .as_deref() - .filter(|s| !s.trim().is_empty()) - { - values.push(Box::new(parent.to_string())); - where_clauses.push(format!("parent_run_id = ?{}", values.len())); - } - if let Some(thread) = request - .parent_thread_id - .as_deref() - .filter(|s| !s.trim().is_empty()) - { - values.push(Box::new(thread.to_string())); - where_clauses.push(format!("parent_thread_id = ?{}", values.len())); - } - - let where_sql = if where_clauses.is_empty() { - String::new() - } else { - format!("WHERE {}", where_clauses.join(" AND ")) - }; - let count_sql = format!("SELECT COUNT(*) FROM agent_runs {where_sql}"); - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - values.iter().map(|v| v.as_ref()).collect(); - let count = conn.query_row(&count_sql, params_ref.as_slice(), |row| { - row.get::<_, i64>(0) - })? as usize; - - let limit = request.limit.unwrap_or(50).min(500) as i64; - let offset = request.offset.unwrap_or(0) as i64; - values.push(Box::new(limit)); - let limit_idx = values.len(); - values.push(Box::new(offset)); - let offset_idx = values.len(); - - let query_sql = format!( - "SELECT id, kind, parent_run_id, parent_thread_id, agent_id, status, - prompt_ref, worker_thread_id, task_board_id, task_card_id, - checkpoint_path, checkpoint_json, summary, error, metadata_json, - started_at, updated_at, completed_at - FROM agent_runs {where_sql} - ORDER BY updated_at DESC - LIMIT ?{limit_idx} OFFSET ?{offset_idx}" - ); - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - values.iter().map(|v| v.as_ref()).collect(); - let mut stmt = conn.prepare(&query_sql)?; - let rows = stmt.query_map(params_ref.as_slice(), |row| map_agent_run_row(conn, row))?; - let mut runs = Vec::new(); - for row in rows { - runs.push(row?); - } - Ok(AgentRunListResponse { runs, count }) - }) -} - -pub fn list_recent_run_events( - config: &Config, - request: &RunEventListRequest, -) -> Result { - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let limit = request.limit.unwrap_or(100).min(1000) as i64; - let after = request.after_sequence.unwrap_or(0) as i64; - let mut stmt = conn.prepare( - "SELECT run_id, sequence, event_type, payload_json, timestamp - FROM run_events - WHERE run_id = ?1 AND sequence > ?2 - ORDER BY sequence ASC - LIMIT ?3", - )?; - let rows = stmt.query_map(params![request.run_id, after, limit], map_run_event_row)?; - let mut events = Vec::new(); - for row in rows { - events.push(row?); - } - Ok(RunEventListResponse { - count: events.len(), - events, - }) - }) -} - -pub fn get_workflow_run(config: &Config, id: &str) -> Result> { - log::debug!("{LOG_PREFIX} get_workflow_run.entry id={id}"); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let mut stmt = conn.prepare( - "SELECT id, definition_id, parent_thread_id, input_json, phase_states_json, - child_run_ids_json, status, summary, started_at, updated_at, completed_at - FROM workflow_runs WHERE id = ?1", - )?; - let run = stmt - .query_row(params![id], map_workflow_run_row) - .optional()?; - log::debug!( - "{LOG_PREFIX} get_workflow_run.exit id={id} found={}", - run.is_some() - ); - Ok(run) - }) -} - -/// List durable workflow runs, most-recently-updated first, with optional -/// filters (definition id, status, parent thread) and pagination. Mirrors -/// [`list_agent_runs`] for the workflow_runs table. -pub fn list_workflow_runs( - config: &Config, - request: &WorkflowRunListRequest, -) -> Result { - log::debug!( - "{LOG_PREFIX} list_workflow_runs.entry definition={:?} status={:?} parent_thread={:?} limit={:?} offset={:?}", - request.definition_id, - request.status, - request.parent_thread_id, - request.limit, - request.offset - ); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let mut where_clauses = Vec::new(); - let mut values: Vec> = Vec::new(); - - if let Some(definition) = request - .definition_id - .as_deref() - .filter(|s| !s.trim().is_empty()) - { - values.push(Box::new(definition.to_string())); - where_clauses.push(format!("definition_id = ?{}", values.len())); - } - if let Some(status) = request.status.as_deref().filter(|s| !s.trim().is_empty()) { - values.push(Box::new(status.to_string())); - where_clauses.push(format!("status = ?{}", values.len())); - } - if let Some(thread) = request - .parent_thread_id - .as_deref() - .filter(|s| !s.trim().is_empty()) - { - values.push(Box::new(thread.to_string())); - where_clauses.push(format!("parent_thread_id = ?{}", values.len())); - } - - let where_sql = if where_clauses.is_empty() { - String::new() - } else { - format!("WHERE {}", where_clauses.join(" AND ")) - }; - let count_sql = format!("SELECT COUNT(*) FROM workflow_runs {where_sql}"); - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - values.iter().map(|v| v.as_ref()).collect(); - let count = conn.query_row(&count_sql, params_ref.as_slice(), |row| { - row.get::<_, i64>(0) - })? as usize; - - let limit = request.limit.unwrap_or(50).min(500) as i64; - // `offset` is `u64`; convert checked so a value > i64::MAX surfaces a - // clear error instead of wrapping negative and corrupting pagination. - let offset = i64::try_from(request.offset.unwrap_or(0)) - .context("workflow run list offset exceeds i64::MAX")?; - values.push(Box::new(limit)); - let limit_idx = values.len(); - values.push(Box::new(offset)); - let offset_idx = values.len(); - - let query_sql = format!( - "SELECT id, definition_id, parent_thread_id, input_json, phase_states_json, - child_run_ids_json, status, summary, started_at, updated_at, completed_at - FROM workflow_runs {where_sql} - ORDER BY updated_at DESC - LIMIT ?{limit_idx} OFFSET ?{offset_idx}" - ); - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - values.iter().map(|v| v.as_ref()).collect(); - let mut stmt = conn.prepare(&query_sql)?; - let rows = stmt.query_map(params_ref.as_slice(), map_workflow_run_row)?; - let mut runs = Vec::new(); - for row in rows { - runs.push(row?); - } - log::debug!( - "{LOG_PREFIX} list_workflow_runs.exit count={count} returned={}", - runs.len() - ); - Ok(WorkflowRunListResponse { runs, count }) - }) -} - -// --------------------------------------------------------------------------- -// Agent-team coordination (issue #3374) -// --------------------------------------------------------------------------- - -/// Insert or update a team row. -pub fn upsert_agent_team(config: &Config, upsert: AgentTeamUpsert) -> Result { - let now = Utc::now(); - let created_at = upsert.created_at.unwrap_or(now); - log::debug!( - "{LOG_PREFIX} upsert_agent_team.entry id={} lead={} status={}", - upsert.id, - upsert.lead_agent_id, - upsert.status.as_str() - ); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - conn.execute( - "INSERT INTO agent_teams ( - id, parent_thread_id, lead_agent_id, status, summary, - created_at, updated_at, closed_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(id) DO UPDATE SET - parent_thread_id = COALESCE(excluded.parent_thread_id, agent_teams.parent_thread_id), - lead_agent_id = excluded.lead_agent_id, - status = excluded.status, - summary = COALESCE(excluded.summary, agent_teams.summary), - updated_at = excluded.updated_at, - closed_at = COALESCE(excluded.closed_at, agent_teams.closed_at)", - params![ - upsert.id, - upsert.parent_thread_id, - upsert.lead_agent_id, - upsert.status.as_str(), - upsert.summary, - created_at.to_rfc3339(), - now.to_rfc3339(), - upsert.closed_at.map(|dt| dt.to_rfc3339()), - ], - ) - .context("upsert agent team")?; - Ok(()) - })?; - let team = get_agent_team(config, &upsert.id)?.context("agent team missing after upsert")?; - log::debug!("{LOG_PREFIX} upsert_agent_team.exit id={}", team.id); - Ok(team) -} - -/// Fetch a single team by id. -pub fn get_agent_team(config: &Config, id: &str) -> Result> { - log::debug!("{LOG_PREFIX} get_agent_team.entry id={id}"); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let team = get_agent_team_inner(conn, id)?; - log::debug!( - "{LOG_PREFIX} get_agent_team.exit id={id} found={}", - team.is_some() - ); - Ok(team) - }) -} - -/// List teams, most-recently-updated first, with optional thread/status filters. -pub fn list_agent_teams( - config: &Config, - request: &AgentTeamListRequest, -) -> Result { - log::debug!( - "{LOG_PREFIX} list_agent_teams.entry parent_thread={:?} status={:?} limit={:?} offset={:?}", - request.parent_thread_id, - request.status, - request.limit, - request.offset - ); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let mut where_clauses = Vec::new(); - let mut values: Vec> = Vec::new(); - - if let Some(thread) = request - .parent_thread_id - .as_deref() - .filter(|s| !s.trim().is_empty()) - { - values.push(Box::new(thread.to_string())); - where_clauses.push(format!("parent_thread_id = ?{}", values.len())); - } - if let Some(status) = request.status.as_deref().filter(|s| !s.trim().is_empty()) { - values.push(Box::new(status.to_string())); - where_clauses.push(format!("status = ?{}", values.len())); - } - - let where_sql = if where_clauses.is_empty() { - String::new() - } else { - format!("WHERE {}", where_clauses.join(" AND ")) - }; - let count_sql = format!("SELECT COUNT(*) FROM agent_teams {where_sql}"); - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - values.iter().map(|v| v.as_ref()).collect(); - let count = conn.query_row(&count_sql, params_ref.as_slice(), |row| { - row.get::<_, i64>(0) - })? as usize; - - let limit = request.limit.unwrap_or(50).min(500) as i64; - // `offset` is `u64`; convert checked so a value > i64::MAX surfaces a - // clear error instead of wrapping negative and corrupting pagination. - let offset = i64::try_from(request.offset.unwrap_or(0)) - .context("agent team list offset exceeds i64::MAX")?; - values.push(Box::new(limit)); - let limit_idx = values.len(); - values.push(Box::new(offset)); - let offset_idx = values.len(); - - let query_sql = format!( - "SELECT id, parent_thread_id, lead_agent_id, status, summary, - created_at, updated_at, closed_at - FROM agent_teams {where_sql} - ORDER BY updated_at DESC - LIMIT ?{limit_idx} OFFSET ?{offset_idx}" - ); - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - values.iter().map(|v| v.as_ref()).collect(); - let mut stmt = conn.prepare(&query_sql)?; - let rows = stmt.query_map(params_ref.as_slice(), map_agent_team_row)?; - let mut teams = Vec::new(); - for row in rows { - teams.push(row?); - } - log::debug!( - "{LOG_PREFIX} list_agent_teams.exit count={count} returned={}", - teams.len() - ); - Ok(AgentTeamListResponse { teams, count }) - }) -} - -/// Insert or update a team member. `UNIQUE(team_id, name)` enforces unique names. -pub fn upsert_agent_team_member( - config: &Config, - upsert: AgentTeamMemberUpsert, -) -> Result { - let now = Utc::now(); - let created_at = upsert.created_at.unwrap_or(now); - log::debug!( - "{LOG_PREFIX} upsert_agent_team_member.entry id={} team={} name={} status={}", - upsert.id, - upsert.team_id, - upsert.name, - upsert.member_status.as_str() - ); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - conn.execute( - "INSERT INTO agent_team_members ( - id, team_id, name, agent_id, member_status, - current_task_id, worker_thread_id, run_id, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - agent_id = COALESCE(excluded.agent_id, agent_team_members.agent_id), - member_status = excluded.member_status, - current_task_id = COALESCE(excluded.current_task_id, agent_team_members.current_task_id), - worker_thread_id = COALESCE(excluded.worker_thread_id, agent_team_members.worker_thread_id), - run_id = COALESCE(excluded.run_id, agent_team_members.run_id), - updated_at = excluded.updated_at", - params![ - upsert.id, - upsert.team_id, - upsert.name, - upsert.agent_id, - upsert.member_status.as_str(), - upsert.current_task_id, - upsert.worker_thread_id, - upsert.run_id, - created_at.to_rfc3339(), - now.to_rfc3339(), - ], - ) - .context("upsert agent team member")?; - Ok(()) - })?; - let member = get_agent_team_member(config, &upsert.id)? - .context("agent team member missing after upsert")?; - log::debug!( - "{LOG_PREFIX} upsert_agent_team_member.exit id={}", - member.id - ); - Ok(member) -} - -/// Fetch a single member by id. -pub fn get_agent_team_member(config: &Config, id: &str) -> Result> { - log::debug!("{LOG_PREFIX} get_agent_team_member.entry id={id}"); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let member = get_agent_team_member_inner(conn, id)?; - log::debug!( - "{LOG_PREFIX} get_agent_team_member.exit id={id} found={}", - member.is_some() - ); - Ok(member) - }) -} - -/// List all members of a team, by creation order. -pub fn list_agent_team_members(config: &Config, team_id: &str) -> Result> { - log::debug!("{LOG_PREFIX} list_agent_team_members.entry team={team_id}"); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let mut stmt = conn.prepare( - "SELECT id, team_id, name, agent_id, member_status, - current_task_id, worker_thread_id, run_id, created_at, updated_at - FROM agent_team_members WHERE team_id = ?1 - ORDER BY created_at ASC", - )?; - let rows = stmt.query_map(params![team_id], map_agent_team_member_row)?; - let mut members = Vec::new(); - for row in rows { - members.push(row?); - } - log::debug!( - "{LOG_PREFIX} list_agent_team_members.exit team={team_id} count={}", - members.len() - ); - Ok(members) - }) -} - -/// Insert or update a team task. -pub fn upsert_agent_team_task( - config: &Config, - upsert: AgentTeamTaskUpsert, -) -> Result { - let now = Utc::now(); - let created_at = upsert.created_at.unwrap_or(now); - let depends_on_json = - serde_json::to_string(&upsert.depends_on).context("serialize task depends_on")?; - let evidence_json = - serde_json::to_string(&upsert.evidence).context("serialize task evidence")?; - let gate_status = upsert.gate_status.unwrap_or_else(|| "pending".to_string()); - log::debug!( - "{LOG_PREFIX} upsert_agent_team_task.entry id={} team={} status={} deps={}", - upsert.id, - upsert.team_id, - upsert.status.as_str(), - upsert.depends_on.len() - ); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - conn.execute( - "INSERT INTO agent_team_tasks ( - id, team_id, title, objective, status, owner_member_id, - claimed_by_member_id, claim_token, depends_on_json, gate_status, - gate_reason, evidence_json, source_run_id, order_index, - created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) - ON CONFLICT(id) DO UPDATE SET - title = excluded.title, - objective = COALESCE(excluded.objective, agent_team_tasks.objective), - status = excluded.status, - owner_member_id = COALESCE(excluded.owner_member_id, agent_team_tasks.owner_member_id), - depends_on_json = excluded.depends_on_json, - gate_status = excluded.gate_status, - gate_reason = COALESCE(excluded.gate_reason, agent_team_tasks.gate_reason), - evidence_json = excluded.evidence_json, - source_run_id = COALESCE(excluded.source_run_id, agent_team_tasks.source_run_id), - order_index = excluded.order_index, - updated_at = excluded.updated_at", - params![ - upsert.id, - upsert.team_id, - upsert.title, - upsert.objective, - upsert.status.as_str(), - upsert.owner_member_id, - depends_on_json, - gate_status, - upsert.gate_reason, - evidence_json, - upsert.source_run_id, - upsert.order_index, - created_at.to_rfc3339(), - now.to_rfc3339(), - ], - ) - .context("upsert agent team task")?; - Ok(()) - })?; - let task = - get_agent_team_task(config, &upsert.id)?.context("agent team task missing after upsert")?; - log::debug!("{LOG_PREFIX} upsert_agent_team_task.exit id={}", task.id); - Ok(task) -} - -/// Fetch a single task by id. -pub fn get_agent_team_task(config: &Config, id: &str) -> Result> { - log::debug!("{LOG_PREFIX} get_agent_team_task.entry id={id}"); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let task = get_agent_team_task_inner(conn, id)?; - log::debug!( - "{LOG_PREFIX} get_agent_team_task.exit id={id} found={}", - task.is_some() - ); - Ok(task) - }) -} - -/// List all tasks of a team, by `order_index` then creation order. -pub fn list_agent_team_tasks(config: &Config, team_id: &str) -> Result> { - log::debug!("{LOG_PREFIX} list_agent_team_tasks.entry team={team_id}"); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let mut stmt = conn.prepare( - "SELECT id, team_id, title, objective, status, owner_member_id, - claimed_by_member_id, claim_token, depends_on_json, gate_status, - gate_reason, evidence_json, source_run_id, order_index, - created_at, updated_at - FROM agent_team_tasks WHERE team_id = ?1 - ORDER BY order_index ASC, created_at ASC", - )?; - let rows = stmt.query_map(params![team_id], map_agent_team_task_row)?; - let mut tasks = Vec::new(); - for row in rows { - tasks.push(row?); - } - log::debug!( - "{LOG_PREFIX} list_agent_team_tasks.exit team={team_id} count={}", - tasks.len() - ); - Ok(tasks) - }) -} - -/// Atomically claim a task for a member. -/// -/// All steps run inside a single `with_connection` transaction so that the -/// dependency check and the compare-and-swap observe a consistent snapshot: -/// 1. Resolve the task by `(id, team_id)`; absent → [`ClaimOutcome::UnknownTask`]. -/// 2. For every dependency id, look up its status; collect those not `done` -/// into `unmet`. Non-empty → [`ClaimOutcome::Blocked`]. -/// 3. WHERE-guarded `UPDATE ... WHERE claimed_by_member_id IS NULL`: SQLite -/// serializes writers, so exactly one concurrent claimer flips the row from -/// unclaimed to claimed. `rows_affected == 0` → already taken -/// ([`ClaimOutcome::AlreadyClaimed`]); otherwise re-fetch and return -/// [`ClaimOutcome::Claimed`]. -pub fn claim_agent_team_task( - config: &Config, - team_id: &str, - task_id: &str, - member_id: &str, - claim_token: &str, -) -> Result { - log::debug!( - "{LOG_PREFIX} claim_agent_team_task.entry team={team_id} task={task_id} member={member_id}" - ); - let outcome = crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - - // 1. Resolve the task within this team. - let task = match get_agent_team_task_inner(conn, task_id)? { - Some(task) if task.team_id == team_id => task, - _ => { - log::debug!( - "{LOG_PREFIX} claim_agent_team_task.unknown team={team_id} task={task_id}" - ); - return Ok(ClaimOutcome::UnknownTask); - } - }; - - // 2. Dependency gate: every dep must be `done`. - let mut unmet = Vec::new(); - for dep_id in &task.depends_on { - let dep_status: Option = conn - .query_row( - "SELECT status FROM agent_team_tasks WHERE id = ?1 AND team_id = ?2", - params![dep_id, team_id], - |row| row.get(0), - ) - .optional()?; - let is_done = dep_status.as_deref() == Some(AgentTeamTaskStatus::Done.as_str()); - if !is_done { - unmet.push(dep_id.clone()); - } - } - if !unmet.is_empty() { - log::debug!( - "{LOG_PREFIX} claim_agent_team_task.blocked team={team_id} task={task_id} unmet={}", - unmet.len() - ); - return Ok(ClaimOutcome::Blocked { unmet }); - } - - // 3. Compare-and-swap on the unclaimed guard. - let now = Utc::now(); - let rows_affected = conn - .execute( - "UPDATE agent_team_tasks - SET claimed_by_member_id = ?1, claim_token = ?2, status = 'in_progress', updated_at = ?3 - WHERE id = ?4 AND team_id = ?5 AND claimed_by_member_id IS NULL", - params![member_id, claim_token, now.to_rfc3339(), task_id, team_id], - ) - .context("compare-and-swap claim agent team task")?; - if rows_affected == 0 { - log::debug!( - "{LOG_PREFIX} claim_agent_team_task.already_claimed team={team_id} task={task_id}" - ); - return Ok(ClaimOutcome::AlreadyClaimed); - } - - let claimed = get_agent_team_task_inner(conn, task_id)? - .context("claimed task missing after compare-and-swap")?; - Ok(ClaimOutcome::Claimed(Box::new(claimed))) - })?; - log::debug!( - "{LOG_PREFIX} claim_agent_team_task.exit team={team_id} task={task_id} outcome={}", - match &outcome { - ClaimOutcome::Claimed(_) => "claimed", - ClaimOutcome::AlreadyClaimed => "already_claimed", - ClaimOutcome::Blocked { .. } => "blocked", - ClaimOutcome::UnknownTask => "unknown", - } - ); - Ok(outcome) -} - -/// Quality-gate a task's completion and, on pass, transition it to `done`. -/// -/// Runs inside a single transaction so the gate evaluation and the status flip -/// observe one consistent snapshot: -/// 1. Resolve the task by `(id, team_id)`; absent → [`CompletionOutcome::UnknownTask`]. -/// 2. The completer must be the current claimant and the task must be -/// `in_progress`; otherwise [`CompletionOutcome::NotClaimed`]. -/// 3. Evaluate the quality gate (every dependency `done`, claimant matches any -/// pre-assigned owner, evidence present when `require_evidence`). Any unmet -/// invariant records `gate_status = "failed"` + the joined reasons and leaves -/// the task `in_progress` → [`CompletionOutcome::GateFailed`]. -/// 4. On pass, merge `evidence`, set `status = "done"`, `gate_status = "passed"`, -/// clear `gate_reason`, re-fetch → [`CompletionOutcome::Completed`]. -pub fn complete_agent_team_task( - config: &Config, - team_id: &str, - task_id: &str, - member_id: &str, - evidence: &[String], - require_evidence: bool, -) -> Result { - log::debug!( - "{LOG_PREFIX} complete_agent_team_task.entry team={team_id} task={task_id} member={member_id}" - ); - let outcome = crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - - // 1. Resolve the task within this team. - let task = match get_agent_team_task_inner(conn, task_id)? { - Some(task) if task.team_id == team_id => task, - _ => { - log::debug!( - "{LOG_PREFIX} complete_agent_team_task.unknown team={team_id} task={task_id}" - ); - return Ok(CompletionOutcome::UnknownTask); - } - }; - - // 2. Only the current claimant may complete, and only while in progress. - let is_claimant = task.claimed_by_member_id.as_deref() == Some(member_id); - let in_progress = task.status == AgentTeamTaskStatus::InProgress; - if !is_claimant || !in_progress { - log::debug!( - "{LOG_PREFIX} complete_agent_team_task.not_claimed team={team_id} task={task_id} claimant={is_claimant} in_progress={in_progress}" - ); - return Ok(CompletionOutcome::NotClaimed); - } - - // Merge prior evidence with the newly-supplied links (de-duplicated, - // order-preserving) so a retry that adds evidence accumulates it. - let mut merged_evidence = task.evidence.clone(); - for link in evidence { - if !merged_evidence.iter().any(|e| e == link) { - merged_evidence.push(link.clone()); - } - } - - // 3. Quality gate. - let reasons = - evaluate_completion_gate(conn, team_id, &task, &merged_evidence, require_evidence)?; - let now = Utc::now(); - if !reasons.is_empty() { - let joined = reasons.join("; "); - conn.execute( - "UPDATE agent_team_tasks - SET gate_status = 'failed', gate_reason = ?1, updated_at = ?2 - WHERE id = ?3 AND team_id = ?4", - params![joined, now.to_rfc3339(), task_id, team_id], - ) - .context("record failed completion gate")?; - log::debug!( - "{LOG_PREFIX} complete_agent_team_task.gate_failed team={team_id} task={task_id} reasons={}", - reasons.len() - ); - return Ok(CompletionOutcome::GateFailed { reasons }); - } - - // 4. Gate passed — flip to done. The WHERE clause is the real CAS: the - // `claimed_by_member_id` guard stops a concurrent shutdown/unclaim from - // completing a task it no longer holds, and the `status = 'in_progress'` - // guard stops a concurrent double-complete by the same member (the - // snapshot check above is a read, not part of the swap — only one of two - // racing UPDATEs flips `in_progress -> done`). - let evidence_json = - serde_json::to_string(&merged_evidence).context("serialize completion evidence")?; - let rows_affected = conn - .execute( - "UPDATE agent_team_tasks - SET status = 'done', gate_status = 'passed', gate_reason = NULL, - evidence_json = ?1, updated_at = ?2 - WHERE id = ?3 AND team_id = ?4 AND claimed_by_member_id = ?5 - AND status = 'in_progress'", - params![evidence_json, now.to_rfc3339(), task_id, team_id, member_id], - ) - .context("complete agent team task")?; - if rows_affected == 0 { - log::debug!( - "{LOG_PREFIX} complete_agent_team_task.lost_claim team={team_id} task={task_id}" - ); - return Ok(CompletionOutcome::NotClaimed); - } - - let done = get_agent_team_task_inner(conn, task_id)? - .context("completed task missing after update")?; - Ok(CompletionOutcome::Completed(Box::new(done))) - })?; - log::debug!( - "{LOG_PREFIX} complete_agent_team_task.exit team={team_id} task={task_id} outcome={}", - match &outcome { - CompletionOutcome::Completed(_) => "completed", - CompletionOutcome::GateFailed { .. } => "gate_failed", - CompletionOutcome::NotClaimed => "not_claimed", - CompletionOutcome::UnknownTask => "unknown", - } - ); - Ok(outcome) -} - -/// Evaluate the quality-gate invariants for a completing task. Returns one -/// human-readable reason per unmet invariant (empty = gate passes). -fn evaluate_completion_gate( - conn: &Connection, - team_id: &str, - task: &AgentTeamTask, - merged_evidence: &[String], - require_evidence: bool, -) -> Result> { - let mut reasons = Vec::new(); - - // Every dependency must still be `done` (defends against a dependency that - // regressed after this task was claimed). - for dep_id in &task.depends_on { - let dep_status: Option = conn - .query_row( - "SELECT status FROM agent_team_tasks WHERE id = ?1 AND team_id = ?2", - params![dep_id, team_id], - |row| row.get(0), - ) - .optional()?; - if dep_status.as_deref() != Some(AgentTeamTaskStatus::Done.as_str()) { - reasons.push(format!("dependency {dep_id} is not done")); - } - } - - // No overlapping ownership: a pre-assigned owner must be the one completing. - if let Some(owner) = &task.owner_member_id { - if Some(owner.as_str()) != task.claimed_by_member_id.as_deref() { - reasons.push(format!( - "task is owned by {owner} but claimed by {}", - task.claimed_by_member_id.as_deref().unwrap_or("nobody") - )); - } - } - - // Evidence gate. - if require_evidence && merged_evidence.is_empty() { - reasons.push("completion requires at least one evidence link".to_string()); - } - - Ok(reasons) -} - -/// Stop a team member and release any task it is actively working on. -/// -/// In one transaction: unclaim the member's `in_progress` tasks back to `todo` -/// (clearing claimant + token so another teammate can pick them up), then mark -/// the member `stopped` and clear its `current_task_id`. Returns the updated -/// member plus the ids of the tasks that were released, or `None` if the member -/// is not part of the team. -pub fn shutdown_agent_team_member( - config: &Config, - team_id: &str, - member_id: &str, -) -> Result)>> { - log::debug!("{LOG_PREFIX} shutdown_agent_team_member.entry team={team_id} member={member_id}"); - let result = crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - - // Existence + team-membership check only; the row is intentionally not - // reused — the caller-facing member is re-read after the UPDATEs below so - // it reflects the stopped state. - match get_agent_team_member_inner(conn, member_id)? { - Some(found) if found.team_id == team_id => {} - _ => { - log::debug!( - "{LOG_PREFIX} shutdown_agent_team_member.unknown team={team_id} member={member_id}" - ); - return Ok(None); - } - } - - // Collect the ids first so the caller can report exactly what was freed. - let released: Vec = { - let mut stmt = conn.prepare( - "SELECT id FROM agent_team_tasks - WHERE team_id = ?1 AND claimed_by_member_id = ?2 AND status = 'in_progress'", - )?; - let ids = stmt.query_map(params![team_id, member_id], |row| row.get::<_, String>(0))?; - let mut out = Vec::new(); - for id in ids { - out.push(id?); - } - out - }; - - let now = Utc::now(); - conn.execute( - "UPDATE agent_team_tasks - SET claimed_by_member_id = NULL, claim_token = NULL, status = 'todo', updated_at = ?1 - WHERE team_id = ?2 AND claimed_by_member_id = ?3 AND status = 'in_progress'", - params![now.to_rfc3339(), team_id, member_id], - ) - .context("release tasks on member shutdown")?; - conn.execute( - "UPDATE agent_team_members - SET member_status = 'stopped', current_task_id = NULL, updated_at = ?1 - WHERE id = ?2 AND team_id = ?3", - params![now.to_rfc3339(), member_id, team_id], - ) - .context("stop agent team member")?; - - let member = get_agent_team_member_inner(conn, member_id)? - .context("member missing after shutdown")?; - Ok(Some((member, released))) - })?; - log::debug!( - "{LOG_PREFIX} shutdown_agent_team_member.exit team={team_id} member={member_id} released={}", - result.as_ref().map(|(_, r)| r.len()).unwrap_or(0) - ); - Ok(result) -} - -/// Mark a member as actively running a task: status → `active`, with the -/// current task id and the worker/run identifiers of the spawned agent. Used by -/// the live runtime right after it claims a task and dispatches a worker. -/// Returns the updated member, or `None` if the member is not in the team. -pub fn mark_agent_team_member_running( - config: &Config, - team_id: &str, - member_id: &str, - task_id: &str, - worker_thread_id: &str, - run_id: &str, -) -> Result> { - log::debug!( - "{LOG_PREFIX} mark_agent_team_member_running.entry team={team_id} member={member_id} task={task_id} run={run_id}" - ); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let now = Utc::now(); - let changed = conn - .execute( - "UPDATE agent_team_members - SET member_status = 'active', current_task_id = ?1, - worker_thread_id = ?2, run_id = ?3, updated_at = ?4 - WHERE id = ?5 AND team_id = ?6", - params![ - task_id, - worker_thread_id, - run_id, - now.to_rfc3339(), - member_id, - team_id - ], - ) - .context("mark agent team member running")?; - if changed == 0 { - return Ok(None); - } - get_agent_team_member_inner(conn, member_id) - }) -} - -/// Mark a member idle: status → `idle`, clearing `current_task_id`. The -/// `worker_thread_id` / `run_id` are intentionally retained as a pointer to the -/// member's last run for history. Returns the updated member, or `None` if the -/// member is not in the team. Used when a worker run finishes (completed, -/// gate-failed, or failed) so the member is free to pick up new work. -pub fn mark_agent_team_member_idle( - config: &Config, - team_id: &str, - member_id: &str, -) -> Result> { - log::debug!("{LOG_PREFIX} mark_agent_team_member_idle.entry team={team_id} member={member_id}"); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let now = Utc::now(); - let changed = conn - .execute( - "UPDATE agent_team_members - SET member_status = 'idle', current_task_id = NULL, updated_at = ?1 - WHERE id = ?2 AND team_id = ?3", - params![now.to_rfc3339(), member_id, team_id], - ) - .context("mark agent team member idle")?; - if changed == 0 { - return Ok(None); - } - get_agent_team_member_inner(conn, member_id) - }) -} - -/// Release a single `in_progress` task back to `todo`, clearing its claim and -/// resetting the quality gate. Returns `true` if a row was actually released -/// (the task existed, belonged to the team, and was `in_progress`). Used by the -/// live runtime when a worker run fails or is aborted, so the task is free for -/// another teammate — the per-task analogue of the bulk release in -/// `shutdown_agent_team_member`. -pub fn release_agent_team_task(config: &Config, team_id: &str, task_id: &str) -> Result { - log::debug!("{LOG_PREFIX} release_agent_team_task.entry team={team_id} task={task_id}"); - crate::openhuman::agent::session_db::store::with_connection(config, |conn| { - init_run_ledger_schema(conn)?; - let now = Utc::now(); - let changed = conn - .execute( - "UPDATE agent_team_tasks - SET status = 'todo', claimed_by_member_id = NULL, claim_token = NULL, - gate_status = 'pending', gate_reason = NULL, updated_at = ?1 - WHERE id = ?2 AND team_id = ?3 AND status = 'in_progress'", - params![now.to_rfc3339(), task_id, team_id], - ) - .context("release agent team task")?; - log::debug!( - "{LOG_PREFIX} release_agent_team_task.exit team={team_id} task={task_id} released={}", - changed > 0 - ); - Ok(changed > 0) - }) -} - -fn get_agent_team_inner(conn: &Connection, id: &str) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, parent_thread_id, lead_agent_id, status, summary, - created_at, updated_at, closed_at - FROM agent_teams WHERE id = ?1", - )?; - stmt.query_row(params![id], map_agent_team_row) - .optional() - .map_err(Into::into) -} - -fn get_agent_team_member_inner(conn: &Connection, id: &str) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, team_id, name, agent_id, member_status, - current_task_id, worker_thread_id, run_id, created_at, updated_at - FROM agent_team_members WHERE id = ?1", - )?; - stmt.query_row(params![id], map_agent_team_member_row) - .optional() - .map_err(Into::into) -} - -fn get_agent_team_task_inner(conn: &Connection, id: &str) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, team_id, title, objective, status, owner_member_id, - claimed_by_member_id, claim_token, depends_on_json, gate_status, - gate_reason, evidence_json, source_run_id, order_index, - created_at, updated_at - FROM agent_team_tasks WHERE id = ?1", - )?; - stmt.query_row(params![id], map_agent_team_task_row) - .optional() - .map_err(Into::into) -} - -fn map_agent_team_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(AgentTeam { - id: row.get(0)?, - parent_thread_id: row.get(1)?, - lead_agent_id: row.get(2)?, - status: AgentTeamStatus::parse(&row.get::<_, String>(3)?), - summary: row.get(4)?, - created_at: parse_rfc3339(&row.get::<_, String>(5)?)?, - updated_at: parse_rfc3339(&row.get::<_, String>(6)?)?, - closed_at: parse_rfc3339_opt(row.get(7)?)?, - }) -} - -fn map_agent_team_member_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(AgentTeamMember { - id: row.get(0)?, - team_id: row.get(1)?, - name: row.get(2)?, - agent_id: row.get(3)?, - member_status: AgentTeamMemberStatus::parse(&row.get::<_, String>(4)?), - current_task_id: row.get(5)?, - worker_thread_id: row.get(6)?, - run_id: row.get(7)?, - created_at: parse_rfc3339(&row.get::<_, String>(8)?)?, - updated_at: parse_rfc3339(&row.get::<_, String>(9)?)?, - }) -} - -fn map_agent_team_task_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(AgentTeamTask { - id: row.get(0)?, - team_id: row.get(1)?, - title: row.get(2)?, - objective: row.get(3)?, - status: AgentTeamTaskStatus::parse(&row.get::<_, String>(4)?), - owner_member_id: row.get(5)?, - claimed_by_member_id: row.get(6)?, - claim_token: row.get(7)?, - depends_on: serde_json::from_str(&row.get::<_, String>(8)?).unwrap_or_default(), - gate_status: row.get(9)?, - gate_reason: row.get(10)?, - evidence: serde_json::from_str(&row.get::<_, String>(11)?).unwrap_or_default(), - source_run_id: row.get(12)?, - order_index: row.get(13)?, - created_at: parse_rfc3339(&row.get::<_, String>(14)?)?, - updated_at: parse_rfc3339(&row.get::<_, String>(15)?)?, - }) -} - -fn get_agent_run_inner(conn: &Connection, id: &str) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, kind, parent_run_id, parent_thread_id, agent_id, status, - prompt_ref, worker_thread_id, task_board_id, task_card_id, - checkpoint_path, checkpoint_json, summary, error, metadata_json, - started_at, updated_at, completed_at - FROM agent_runs WHERE id = ?1", - )?; - stmt.query_row(params![id], |row| map_agent_run_row(conn, row)) - .optional() - .map_err(Into::into) -} - -fn get_run_telemetry_inner(conn: &Connection, run_id: &str) -> Result { - let mut stmt = conn.prepare( - "SELECT run_id, input_tokens, output_tokens, cached_input_tokens, cost_usd, - elapsed_ms, tool_count, model, provider, error, updated_at - FROM run_telemetry WHERE run_id = ?1", - )?; - stmt.query_row(params![run_id], map_run_telemetry_row) - .context("run telemetry missing after upsert") -} - -fn get_optional_run_telemetry( - conn: &Connection, - run_id: &str, -) -> rusqlite::Result> { - let mut stmt = conn.prepare( - "SELECT run_id, input_tokens, output_tokens, cached_input_tokens, cost_usd, - elapsed_ms, tool_count, model, provider, error, updated_at - FROM run_telemetry WHERE run_id = ?1", - )?; - stmt.query_row(params![run_id], map_run_telemetry_row) - .optional() -} - -fn map_agent_run_row(conn: &Connection, row: &rusqlite::Row<'_>) -> rusqlite::Result { - let id: String = row.get(0)?; - let checkpoint_json: Option = row.get(11)?; - let metadata_json: String = row.get(14)?; - Ok(AgentRun { - id: id.clone(), - kind: super::types::AgentRunKind::parse(&row.get::<_, String>(1)?), - parent_run_id: row.get(2)?, - parent_thread_id: row.get(3)?, - agent_id: row.get(4)?, - status: AgentRunStatus::parse(&row.get::<_, String>(5)?), - prompt_ref: row.get(6)?, - worker_thread_id: row.get(7)?, - task_board_id: row.get(8)?, - task_card_id: row.get(9)?, - checkpoint_path: row.get(10)?, - checkpoint: parse_json_opt(checkpoint_json), - summary: row.get(12)?, - error: row.get(13)?, - metadata: parse_json(metadata_json), - telemetry: get_optional_run_telemetry(conn, &id)?, - started_at: parse_rfc3339(&row.get::<_, String>(15)?)?, - updated_at: parse_rfc3339(&row.get::<_, String>(16)?)?, - completed_at: parse_rfc3339_opt(row.get(17)?)?, - }) -} - -fn map_workflow_run_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(WorkflowRun { - id: row.get(0)?, - definition_id: row.get(1)?, - parent_thread_id: row.get(2)?, - input: parse_json(row.get(3)?), - phase_states: parse_json(row.get(4)?), - child_run_ids: serde_json::from_str(&row.get::<_, String>(5)?).unwrap_or_default(), - status: super::types::WorkflowRunStatus::parse(&row.get::<_, String>(6)?), - summary: row.get(7)?, - started_at: parse_rfc3339(&row.get::<_, String>(8)?)?, - updated_at: parse_rfc3339(&row.get::<_, String>(9)?)?, - completed_at: parse_rfc3339_opt(row.get(10)?)?, - }) -} - -fn map_run_event_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(RunEvent { - run_id: row.get(0)?, - sequence: row.get::<_, i64>(1)? as u64, - event_type: row.get(2)?, - payload: parse_json(row.get(3)?), - timestamp: parse_rfc3339(&row.get::<_, String>(4)?)?, - }) -} - -fn map_run_telemetry_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(RunTelemetry { - run_id: row.get(0)?, - input_tokens: row.get::<_, i64>(1)? as u64, - output_tokens: row.get::<_, i64>(2)? as u64, - cached_input_tokens: row.get::<_, i64>(3)? as u64, - cost_usd: row.get(4)?, - elapsed_ms: row.get::<_, Option>(5)?.map(|v| v as u64), - tool_count: row.get::<_, i64>(6)? as u64, - model: row.get(7)?, - provider: row.get(8)?, - error: row.get(9)?, - updated_at: Some(parse_rfc3339(&row.get::<_, String>(10)?)?), - }) -} - -fn parse_json(raw: String) -> Value { - serde_json::from_str(&raw).unwrap_or_else(|_| json!({})) -} - -fn parse_json_opt(raw: Option) -> Option { - raw.and_then(|value| serde_json::from_str(&value).ok()) -} - -fn parse_rfc3339(raw: &str) -> rusqlite::Result> { - DateTime::parse_from_rfc3339(raw) - .map(|dt| dt.with_timezone(&Utc)) - .map_err(|err| { - rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(err)) - }) -} - -fn parse_rfc3339_opt(raw: Option) -> rusqlite::Result>> { - match raw { - Some(value) => parse_rfc3339(&value).map(Some), - None => Ok(None), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn test_config(dir: &TempDir) -> Config { - let mut config = Config::default(); - config.workspace_dir = dir.path().to_path_buf(); - config.action_dir = dir.path().join("actions"); - config - } - - #[test] - fn agent_run_append_list_get_and_events_are_ordered() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - - let run = upsert_agent_run( - &config, - AgentRunUpsert { - id: "run-1".into(), - kind: super::super::types::AgentRunKind::Subagent, - parent_run_id: Some("parent".into()), - parent_thread_id: Some("thread-1".into()), - agent_id: Some("researcher".into()), - status: AgentRunStatus::Running, - prompt_ref: Some("worker-1:user:seed".into()), - worker_thread_id: Some("worker-1".into()), - task_board_id: None, - task_card_id: None, - checkpoint_path: None, - checkpoint: None, - summary: None, - error: None, - metadata: json!({"source": "test"}), - started_at: None, - completed_at: None, - }, - ) - .unwrap(); - assert_eq!(run.status, AgentRunStatus::Running); - - append_run_event( - &config, - RunEventAppend { - run_id: "run-1".into(), - event_type: "spawned".into(), - payload: json!({"agentId": "researcher"}), - }, - ) - .unwrap(); - append_run_event( - &config, - RunEventAppend { - run_id: "run-1".into(), - event_type: "completed".into(), - payload: json!({"elapsedMs": 12}), - }, - ) - .unwrap(); - - let events = list_recent_run_events( - &config, - &RunEventListRequest { - run_id: "run-1".into(), - after_sequence: Some(0), - limit: None, - }, - ) - .unwrap(); - assert_eq!(events.events.len(), 2); - assert_eq!(events.events[0].sequence, 1); - assert_eq!(events.events[1].sequence, 2); - - let list = list_agent_runs( - &config, - &AgentRunListRequest { - parent_thread_id: Some("thread-1".into()), - ..Default::default() - }, - ) - .unwrap(); - assert_eq!(list.count, 1); - assert_eq!(list.runs[0].worker_thread_id.as_deref(), Some("worker-1")); - } - - #[test] - fn transition_sets_status_and_clears_error_and_completed_at() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - - // Seed a failed run carrying an error + completion time. - let completed_at = Utc::now(); - upsert_agent_run( - &config, - AgentRunUpsert { - id: "run-1".into(), - kind: super::super::types::AgentRunKind::Subagent, - parent_run_id: None, - parent_thread_id: Some("thread-1".into()), - agent_id: Some("researcher".into()), - status: AgentRunStatus::Failed, - prompt_ref: None, - worker_thread_id: None, - task_board_id: None, - task_card_id: None, - checkpoint_path: None, - checkpoint: None, - summary: None, - error: Some("boom".into()), - metadata: json!({}), - started_at: None, - completed_at: Some(completed_at), - }, - ) - .unwrap(); - - // Re-queue: passing None for both columns must CLEAR them (the upsert - // path's COALESCE cannot do this — that is the whole reason this op - // exists). - let updated = - transition_agent_run_status(&config, "run-1", AgentRunStatus::Pending, None, None) - .unwrap() - .expect("run present"); - assert_eq!(updated.status, AgentRunStatus::Pending); - assert_eq!(updated.error, None); - assert_eq!(updated.completed_at, None); - - // Stopping: status + error + completion are all set verbatim. - let stopped_at = Utc::now(); - let updated = transition_agent_run_status( - &config, - "run-1", - AgentRunStatus::Cancelled, - Some("manual"), - Some(stopped_at), - ) - .unwrap() - .expect("run present"); - assert_eq!(updated.status, AgentRunStatus::Cancelled); - assert_eq!(updated.error.as_deref(), Some("manual")); - assert!(updated.completed_at.is_some()); - } - - #[test] - fn transition_unknown_run_returns_none() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - let result = - transition_agent_run_status(&config, "ghost", AgentRunStatus::Pending, None, None) - .unwrap(); - assert!(result.is_none()); - } - - fn seed_team(config: &Config, team_id: &str) { - upsert_agent_team( - config, - AgentTeamUpsert { - id: team_id.into(), - parent_thread_id: Some("thread-team".into()), - lead_agent_id: "lead".into(), - status: AgentTeamStatus::Active, - summary: None, - created_at: None, - closed_at: None, - }, - ) - .unwrap(); - } - - fn seed_task(config: &Config, team_id: &str, task_id: &str, depends_on: Vec) { - upsert_agent_team_task( - config, - AgentTeamTaskUpsert { - id: task_id.into(), - team_id: team_id.into(), - title: format!("task {task_id}"), - objective: None, - status: AgentTeamTaskStatus::Todo, - owner_member_id: None, - depends_on, - gate_status: None, - gate_reason: None, - evidence: vec![], - source_run_id: None, - order_index: 0, - created_at: None, - }, - ) - .unwrap(); - } - - #[test] - fn claim_is_atomic_first_wins_then_already_claimed() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - seed_team(&config, "team-1"); - seed_task(&config, "team-1", "task-a", vec![]); - - let first = claim_agent_team_task(&config, "team-1", "task-a", "m1", "tok-1").unwrap(); - match first { - ClaimOutcome::Claimed(task) => { - assert_eq!(task.claimed_by_member_id.as_deref(), Some("m1")); - assert_eq!(task.status, AgentTeamTaskStatus::InProgress); - } - other => panic!("expected Claimed, got {other:?}"), - } - - let second = claim_agent_team_task(&config, "team-1", "task-a", "m2", "tok-2").unwrap(); - assert_eq!(second, ClaimOutcome::AlreadyClaimed); - } - - #[test] - fn claim_unknown_task_returns_unknown() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - seed_team(&config, "team-1"); - let outcome = claim_agent_team_task(&config, "team-1", "ghost", "m1", "tok").unwrap(); - assert_eq!(outcome, ClaimOutcome::UnknownTask); - } - - fn seed_member(config: &Config, team_id: &str, member_id: &str) { - upsert_agent_team_member( - config, - AgentTeamMemberUpsert { - id: member_id.into(), - team_id: team_id.into(), - name: member_id.into(), - agent_id: None, - member_status: AgentTeamMemberStatus::Pending, - current_task_id: None, - worker_thread_id: None, - run_id: None, - created_at: None, - }, - ) - .unwrap(); - } - - #[test] - fn mark_member_running_then_idle_keeps_run_pointer() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - seed_team(&config, "team-1"); - seed_member(&config, "team-1", "m1"); - seed_task(&config, "team-1", "task-a", vec![]); - claim_agent_team_task(&config, "team-1", "task-a", "m1", "tok-1").unwrap(); - - let running = - mark_agent_team_member_running(&config, "team-1", "m1", "task-a", "worker-x", "run-x") - .unwrap() - .expect("member updated"); - assert_eq!(running.member_status, AgentTeamMemberStatus::Active); - assert_eq!(running.current_task_id.as_deref(), Some("task-a")); - assert_eq!(running.worker_thread_id.as_deref(), Some("worker-x")); - assert_eq!(running.run_id.as_deref(), Some("run-x")); - - let idle = mark_agent_team_member_idle(&config, "team-1", "m1") - .unwrap() - .expect("member updated"); - assert_eq!(idle.member_status, AgentTeamMemberStatus::Idle); - assert_eq!(idle.current_task_id, None); - // worker/run pointer retained as last-run history. - assert_eq!(idle.worker_thread_id.as_deref(), Some("worker-x")); - assert_eq!(idle.run_id.as_deref(), Some("run-x")); - - // Unknown member → None, no-op. - assert!( - mark_agent_team_member_running(&config, "team-1", "ghost", "task-a", "w", "r") - .unwrap() - .is_none() - ); - assert!(mark_agent_team_member_idle(&config, "team-1", "ghost") - .unwrap() - .is_none()); - } - - #[test] - fn release_task_frees_in_progress_only() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - seed_team(&config, "team-1"); - seed_member(&config, "team-1", "m1"); - seed_task(&config, "team-1", "task-a", vec![]); - claim_agent_team_task(&config, "team-1", "task-a", "m1", "tok-1").unwrap(); - - // In progress → released back to todo, claim cleared, gate reset. - assert!(release_agent_team_task(&config, "team-1", "task-a").unwrap()); - let task = get_agent_team_task(&config, "task-a").unwrap().unwrap(); - assert_eq!(task.status, AgentTeamTaskStatus::Todo); - assert_eq!(task.claimed_by_member_id, None); - assert_eq!(task.claim_token, None); - assert_eq!(task.gate_status, "pending"); - - // Already todo (not in_progress) → no-op, returns false. - assert!(!release_agent_team_task(&config, "team-1", "task-a").unwrap()); - // Unknown task → false. - assert!(!release_agent_team_task(&config, "team-1", "ghost").unwrap()); - } - - #[test] - fn claim_blocked_until_dependency_done() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - seed_team(&config, "team-1"); - seed_task(&config, "team-1", "task-a", vec![]); - seed_task(&config, "team-1", "task-b", vec!["task-a".into()]); - - // B is blocked while A is still todo. - let blocked = claim_agent_team_task(&config, "team-1", "task-b", "m1", "tok").unwrap(); - assert_eq!( - blocked, - ClaimOutcome::Blocked { - unmet: vec!["task-a".into()] - } - ); - - // Mark A done, then B claims fine. - upsert_agent_team_task( - &config, - AgentTeamTaskUpsert { - id: "task-a".into(), - team_id: "team-1".into(), - title: "task task-a".into(), - objective: None, - status: AgentTeamTaskStatus::Done, - owner_member_id: None, - depends_on: vec![], - gate_status: None, - gate_reason: None, - evidence: vec![], - source_run_id: None, - order_index: 0, - created_at: None, - }, - ) - .unwrap(); - - let ok = claim_agent_team_task(&config, "team-1", "task-b", "m1", "tok").unwrap(); - assert!(matches!(ok, ClaimOutcome::Claimed(_))); - } - - #[test] - fn team_members_and_tasks_list_back() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - seed_team(&config, "team-1"); - upsert_agent_team_member( - &config, - AgentTeamMemberUpsert { - id: "mem-1".into(), - team_id: "team-1".into(), - name: "alice".into(), - agent_id: Some("researcher".into()), - member_status: AgentTeamMemberStatus::Active, - current_task_id: None, - worker_thread_id: None, - run_id: None, - created_at: None, - }, - ) - .unwrap(); - seed_task(&config, "team-1", "task-a", vec![]); - - let members = list_agent_team_members(&config, "team-1").unwrap(); - assert_eq!(members.len(), 1); - assert_eq!(members[0].name, "alice"); - - let tasks = list_agent_team_tasks(&config, "team-1").unwrap(); - assert_eq!(tasks.len(), 1); - assert_eq!(tasks[0].id, "task-a"); - - let teams = list_agent_teams(&config, &AgentTeamListRequest::default()).unwrap(); - assert_eq!(teams.count, 1); - } - - fn seed_run(config: &Config, id: &str, status: AgentRunStatus) { - upsert_agent_run( - config, - AgentRunUpsert { - id: id.into(), - kind: super::super::types::AgentRunKind::Subagent, - parent_run_id: None, - parent_thread_id: Some("thread-1".into()), - agent_id: Some("tinyplace_agent".into()), - status, - prompt_ref: None, - worker_thread_id: None, - task_board_id: None, - task_card_id: None, - checkpoint_path: None, - checkpoint: None, - summary: None, - error: None, - metadata: json!({}), - started_at: None, - completed_at: None, - }, - ) - .unwrap(); - } - - #[test] - fn interrupt_orphaned_runs_settles_only_non_terminal_inflight_rows() { - let dir = TempDir::new().unwrap(); - let config = test_config(&dir); - - seed_run(&config, "run-running", AgentRunStatus::Running); - seed_run(&config, "run-pending", AgentRunStatus::Pending); - seed_run(&config, "run-completed", AgentRunStatus::Completed); - seed_run(&config, "run-awaiting", AgentRunStatus::AwaitingUser); - - let settled = interrupt_orphaned_agent_runs(&config).unwrap(); - assert_eq!(settled, 2, "only running + pending are orphaned at boot"); - - let get = |id: &str| get_agent_run(&config, id).unwrap().expect("run present"); - // Orphaned in-flight rows become terminal `interrupted` with a completion time… - let running = get("run-running"); - assert_eq!(running.status, AgentRunStatus::Interrupted); - assert!(running.completed_at.is_some()); - assert_eq!(get("run-pending").status, AgentRunStatus::Interrupted); - // …already-terminal and resumable rows are untouched. - assert_eq!(get("run-completed").status, AgentRunStatus::Completed); - assert_eq!(get("run-awaiting").status, AgentRunStatus::AwaitingUser); - - // Idempotent: a second sweep finds nothing left to settle. - assert_eq!(interrupt_orphaned_agent_runs(&config).unwrap(), 0); - } -} diff --git a/src/openhuman/agent/session_db/run_ledger/store.rs b/src/openhuman/agent/session_db/run_ledger/store.rs deleted file mode 100644 index 9388927016..0000000000 --- a/src/openhuman/agent/session_db/run_ledger/store.rs +++ /dev/null @@ -1,125 +0,0 @@ -use anyhow::{Context, Result}; -use rusqlite::Connection; - -pub(crate) fn init_run_ledger_schema(conn: &Connection) -> Result<()> { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS agent_runs ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - parent_run_id TEXT, - parent_thread_id TEXT, - agent_id TEXT, - status TEXT NOT NULL, - prompt_ref TEXT, - worker_thread_id TEXT, - task_board_id TEXT, - task_card_id TEXT, - checkpoint_path TEXT, - checkpoint_json TEXT, - summary TEXT, - error TEXT, - metadata_json TEXT NOT NULL DEFAULT '{}', - started_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status); - CREATE INDEX IF NOT EXISTS idx_agent_runs_kind ON agent_runs(kind); - CREATE INDEX IF NOT EXISTS idx_agent_runs_parent ON agent_runs(parent_run_id); - CREATE INDEX IF NOT EXISTS idx_agent_runs_thread ON agent_runs(parent_thread_id); - CREATE INDEX IF NOT EXISTS idx_agent_runs_updated ON agent_runs(updated_at); - CREATE INDEX IF NOT EXISTS idx_agent_runs_worker_thread ON agent_runs(worker_thread_id); - - CREATE TABLE IF NOT EXISTS workflow_runs ( - id TEXT PRIMARY KEY, - definition_id TEXT NOT NULL, - parent_thread_id TEXT, - input_json TEXT NOT NULL DEFAULT '{}', - phase_states_json TEXT NOT NULL DEFAULT '{}', - child_run_ids_json TEXT NOT NULL DEFAULT '[]', - status TEXT NOT NULL, - summary TEXT, - started_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_workflow_runs_definition ON workflow_runs(definition_id); - CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status); - CREATE INDEX IF NOT EXISTS idx_workflow_runs_thread ON workflow_runs(parent_thread_id); - - CREATE TABLE IF NOT EXISTS run_events ( - run_id TEXT NOT NULL, - sequence INTEGER NOT NULL, - event_type TEXT NOT NULL, - payload_json TEXT NOT NULL DEFAULT '{}', - timestamp TEXT NOT NULL, - PRIMARY KEY (run_id, sequence) - ); - CREATE INDEX IF NOT EXISTS idx_run_events_timestamp ON run_events(timestamp); - - CREATE TABLE IF NOT EXISTS run_telemetry ( - run_id TEXT PRIMARY KEY, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cached_input_tokens INTEGER NOT NULL DEFAULT 0, - cost_usd REAL NOT NULL DEFAULT 0.0, - elapsed_ms INTEGER, - tool_count INTEGER NOT NULL DEFAULT 0, - model TEXT, - provider TEXT, - error TEXT, - updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS agent_teams ( - id TEXT PRIMARY KEY, - parent_thread_id TEXT, - lead_agent_id TEXT NOT NULL, - status TEXT NOT NULL, - summary TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - closed_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_agent_teams_thread ON agent_teams(parent_thread_id); - CREATE INDEX IF NOT EXISTS idx_agent_teams_status ON agent_teams(status); - - CREATE TABLE IF NOT EXISTS agent_team_members ( - id TEXT PRIMARY KEY, - team_id TEXT NOT NULL, - name TEXT NOT NULL, - agent_id TEXT, - member_status TEXT NOT NULL, - current_task_id TEXT, - worker_thread_id TEXT, - run_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(team_id, name) - ); - CREATE INDEX IF NOT EXISTS idx_agent_team_members_team ON agent_team_members(team_id); - - CREATE TABLE IF NOT EXISTS agent_team_tasks ( - id TEXT PRIMARY KEY, - team_id TEXT NOT NULL, - title TEXT NOT NULL, - objective TEXT, - status TEXT NOT NULL, - owner_member_id TEXT, - claimed_by_member_id TEXT, - claim_token TEXT, - depends_on_json TEXT NOT NULL DEFAULT '[]', - gate_status TEXT NOT NULL DEFAULT 'pending', - gate_reason TEXT, - evidence_json TEXT NOT NULL DEFAULT '[]', - source_run_id TEXT, - order_index INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_agent_team_tasks_team ON agent_team_tasks(team_id); - CREATE INDEX IF NOT EXISTS idx_agent_team_tasks_status ON agent_team_tasks(status); - CREATE INDEX IF NOT EXISTS idx_agent_team_tasks_claimed ON agent_team_tasks(claimed_by_member_id);", - ) - .context("failed to initialize run ledger schema") -} diff --git a/src/openhuman/agent/session_db/run_ledger/types.rs b/src/openhuman/agent/session_db/run_ledger/types.rs deleted file mode 100644 index 1f093e0b2a..0000000000 --- a/src/openhuman/agent/session_db/run_ledger/types.rs +++ /dev/null @@ -1,547 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentRunKind { - Subagent, - WorkerThread, - BackgroundAgent, - TeamMember, - WorkflowChild, -} - -impl AgentRunKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Subagent => "subagent", - Self::WorkerThread => "worker_thread", - Self::BackgroundAgent => "background_agent", - Self::TeamMember => "team_member", - Self::WorkflowChild => "workflow_child", - } - } - - pub fn parse(raw: &str) -> Self { - match raw { - "worker_thread" => Self::WorkerThread, - "background_agent" => Self::BackgroundAgent, - "team_member" => Self::TeamMember, - "workflow_child" => Self::WorkflowChild, - _ => Self::Subagent, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentRunStatus { - Pending, - Running, - AwaitingUser, - Paused, - Completed, - Failed, - Cancelled, - Interrupted, -} - -impl AgentRunStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Pending => "pending", - Self::Running => "running", - Self::AwaitingUser => "awaiting_user", - Self::Paused => "paused", - Self::Completed => "completed", - Self::Failed => "failed", - Self::Cancelled => "cancelled", - Self::Interrupted => "interrupted", - } - } - - pub fn parse(raw: &str) -> Self { - match raw { - "running" => Self::Running, - "awaiting_user" => Self::AwaitingUser, - "paused" => Self::Paused, - "completed" => Self::Completed, - "failed" => Self::Failed, - "cancelled" => Self::Cancelled, - "interrupted" => Self::Interrupted, - _ => Self::Pending, - } - } - - pub fn is_terminal(self) -> bool { - matches!( - self, - Self::Completed | Self::Failed | Self::Cancelled | Self::Interrupted - ) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkflowRunStatus { - Pending, - Running, - Completed, - Failed, - Cancelled, - Interrupted, -} - -impl WorkflowRunStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Pending => "pending", - Self::Running => "running", - Self::Completed => "completed", - Self::Failed => "failed", - Self::Cancelled => "cancelled", - Self::Interrupted => "interrupted", - } - } - - pub fn parse(raw: &str) -> Self { - match raw { - "running" => Self::Running, - "completed" => Self::Completed, - "failed" => Self::Failed, - "cancelled" => Self::Cancelled, - "interrupted" => Self::Interrupted, - _ => Self::Pending, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentRun { - pub id: String, - pub kind: AgentRunKind, - pub parent_run_id: Option, - pub parent_thread_id: Option, - pub agent_id: Option, - pub status: AgentRunStatus, - pub prompt_ref: Option, - pub worker_thread_id: Option, - pub task_board_id: Option, - pub task_card_id: Option, - pub checkpoint_path: Option, - pub checkpoint: Option, - pub summary: Option, - pub error: Option, - pub metadata: Value, - pub telemetry: Option, - pub started_at: DateTime, - pub updated_at: DateTime, - pub completed_at: Option>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkflowRun { - pub id: String, - pub definition_id: String, - pub parent_thread_id: Option, - pub input: Value, - pub phase_states: Value, - pub child_run_ids: Vec, - pub status: WorkflowRunStatus, - pub summary: Option, - pub started_at: DateTime, - pub updated_at: DateTime, - pub completed_at: Option>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunEvent { - pub run_id: String, - pub sequence: u64, - pub event_type: String, - pub payload: Value, - pub timestamp: DateTime, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct RunTelemetry { - pub run_id: String, - pub input_tokens: u64, - pub output_tokens: u64, - pub cached_input_tokens: u64, - pub cost_usd: f64, - pub elapsed_ms: Option, - pub tool_count: u64, - pub model: Option, - pub provider: Option, - pub error: Option, - pub updated_at: Option>, -} - -#[derive(Debug, Clone)] -pub struct AgentRunUpsert { - pub id: String, - pub kind: AgentRunKind, - pub parent_run_id: Option, - pub parent_thread_id: Option, - pub agent_id: Option, - pub status: AgentRunStatus, - pub prompt_ref: Option, - pub worker_thread_id: Option, - pub task_board_id: Option, - pub task_card_id: Option, - pub checkpoint_path: Option, - pub checkpoint: Option, - pub summary: Option, - pub error: Option, - pub metadata: Value, - pub started_at: Option>, - pub completed_at: Option>, -} - -#[derive(Debug, Clone)] -pub struct WorkflowRunUpsert { - pub id: String, - pub definition_id: String, - pub parent_thread_id: Option, - pub input: Value, - pub phase_states: Value, - pub child_run_ids: Vec, - pub status: WorkflowRunStatus, - pub summary: Option, - pub started_at: Option>, - pub completed_at: Option>, -} - -#[derive(Debug, Clone)] -pub struct RunEventAppend { - pub run_id: String, - pub event_type: String, - pub payload: Value, -} - -#[derive(Debug, Clone, Default)] -pub struct RunTelemetryUpsert { - pub run_id: String, - pub input_tokens: Option, - pub output_tokens: Option, - pub cached_input_tokens: Option, - pub cost_usd: Option, - pub elapsed_ms: Option, - pub tool_count: Option, - pub model: Option, - pub provider: Option, - pub error: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentRunListRequest { - #[serde(default)] - pub status: Option, - #[serde(default)] - pub kind: Option, - #[serde(default)] - pub parent_run_id: Option, - #[serde(default)] - pub parent_thread_id: Option, - #[serde(default)] - pub limit: Option, - #[serde(default)] - pub offset: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentRunListResponse { - pub runs: Vec, - pub count: usize, -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkflowRunListRequest { - #[serde(default)] - pub definition_id: Option, - #[serde(default)] - pub status: Option, - #[serde(default)] - pub parent_thread_id: Option, - /// `u64` to match the `TypeSchema::U64` the controller advertises (the RPC - /// scalar-coercion layer only handles `U64`). Capped at 500 in `list_workflow_runs`. - #[serde(default)] - pub limit: Option, - #[serde(default)] - pub offset: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorkflowRunListResponse { - pub runs: Vec, - pub count: usize, -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunEventListRequest { - pub run_id: String, - #[serde(default)] - pub after_sequence: Option, - #[serde(default)] - pub limit: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RunEventListResponse { - pub events: Vec, - pub count: usize, -} - -// --------------------------------------------------------------------------- -// Agent-team coordination (issue #3374) -// --------------------------------------------------------------------------- - -/// Lifecycle of an agent team. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentTeamStatus { - Active, - Closed, -} - -impl AgentTeamStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Active => "active", - Self::Closed => "closed", - } - } - - /// Parse a stored status string (named `parse`, not `from_str`, to match the - /// run-ledger status-enum convention and avoid the `FromStr` clippy lint). - pub fn parse(raw: &str) -> Self { - match raw { - "closed" => Self::Closed, - _ => Self::Active, - } - } -} - -/// Lifecycle of a single team member. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentTeamMemberStatus { - Pending, - Active, - Idle, - Stopped, -} - -impl AgentTeamMemberStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Pending => "pending", - Self::Active => "active", - Self::Idle => "idle", - Self::Stopped => "stopped", - } - } - - pub fn parse(raw: &str) -> Self { - match raw { - "active" => Self::Active, - "idle" => Self::Idle, - "stopped" => Self::Stopped, - _ => Self::Pending, - } - } -} - -/// Lifecycle of a coordination task within a team. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentTeamTaskStatus { - Todo, - Ready, - InProgress, - Blocked, - Done, -} - -impl AgentTeamTaskStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Todo => "todo", - Self::Ready => "ready", - Self::InProgress => "in_progress", - Self::Blocked => "blocked", - Self::Done => "done", - } - } - - pub fn parse(raw: &str) -> Self { - match raw { - "ready" => Self::Ready, - "in_progress" => Self::InProgress, - "blocked" => Self::Blocked, - "done" => Self::Done, - _ => Self::Todo, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentTeam { - pub id: String, - pub parent_thread_id: Option, - pub lead_agent_id: String, - pub status: AgentTeamStatus, - pub summary: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub closed_at: Option>, -} - -#[derive(Debug, Clone)] -pub struct AgentTeamUpsert { - pub id: String, - pub parent_thread_id: Option, - pub lead_agent_id: String, - pub status: AgentTeamStatus, - pub summary: Option, - pub created_at: Option>, - pub closed_at: Option>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentTeamMember { - pub id: String, - pub team_id: String, - pub name: String, - pub agent_id: Option, - pub member_status: AgentTeamMemberStatus, - pub current_task_id: Option, - pub worker_thread_id: Option, - pub run_id: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone)] -pub struct AgentTeamMemberUpsert { - pub id: String, - pub team_id: String, - pub name: String, - pub agent_id: Option, - pub member_status: AgentTeamMemberStatus, - pub current_task_id: Option, - pub worker_thread_id: Option, - pub run_id: Option, - pub created_at: Option>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentTeamTask { - pub id: String, - pub team_id: String, - pub title: String, - pub objective: Option, - pub status: AgentTeamTaskStatus, - pub owner_member_id: Option, - pub claimed_by_member_id: Option, - pub claim_token: Option, - pub depends_on: Vec, - pub gate_status: String, - pub gate_reason: Option, - pub evidence: Vec, - pub source_run_id: Option, - pub order_index: i64, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone)] -pub struct AgentTeamTaskUpsert { - pub id: String, - pub team_id: String, - pub title: String, - pub objective: Option, - pub status: AgentTeamTaskStatus, - pub owner_member_id: Option, - pub depends_on: Vec, - pub gate_status: Option, - pub gate_reason: Option, - pub evidence: Vec, - pub source_run_id: Option, - pub order_index: i64, - pub created_at: Option>, -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentTeamListRequest { - #[serde(default)] - pub parent_thread_id: Option, - #[serde(default)] - pub status: Option, - /// `u64` to match the `TypeSchema::U64` the controller advertises (the RPC - /// scalar-coercion layer only handles `U64`). Capped at 500 in - /// `list_agent_teams`. - #[serde(default)] - pub limit: Option, - #[serde(default)] - pub offset: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentTeamListResponse { - pub teams: Vec, - pub count: usize, -} - -/// Outcome of an atomic claim attempt on a team task. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum ClaimOutcome { - /// The claim succeeded; carries the freshly-claimed task. Boxed to keep the - /// enum small (the task payload dwarfs the other variants). - Claimed(Box), - /// Another member already holds the claim. - AlreadyClaimed, - /// One or more dependency tasks are not yet `done`. - Blocked { unmet: Vec }, - /// No task matched the given team + task id. - UnknownTask, -} - -/// Outcome of a completion attempt on a team task. -/// -/// Completion gates a task's transition to `done` behind quality invariants -/// (dependencies done, claimer owns the task, evidence present when required). -/// A failed gate leaves the task `in_progress` with `gate_status = "failed"` -/// and the reasons recorded, so a teammate can fix and retry. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum CompletionOutcome { - /// The task passed its quality gate and is now `done`. Boxed to keep the - /// enum small (the task payload dwarfs the other variants). - Completed(Box), - /// One or more quality-gate invariants failed; carries human-readable - /// reasons for each unmet invariant. - GateFailed { reasons: Vec }, - /// The task is not claimed by the completing member, or is not in progress. - NotClaimed, - /// No task matched the given team + task id. - UnknownTask, -} diff --git a/src/openhuman/agent/session_db/schemas.rs b/src/openhuman/agent/session_db/schemas.rs index 7694ab1998..247e001105 100644 --- a/src/openhuman/agent/session_db/schemas.rs +++ b/src/openhuman/agent/session_db/schemas.rs @@ -7,8 +7,8 @@ use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::openhuman::config::rpc as config_rpc; use crate::rpc::RpcOutcome; -use super::run_ledger::{AgentRunListRequest, RunEventListRequest}; -use super::types::SessionSearchParams; +use tinyagents::session::run_ledger::{AgentRunListRequest, RunEventListRequest}; +use tinyagents::session::types::SessionSearchParams; pub fn all_controller_schemas() -> Vec { vec![ @@ -230,8 +230,8 @@ fn handle_session_db_list(params: Map) -> ControllerFuture { .and_then(|v| v.as_str()) .map(String::from); - let result = super::ops::list_sessions( - &config, + let result = tinyagents::session::list_sessions( + &config.workspace_dir, limit, offset, status.as_deref(), @@ -262,7 +262,7 @@ fn handle_session_db_get(params: Map) -> ControllerFuture { .and_then(|v| v.as_str()) .ok_or_else(|| "missing required param: id".to_string())?; - let session = super::ops::get_session(&config, id).map_err(|e| { + let session = tinyagents::session::get_session(&config.workspace_dir, id).map_err(|e| { let s = e.to_string(); log::warn!(target: "session_db_rpc", "[session_db_rpc][{cid}] get.error id={id} err={s}"); s @@ -292,7 +292,11 @@ fn handle_session_db_search(params: Map) -> ControllerFuture { })? }; - let result = super::ops::search_sessions(&config, &search_params).map_err(|e| { + let result = tinyagents::session::search_sessions( + &config.workspace_dir, + &search_params, + ) + .map_err(|e| { let s = e.to_string(); log::warn!(target: "session_db_rpc", "[session_db_rpc][{cid}] search.error err={s}"); s @@ -321,7 +325,7 @@ fn handle_session_db_get_messages(params: Map) -> ControllerFutur .and_then(|v| v.as_u64()) .map(|v| v as u32); - let messages = super::ops::list_messages(&config, session_id, limit).map_err(|e| { + let messages = tinyagents::session::list_messages(&config.workspace_dir, session_id, limit).map_err(|e| { let s = e.to_string(); log::warn!(target: "session_db_rpc", "[session_db_rpc][{cid}] get_messages.error err={s}"); s @@ -350,7 +354,7 @@ fn handle_session_db_get_tool_calls(params: Map) -> ControllerFut .and_then(|v| v.as_u64()) .map(|v| v as u32); - let tool_calls = super::ops::list_tool_calls(&config, session_id, limit).map_err(|e| { + let tool_calls = tinyagents::session::list_tool_calls(&config.workspace_dir, session_id, limit).map_err(|e| { let s = e.to_string(); log::warn!(target: "session_db_rpc", "[session_db_rpc][{cid}] get_tool_calls.error err={s}"); s @@ -375,7 +379,7 @@ fn handle_session_db_get_children(params: Map) -> ControllerFutur .and_then(|v| v.as_str()) .ok_or_else(|| "missing required param: sessionId".to_string())?; - let children = super::ops::list_children(&config, session_id).map_err(|e| { + let children = tinyagents::session::list_children(&config.workspace_dir, session_id).map_err(|e| { let s = e.to_string(); log::warn!(target: "session_db_rpc", "[session_db_rpc][{cid}] get_children.error err={s}"); s @@ -403,7 +407,11 @@ fn handle_run_ledger_list(params: Map) -> ControllerFuture { s })? }; - let response = super::run_ledger::list_agent_runs(&config, &request).map_err(|e| { + let response = tinyagents::session::run_ledger::list_agent_runs( + &config.workspace_dir, + &request, + ) + .map_err(|e| { let s = e.to_string(); log::warn!(target: "run_ledger_rpc", "[run_ledger_rpc][{cid}] list.error err={s}"); s @@ -423,7 +431,7 @@ fn handle_run_ledger_get(params: Map) -> ControllerFuture { .get("id") .and_then(|v| v.as_str()) .ok_or_else(|| "missing required param: id".to_string())?; - let run = super::run_ledger::get_agent_run(&config, id).map_err(|e| { + let run = tinyagents::session::run_ledger::get_agent_run(&config.workspace_dir, id).map_err(|e| { let s = e.to_string(); log::warn!(target: "run_ledger_rpc", "[run_ledger_rpc][{cid}] get.error id={id} err={s}"); s @@ -445,12 +453,15 @@ fn handle_run_ledger_events(params: Map) -> ControllerFuture { log::warn!(target: "run_ledger_rpc", "[run_ledger_rpc][{cid}] events.bad_params err={s}"); s })?; - let response = - super::run_ledger::list_recent_run_events(&config, &request).map_err(|e| { - let s = e.to_string(); - log::warn!(target: "run_ledger_rpc", "[run_ledger_rpc][{cid}] events.error err={s}"); - s - })?; + let response = tinyagents::session::run_ledger::list_recent_run_events( + &config.workspace_dir, + &request, + ) + .map_err(|e| { + let s = e.to_string(); + log::warn!(target: "run_ledger_rpc", "[run_ledger_rpc][{cid}] events.error err={s}"); + s + })?; to_json(response) }) } diff --git a/src/openhuman/agent/session_db/store.rs b/src/openhuman/agent/session_db/store.rs deleted file mode 100644 index 75c8d95389..0000000000 --- a/src/openhuman/agent/session_db/store.rs +++ /dev/null @@ -1,175 +0,0 @@ -use crate::openhuman::config::Config; -use anyhow::{Context, Result}; -use rusqlite::Connection; - -pub fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) -> Result { - let db_path = config.workspace_dir.join("session_db").join("sessions.db"); - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent).with_context(|| { - format!( - "failed to create session_db directory: {}", - parent.display() - ) - })?; - } - - let conn = Connection::open(&db_path) - .with_context(|| format!("failed to open session DB: {}", db_path.display()))?; - - init_schema(&conn)?; - f(&conn) -} - -#[cfg(test)] -pub fn with_memory_connection(f: impl FnOnce(&Connection) -> Result) -> Result { - let conn = Connection::open_in_memory().context("failed to open in-memory session DB")?; - init_schema(&conn)?; - f(&conn) -} - -fn init_schema(conn: &Connection) -> Result<()> { - conn.execute_batch( - "PRAGMA journal_mode = WAL; - PRAGMA foreign_keys = ON; - - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - agent_definition_id TEXT NOT NULL, - agent_definition_name TEXT NOT NULL, - session_key TEXT NOT NULL, - parent_session_id TEXT, - thread_id TEXT, - source_channel TEXT, - status TEXT NOT NULL DEFAULT 'running', - model TEXT, - turn_count INTEGER NOT NULL DEFAULT 0, - input_tokens INTEGER NOT NULL DEFAULT 0, - output_tokens INTEGER NOT NULL DEFAULT 0, - cached_input_tokens INTEGER NOT NULL DEFAULT 0, - cost_usd REAL NOT NULL DEFAULT 0.0, - transcript_path TEXT, - started_at TEXT NOT NULL, - ended_at TEXT, - FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ON DELETE SET NULL - ); - CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_definition_id); - CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); - CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at); - CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id); - CREATE INDEX IF NOT EXISTS idx_sessions_thread ON sessions(thread_id); - CREATE INDEX IF NOT EXISTS idx_sessions_channel ON sessions(source_channel); - CREATE INDEX IF NOT EXISTS idx_sessions_key ON sessions(session_key); - - CREATE TABLE IF NOT EXISTS session_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - model TEXT, - input_tokens INTEGER, - output_tokens INTEGER, - cost_usd REAL, - created_at TEXT NOT NULL, - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_messages_session ON session_messages(session_id); - - CREATE TABLE IF NOT EXISTS session_tool_calls ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - message_id INTEGER, - tool_name TEXT NOT NULL, - tool_input TEXT, - tool_output TEXT, - status TEXT NOT NULL DEFAULT 'pending', - duration_ms INTEGER, - created_at TEXT NOT NULL, - FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, - FOREIGN KEY (message_id) REFERENCES session_messages(id) ON DELETE SET NULL - ); - CREATE INDEX IF NOT EXISTS idx_tool_calls_session ON session_tool_calls(session_id); - CREATE INDEX IF NOT EXISTS idx_tool_calls_name ON session_tool_calls(tool_name);", - ) - .context("failed to initialize session_db schema")?; - - init_fts(conn)?; - Ok(()) -} - -fn init_fts(conn: &Connection) -> Result<()> { - let has_fts: bool = conn - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='sessions_fts'")? - .exists([])?; - - if !has_fts { - conn.execute_batch( - "CREATE VIRTUAL TABLE sessions_fts USING fts5( - session_id, - agent_definition_name, - content, - tool_name - );", - ) - .context("failed to create sessions_fts virtual table")?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn schema_initializes_without_error() { - with_memory_connection(|conn| { - let count: i64 = conn.query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))?; - assert_eq!(count, 0); - Ok(()) - }) - .unwrap(); - } - - #[test] - fn schema_is_idempotent() { - let conn = Connection::open_in_memory().unwrap(); - init_schema(&conn).unwrap(); - init_schema(&conn).unwrap(); - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 0); - } - - #[test] - fn wal_mode_is_set() { - with_memory_connection(|conn| { - let mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0))?; - // In-memory DBs may report "memory" instead of "wal" - assert!(mode == "wal" || mode == "memory"); - Ok(()) - }) - .unwrap(); - } - - #[test] - fn fts_table_exists_after_init() { - with_memory_connection(|conn| { - let exists: bool = conn - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='sessions_fts'")? - .exists([])?; - assert!(exists); - Ok(()) - }) - .unwrap(); - } - - #[test] - fn foreign_keys_are_enabled() { - with_memory_connection(|conn| { - let fk: i64 = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?; - assert_eq!(fk, 1); - Ok(()) - }) - .unwrap(); - } -} diff --git a/src/openhuman/agent/session_db/types.rs b/src/openhuman/agent/session_db/types.rs deleted file mode 100644 index b6082059e0..0000000000 --- a/src/openhuman/agent/session_db/types.rs +++ /dev/null @@ -1,148 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SessionStatus { - Running, - Completed, - Failed, - Interrupted, -} - -impl SessionStatus { - pub fn as_str(&self) -> &'static str { - match self { - Self::Running => "running", - Self::Completed => "completed", - Self::Failed => "failed", - Self::Interrupted => "interrupted", - } - } - - pub fn parse(s: &str) -> Self { - match s { - "completed" => Self::Completed, - "failed" => Self::Failed, - "interrupted" => Self::Interrupted, - _ => Self::Running, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionRecord { - pub id: String, - pub agent_definition_id: String, - pub agent_definition_name: String, - pub session_key: String, - pub parent_session_id: Option, - pub thread_id: Option, - pub source_channel: Option, - pub status: SessionStatus, - pub model: Option, - pub turn_count: u32, - pub input_tokens: u64, - pub output_tokens: u64, - pub cached_input_tokens: u64, - pub cost_usd: f64, - pub transcript_path: Option, - pub started_at: DateTime, - pub ended_at: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionMessage { - pub id: i64, - pub session_id: String, - pub role: String, - pub content: String, - pub model: Option, - pub input_tokens: Option, - pub output_tokens: Option, - pub cost_usd: Option, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionToolCall { - pub id: i64, - pub session_id: String, - pub message_id: Option, - pub tool_name: String, - pub tool_input: Option, - pub tool_output: Option, - pub status: String, - pub duration_ms: Option, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionSearchParams { - #[serde(default)] - pub query: Option, - #[serde(default)] - pub agent_id: Option, - #[serde(default)] - pub tool_name: Option, - #[serde(default)] - pub source_channel: Option, - #[serde(default)] - pub parent_session_id: Option, - #[serde(default)] - pub status: Option, - #[serde(default)] - pub thread_id: Option, - #[serde(default)] - pub limit: Option, - #[serde(default)] - pub offset: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionSearchResult { - pub sessions: Vec, - pub total: u64, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn session_status_roundtrip() { - for status in [ - SessionStatus::Running, - SessionStatus::Completed, - SessionStatus::Failed, - SessionStatus::Interrupted, - ] { - assert_eq!(SessionStatus::parse(status.as_str()), status); - } - } - - #[test] - fn session_status_parse_unknown_defaults_to_running() { - assert_eq!(SessionStatus::parse("bogus"), SessionStatus::Running); - assert_eq!(SessionStatus::parse(""), SessionStatus::Running); - } - - #[test] - fn session_status_serde_roundtrip() { - let status = SessionStatus::Completed; - let json = serde_json::to_string(&status).unwrap(); - assert_eq!(json, "\"completed\""); - let parsed: SessionStatus = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, status); - } - - #[test] - fn session_search_params_defaults() { - let params = SessionSearchParams::default(); - assert!(params.query.is_none()); - assert!(params.agent_id.is_none()); - assert!(params.limit.is_none()); - assert!(params.offset.is_none()); - } -} diff --git a/src/openhuman/hosted/orchestration/ops.rs b/src/openhuman/hosted/orchestration/ops.rs index ecca56ab0e..63a4647e69 100644 --- a/src/openhuman/hosted/orchestration/ops.rs +++ b/src/openhuman/hosted/orchestration/ops.rs @@ -223,9 +223,7 @@ pub(super) fn command_center_needs_input( config: &Config, ) -> Vec { use crate::openhuman::agent::orchestration::command_center::build_view; - use crate::openhuman::agent::session_db::run_ledger::{ - list_agent_runs, AgentRunListRequest, AgentRunStatus, - }; + use tinyagents::session::run_ledger::{list_agent_runs, AgentRunListRequest, AgentRunStatus}; let request = AgentRunListRequest { status: Some(AgentRunStatus::AwaitingUser.as_str().to_string()), kind: None, @@ -234,7 +232,7 @@ pub(super) fn command_center_needs_input( limit: Some(ATTENTION_RUN_LIMIT), offset: None, }; - match list_agent_runs(config, &request) { + match list_agent_runs(&config.workspace_dir, &request) { Ok(response) => { super::attention::needs_input_from_command_center(build_view(response.runs)) } @@ -354,7 +352,7 @@ mod tests { #[test] fn command_center_needs_input_surfaces_only_blocked_runs() { - use crate::openhuman::agent::session_db::run_ledger::{ + use tinyagents::session::run_ledger::{ upsert_agent_run, AgentRunKind, AgentRunStatus, AgentRunUpsert, }; let tmp = tempfile::tempdir().unwrap(); @@ -364,7 +362,7 @@ mod tests { }; let seed = |id: &str, status: AgentRunStatus| { upsert_agent_run( - &config, + &config.workspace_dir, AgentRunUpsert { id: id.into(), kind: AgentRunKind::Subagent, diff --git a/src/openhuman/threads/transcript_view/tests.rs b/src/openhuman/threads/transcript_view/tests.rs index 62f9e0b5ec..bdbb8cb950 100644 --- a/src/openhuman/threads/transcript_view/tests.rs +++ b/src/openhuman/threads/transcript_view/tests.rs @@ -387,6 +387,213 @@ fn tool_failure_metadata_round_trips_write_to_display_line() { assert_eq!(failed.failure_detail.as_deref(), Some("boom: exit 1")); } +/// The **golden test for the turn path's write call site**. +/// +/// Every other test in this file hand-writes JSONL string literals, so they pin +/// the reader/projector but say nothing about the writer the live turn loop +/// actually calls. `tool_failure_metadata_round_trips_write_to_display_line` +/// goes through `write_transcript`, not `append_transcript_turn`. That left the +/// seam this test covers — `append_transcript_turn` → `read_transcript_display` +/// → `project_thread` — with no coverage at all, which is exactly the seam the +/// tinyagents `ChatHistory` migration touches. +/// +/// It fails loudly if a write ever drops `request_id` or `turn_usage`: without +/// `request_id` there is no `DisplayItem::TurnBoundary` (project.rs +/// `maybe_emit_turn_boundary`) and `turn_segments` goes empty, unanchoring every +/// sub-agent; without `turn_usage` every `DisplayItem::ToolCall` disappears +/// (tool calls are read off `turn_usage.tool_calls`), `Reasoning` vanishes, and +/// `AssistantMessage.{model,iteration,interim}` collapse to `None`/`false`. +/// +/// All timestamps are fixed literals so nothing here is clock-dependent. +#[test] +fn append_transcript_turn_projects_full_display_shape() { + let dir = TempDir::new().unwrap(); + let now = "2026-07-21T09:00:00Z".to_string(); + let meta = transcript::TranscriptMeta { + agent_name: "orchestrator".into(), + agent_id: Some("orchestrator".into()), + agent_type: Some("root".into()), + dispatcher: "native".into(), + provider: Some("anthropic".into()), + model: Some("claude-x".into()), + created: now.clone(), + updated: now, + turn_count: 1, + input_tokens: 30, + output_tokens: 13, + cached_input_tokens: 0, + charged_amount_usd: 0.003, + thread_id: Some("thr_golden".into()), + task_id: None, + }; + + let usage = |cost: f64| transcript::MessageUsage { + input: 20, + output: 8, + cached_input: 0, + context_window: 200_000, + cost_usd: cost, + }; + + // Iteration 1 of the turn: the model reasons and emits a native tool call. + // `turn_usage` attaches to the last assistant row of the written slice, so + // this one lands on the interim assistant. + let interim_usage = transcript::TurnUsage { + provider: "anthropic".into(), + model: "claude-x".into(), + usage: usage(0.001), + ts: "2026-07-21T09:00:01Z".into(), + reasoning_content: Some("I should call the weather tool.".into()), + tool_calls: vec![crate::openhuman::inference::provider::ToolCall { + id: "call-1".into(), + name: "get_weather".into(), + arguments: r#"{"city":"NYC"}"#.into(), + extra_content: None, + }], + iteration: 1, + }; + // Iteration 2: the final answer, no further tool calls. + let final_usage = transcript::TurnUsage { + provider: "anthropic".into(), + model: "claude-x".into(), + usage: usage(0.002), + ts: "2026-07-21T09:00:02Z".into(), + reasoning_content: None, + tool_calls: vec![], + iteration: 2, + }; + + let msg = |id: Option<&str>, role: &str, content: &str| ChatMessage { + id: id.map(str::to_string), + role: role.into(), + content: content.into(), + extra_metadata: None, + }; + + let first = vec![ + msg(None, "user", "What's the weather in NYC?"), + msg(None, "assistant", "Let me check."), + ]; + let mut second = first.clone(); + second.push(msg(Some("call-1"), "tool", "72F and sunny")); + second.push(msg(None, "assistant", "It's 72F and sunny in NYC.")); + + let path = transcript::resolve_keyed_transcript_path(dir.path(), "900_orchestrator").unwrap(); + // First write creates the file (meta + all lines); the second is a pure + // extension appending only the new tail — both are the real turn-path shape. + transcript::append_transcript_turn( + &path, + &[], + &first, + &meta, + Some(&interim_usage), + Some("req-1"), + ) + .unwrap(); + transcript::append_transcript_turn( + &path, + &first, + &second, + &meta, + Some(&final_usage), + Some("req-1"), + ) + .unwrap(); + + let display = read_transcript_display(&path).unwrap(); + let items = project_records(&display.records); + + // A turn boundary must be emitted from the stamped request_id. + let boundary = items + .iter() + .find_map(|i| match i { + DisplayItem::TurnBoundary { request_id } => Some(request_id.clone()), + _ => None, + }) + .expect("turnBoundary projected from request_id"); + assert_eq!(boundary, "req-1"); + + // Reasoning comes off turn_usage.reasoning_content. + let reasoning = items + .iter() + .find_map(|i| match i { + DisplayItem::Reasoning { text } => Some(text.clone()), + _ => None, + }) + .expect("reasoning projected from turn_usage"); + assert_eq!(reasoning, "I should call the weather tool."); + + // The tool call itself is read off turn_usage.tool_calls, and pairs with the + // role:"tool" line by id. + let tool = items + .iter() + .find_map(|i| match i { + DisplayItem::ToolCall { + call_id, + name, + args, + result, + status, + .. + } => Some(( + call_id.clone(), + name.clone(), + args.clone(), + result.clone(), + *status, + )), + _ => None, + }) + .expect("toolCall projected from turn_usage.tool_calls"); + assert_eq!(tool.0, "call-1"); + assert_eq!(tool.1, "get_weather"); + assert_eq!( + tool.2 + .as_ref() + .and_then(|v| v.get("city")) + .and_then(|v| v.as_str()), + Some("NYC") + ); + assert_eq!(tool.3.as_deref(), Some("72F and sunny")); + assert_eq!(tool.4, ToolCallStatus::Success); + + // Model / iteration / request_id / interim all come off the persisted + // `turn_usage` + `request_id`; each is `None`/`false` if either is dropped. + let assistants: Vec<_> = items + .iter() + .filter_map(|i| match i { + DisplayItem::AssistantMessage { + content, + model, + iteration, + request_id, + interim, + .. + } => Some(( + content.clone(), + model.clone(), + *iteration, + request_id.clone(), + *interim, + )), + _ => None, + }) + .collect(); + assert_eq!(assistants.len(), 2, "unexpected items: {items:#?}"); + + assert_eq!(assistants[0].0, "Let me check."); + assert_eq!(assistants[0].1.as_deref(), Some("claude-x")); + assert_eq!(assistants[0].2, Some(1)); + assert_eq!(assistants[0].3.as_deref(), Some("req-1")); + assert!(assistants[0].4, "tool-calling step is interim"); + + assert_eq!(assistants[1].0, "It's 72F and sunny in NYC."); + assert_eq!(assistants[1].1.as_deref(), Some("claude-x")); + assert_eq!(assistants[1].2, Some(2)); + assert_eq!(assistants[1].3.as_deref(), Some("req-1")); + assert!(!assistants[1].4, "final answer is not interim"); +} + #[test] fn subagent_anchors_to_parent_turn_by_spawn_timestamp() { let dir = TempDir::new().unwrap(); diff --git a/src/openhuman/web_chat/progress_bridge.rs b/src/openhuman/web_chat/progress_bridge.rs index 2828184e59..215ec14da6 100644 --- a/src/openhuman/web_chat/progress_bridge.rs +++ b/src/openhuman/web_chat/progress_bridge.rs @@ -129,10 +129,10 @@ fn cap_wire_output(output: String) -> String { pub(super) fn ledger_upsert_agent_run( config: &crate::openhuman::config::Config, - upsert: crate::openhuman::agent::session_db::run_ledger::AgentRunUpsert, + upsert: tinyagents::session::run_ledger::AgentRunUpsert, ) { if let Err(err) = - crate::openhuman::agent::session_db::run_ledger::upsert_agent_run(config, upsert) + tinyagents::session::run_ledger::upsert_agent_run(&config.workspace_dir, upsert) { log::warn!("[run_ledger][web_channel] failed to upsert run: {err}"); } @@ -140,10 +140,10 @@ pub(super) fn ledger_upsert_agent_run( pub(super) fn ledger_append_event( config: &crate::openhuman::config::Config, - event: crate::openhuman::agent::session_db::run_ledger::RunEventAppend, + event: tinyagents::session::run_ledger::RunEventAppend, ) { if let Err(err) = - crate::openhuman::agent::session_db::run_ledger::append_run_event(config, event) + tinyagents::session::run_ledger::append_run_event(&config.workspace_dir, event) { log::warn!("[run_ledger][web_channel] failed to append event: {err}"); } @@ -151,10 +151,10 @@ pub(super) fn ledger_append_event( pub(super) fn ledger_upsert_telemetry( config: &crate::openhuman::config::Config, - telemetry: crate::openhuman::agent::session_db::run_ledger::RunTelemetryUpsert, + telemetry: tinyagents::session::run_ledger::RunTelemetryUpsert, ) { if let Err(err) = - crate::openhuman::agent::session_db::run_ledger::upsert_run_telemetry(config, telemetry) + tinyagents::session::run_ledger::upsert_run_telemetry(&config.workspace_dir, telemetry) { log::warn!("[run_ledger][web_channel] failed to upsert telemetry: {err}"); } @@ -163,8 +163,8 @@ pub(super) fn ledger_upsert_telemetry( pub(super) fn ledger_get_telemetry( config: &crate::openhuman::config::Config, run_id: &str, -) -> Option { - match crate::openhuman::agent::session_db::run_ledger::get_agent_run(config, run_id) { +) -> Option { + match tinyagents::session::run_ledger::get_agent_run(&config.workspace_dir, run_id) { Ok(Some(run)) => { let telemetry = run.telemetry; log::debug!( @@ -338,10 +338,10 @@ pub(crate) fn spawn_progress_bridge( config: crate::openhuman::config::Config, ) { use crate::openhuman::agent::progress::AgentProgress; - use crate::openhuman::agent::session_db::run_ledger::{ + use std::collections::HashMap; + use tinyagents::session::run_ledger::{ AgentRunKind, AgentRunStatus, AgentRunUpsert, RunEventAppend, RunTelemetryUpsert, }; - use std::collections::HashMap; tokio::spawn(async move { log::debug!( diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 971fb8b946..e8c5d2543c 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -3092,9 +3092,10 @@ async fn json_rpc_thread_generate_title_falls_back_when_provider_path_is_unavail .expect("generated title"); assert_ne!(generated_title, original_title); - assert!( - generated_title.contains("Please summarize the latest five email threads for"), - "fallback title should be derived from the first user message: {generated_title}" + assert_eq!( + generated_title, + "summarize latest five", + "fallback title should be the 3-word shape derived from the first user message (filler stripped), got: {generated_title}" ); let captured_models = with_chat_completion_models(|models| models.clone()); @@ -3338,15 +3339,15 @@ async fn json_rpc_run_ledger_lifecycle() { .await .expect("load config"); - openhuman_core::openhuman::agent::session_db::run_ledger::upsert_agent_run( - &config, - openhuman_core::openhuman::agent::session_db::run_ledger::AgentRunUpsert { + tinyagents::session::run_ledger::upsert_agent_run( + &config.workspace_dir, + tinyagents::session::run_ledger::AgentRunUpsert { id: "sub-run-1".to_string(), - kind: openhuman_core::openhuman::agent::session_db::run_ledger::AgentRunKind::WorkerThread, + kind: tinyagents::session::run_ledger::AgentRunKind::WorkerThread, parent_run_id: Some("req-run-1".to_string()), parent_thread_id: Some("thread-run-1".to_string()), agent_id: Some("researcher".to_string()), - status: openhuman_core::openhuman::agent::session_db::run_ledger::AgentRunStatus::AwaitingUser, + status: tinyagents::session::run_ledger::AgentRunStatus::AwaitingUser, prompt_ref: Some("thread:worker-1:message:seed".to_string()), worker_thread_id: Some("worker-1".to_string()), task_board_id: Some("thread-run-1".to_string()), @@ -3365,9 +3366,9 @@ async fn json_rpc_run_ledger_lifecycle() { ) .expect("seed run"); - openhuman_core::openhuman::agent::session_db::run_ledger::append_run_event( - &config, - openhuman_core::openhuman::agent::session_db::run_ledger::RunEventAppend { + tinyagents::session::run_ledger::append_run_event( + &config.workspace_dir, + tinyagents::session::run_ledger::RunEventAppend { run_id: "sub-run-1".to_string(), event_type: "subagent_awaiting_user".to_string(), payload: json!({ "question": "Which repo should I inspect?" }), @@ -3457,7 +3458,7 @@ async fn json_rpc_agent_work_list_groups_runs_by_bucket() { .await .expect("load config"); - use openhuman_core::openhuman::agent::session_db::run_ledger::{ + use tinyagents::session::run_ledger::{ upsert_agent_run, AgentRunKind, AgentRunStatus, AgentRunUpsert, }; let seed = |id: &str, status: AgentRunStatus| AgentRunUpsert { @@ -3480,10 +3481,26 @@ async fn json_rpc_agent_work_list_groups_runs_by_bucket() { completed_at: None, }; // Two awaiting-user (needs_input), one running (working), one completed. - upsert_agent_run(&config, seed("work-a", AgentRunStatus::AwaitingUser)).expect("seed a"); - upsert_agent_run(&config, seed("work-b", AgentRunStatus::AwaitingUser)).expect("seed b"); - upsert_agent_run(&config, seed("work-c", AgentRunStatus::Running)).expect("seed c"); - upsert_agent_run(&config, seed("work-d", AgentRunStatus::Completed)).expect("seed d"); + upsert_agent_run( + &config.workspace_dir, + seed("work-a", AgentRunStatus::AwaitingUser), + ) + .expect("seed a"); + upsert_agent_run( + &config.workspace_dir, + seed("work-b", AgentRunStatus::AwaitingUser), + ) + .expect("seed b"); + upsert_agent_run( + &config.workspace_dir, + seed("work-c", AgentRunStatus::Running), + ) + .expect("seed c"); + upsert_agent_run( + &config.workspace_dir, + seed("work-d", AgentRunStatus::Completed), + ) + .expect("seed d"); let list = post_json_rpc(&rpc_base, 9131, "openhuman.agent_work_list", json!({})).await; let outer = assert_no_jsonrpc_error(&list, "agent_work_list"); @@ -3572,16 +3589,16 @@ async fn json_rpc_workflow_run_definitions_and_runs_roundtrip() { ); // Seed a durable workflow run, then list + get it. - openhuman_core::openhuman::agent::session_db::run_ledger::upsert_workflow_run( - &config, - openhuman_core::openhuman::agent::session_db::run_ledger::WorkflowRunUpsert { + tinyagents::session::run_ledger::upsert_workflow_run( + &config.workspace_dir, + tinyagents::session::run_ledger::WorkflowRunUpsert { id: "wf-run-1".to_string(), definition_id: "parallel_research_cross_check".to_string(), parent_thread_id: Some("thread-wf-1".to_string()), input: json!({ "question": "test" }), phase_states: json!({ "decompose": "completed" }), child_run_ids: vec!["child-1".to_string()], - status: openhuman_core::openhuman::agent::session_db::run_ledger::WorkflowRunStatus::Running, + status: tinyagents::session::run_ledger::WorkflowRunStatus::Running, summary: None, started_at: None, completed_at: None, @@ -3770,20 +3787,18 @@ async fn json_rpc_agent_team_coordination_roundtrip() { ); // Mark A done directly via the run ledger, then B claims fine. - let task_a = openhuman_core::openhuman::agent::session_db::run_ledger::get_agent_team_task( - &config, &task_a_id, - ) - .expect("get task A") - .expect("task A present"); - openhuman_core::openhuman::agent::session_db::run_ledger::upsert_agent_team_task( - &config, - openhuman_core::openhuman::agent::session_db::run_ledger::AgentTeamTaskUpsert { + let task_a = + tinyagents::session::run_ledger::get_agent_team_task(&config.workspace_dir, &task_a_id) + .expect("get task A") + .expect("task A present"); + tinyagents::session::run_ledger::upsert_agent_team_task( + &config.workspace_dir, + tinyagents::session::run_ledger::AgentTeamTaskUpsert { id: task_a.id.clone(), team_id: task_a.team_id.clone(), title: task_a.title.clone(), objective: task_a.objective.clone(), - status: - openhuman_core::openhuman::agent::session_db::run_ledger::AgentTeamTaskStatus::Done, + status: tinyagents::session::run_ledger::AgentTeamTaskStatus::Done, owner_member_id: task_a.owner_member_id.clone(), depends_on: task_a.depends_on.clone(), gate_status: Some(task_a.gate_status.clone()), diff --git a/vendor/tinyagents b/vendor/tinyagents index 3e1dbea5b5..107a515d23 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 3e1dbea5b5cb8cba9b8307408b34e8ce9ed5ec4e +Subproject commit 107a515d2385686167931423b7dc8be53b14be15