Promote main → release (conflict-resolved) (#5256)

Co-authored-by: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com>
Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cyrus Gray <144336577+graycyrus@users.noreply.github.com>
Co-authored-by: Horst1993 <horst.w@gmicloud.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: James Gentes <jgentes@users.noreply.github.com>
Co-authored-by: Sam <samrusani@users.noreply.github.com>
Co-authored-by: Sami Rusani <14844597+samrusani@users.noreply.github.com>
Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com>
Co-authored-by: Muhammad Ismail <78064250+myi1@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: nb213 <binyangzhu000@gmail.com>
Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
Co-authored-by: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com>
Co-authored-by: sanil-23 <sanil@tinyhumans.ai>
Co-authored-by: M3gA-Mind <elvin@mahadao.com>
Co-authored-by: oxoxDev <oxoxdev@users.noreply.github.com>
Co-authored-by: mysma-9403 <64923976+mysma-9403@users.noreply.github.com>
Co-authored-by: mwakidenis <mwakidenice@gmail.com>
Co-authored-by: NgoQuocViet2001 <123613986+NgoQuocViet2001@users.noreply.github.com>
Co-authored-by: viet.ngo <viet.ngo@sotatek.com>
Co-authored-by: Maciej Myszkiewicz <mmyszkiewicz@bwcoders.com>
This commit is contained in:
Mega Mind
2026-07-29 04:21:25 +05:30
committed by GitHub
co-authored by YellowSnnowmann Steven Enamakel github-actions[bot] <github-actions[bot]@users.noreply.github.com> Cyrus Gray Horst1993 Cursor James Gentes Sam Sami Rusani oxoxDev Muhammad Ismail Claude Fable 5 nb213 binyangzhu000-sudo Steven Enamakel CodeGhost21 sanil-23 M3gA-Mind oxoxDev mysma-9403 mwakidenis NgoQuocViet2001 viet.ngo Maciej Myszkiewicz
parent 220e1a041c
commit 8ea875c13d
113 changed files with 3585 additions and 1893 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ jobs:
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libpango-1.0-0 libcairo2 libatspi2.0-0 libxshmfence1 libu2f-udev \
xvfb
xvfb dbus dbus-x11
if [ "$MATRIX_TARGET" = "x86_64-unknown-linux-gnu" ] \
&& ! command -v apparmor_parser >/dev/null 2>&1; then
sudo apt-get install -y apparmor
+10
View File
@@ -148,8 +148,11 @@ jobs:
- 'src/**'
- 'tests/**'
- 'scripts/ci-cancel-aware.sh'
- 'scripts/check-linux-tls-dependencies.sh'
- 'scripts/test-rust-e2e.sh'
- 'scripts/test-rust-with-mock.sh'
- 'vendor/motosan-ai-oauth/**'
- 'vendor/tinychannels'
# Changes here invalidate per-module test scoping → full suite.
rust-core-full:
- '.github/workflows/ci-lite.yml'
@@ -160,6 +163,9 @@ jobs:
- '.cargo/config.toml'
- 'rust-toolchain.toml'
- 'scripts/ci-cancel-aware.sh'
- 'scripts/check-linux-tls-dependencies.sh'
- 'vendor/motosan-ai-oauth/**'
- 'vendor/tinychannels'
rust-core-src:
- 'src/**'
- 'tests/**'
@@ -349,6 +355,10 @@ jobs:
- name: Check Rust formatting
run: cargo fmt --all -- --check
- name: Enforce Linux TLS dependency policy
if: needs.changes.outputs['rust-core'] == 'true' || needs.changes.outputs['rust-tauri'] == 'true'
run: bash scripts/check-linux-tls-dependencies.sh
- name: Run clippy (core crate)
if: needs.changes.outputs['rust-core'] == 'true'
run: bash scripts/ci-cancel-aware.sh cargo clippy -p openhuman -- -D warnings
+1 -1
View File
@@ -401,7 +401,7 @@ jobs:
# fork the core image doesn't need. The Dockerfile COPYs vendor/ because
# [patch.crates-io] resolves Rust SDK crates from vendor/.
- name: Init vendored Rust submodules
run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace
run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
+1 -1
View File
@@ -297,7 +297,7 @@ jobs:
# fork the core image doesn't need. The Dockerfile COPYs vendor/ because
# [patch.crates-io] resolves Rust SDK crates from vendor/.
- name: Init vendored Rust submodules
run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace
run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build image (no push)
+3
View File
@@ -23,3 +23,6 @@
[submodule "vendor/tinyplace"]
path = vendor/tinyplace
url = https://github.com/tinyhumansai/tiny.place.git
[submodule "vendor/tinyhumans-sdk"]
path = vendor/tinyhumans-sdk
url = https://github.com/tinyhumansai/sdk.git
+35 -7
View File
@@ -175,14 +175,42 @@ Embedded provider webviews **must not** grow new JS injection. No new `.js` unde
## Rust core (`src/`)
### TinyHumans backend SDK boundary
### Backend API access — `src/api/` over `tinyhumans-sdk`
- All Rust-core calls to the TinyHumans managed backend must go through
`tinyhumans-sdk`, with OpenHuman adapters retaining local egress, budget,
session-expiry, TLS, and observability policy.
- Do not add direct `reqwest` calls for TinyHumans JSON APIs or duplicate SDK
wire types in OpenHuman. Extend the SDK and update its pinned revision when a
public route or type is missing.
Calls to the TinyHumans cloud backend go through the vendored
[`tinyhumans-sdk`](https://github.com/tinyhumansai/sdk) crate at
`vendor/tinyhumans-sdk` (git submodule, path dependency — the crate is not on
crates.io, so unlike the other `vendor/` crates it has no `[patch.crates-io]`
entry). **The SDK is the source of truth for backend routes.** A route missing
from it belongs upstream in the SDK repo, not re-implemented in `src/api/`.
The split:
- **SDK** — routes, URL building, percent-encoding, credential headers,
`{success,data}` envelope handling, and the admin/webhook-receiver route gate.
- **`src/api/`** — the OpenHuman-specific layer on top: session-token retrieval
(`jwt.rs`), base-URL/env resolution (`config.rs`), and the error
classification + Sentry policy in `rest.rs`.
`BackendOAuthClient` owns a `TinyHumansClient` built with
`with_http_client(...)` so the SDK inherits this crate's transport — platform
TLS (schannel on Windows for corporate TLS-inspection proxies, rustls
elsewhere), the 120s/15s timeouts, `http1_only`, and the `x-core-version` /
`x-tauri-version` headers. Bind a session token with `sdk_for(bearer_jwt)`.
**Every SDK-backed call must map its error through `classify_sdk_error`.** That
function mirrors `authed_json`'s classification exactly (401 →
`Unauthorized`/`SESSION_EXPIRED`, channel-message 404 → `MessageNotFound`,
announcements 404 → `AnnouncementNotFound`, transient statuses logged not
reported). Skipping it would change a route's Sentry and session-expiry
behaviour purely by moving it onto a typed SDK method. `rest_tests.rs` pins the
two paths' equivalence — keep that as call sites migrate.
**Hard rules (carried forward from the release-branch wording):**
- Do not add direct `reqwest` calls for TinyHumans JSON APIs, and do not
duplicate SDK wire types in OpenHuman. Extend the SDK and update its pinned
revision when a public route or type is missing.
- Never expose or call TinyHumans admin or webhook APIs from the SDK boundary.
Local inbound webhook routing is an OpenHuman runtime feature and is not an
exception for backend `/webhooks/*` calls.
Generated
+19 -188
View File
@@ -157,7 +157,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -168,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1128,18 +1128,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
dependencies = [
"encode_unicode",
"libc",
"unicode-width",
"windows-sys 0.61.2",
]
[[package]]
name = "const-hex"
version = "1.19.0"
@@ -1825,7 +1813,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1980,12 +1968,6 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -2097,7 +2079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3060,11 +3042,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -3485,7 +3465,7 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4039,14 +4019,12 @@ dependencies = [
[[package]]
name = "motosan-ai-oauth"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16994a67367076b08479af83ca05503c4d423fc6631f849fb92fa787956ad557"
version = "0.2.1"
dependencies = [
"base64 0.22.1",
"percent-encoding",
"rand 0.9.4",
"reqwest 0.12.28",
"reqwest",
"serde",
"sha2 0.10.9",
"thiserror 2.0.18",
@@ -4204,7 +4182,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -4676,7 +4654,6 @@ dependencies = [
"chrono-tz",
"clap",
"coins-bip39",
"console",
"cpal",
"cron",
"crossterm",
@@ -4705,7 +4682,6 @@ dependencies = [
"hostname",
"hound",
"iana-time-zone",
"image",
"keyring",
"landlock",
"lettre",
@@ -4725,21 +4701,18 @@ dependencies = [
"ratatui",
"rdev",
"regex",
"reqwest 0.12.28",
"reqwest",
"ring",
"rppal",
"rusqlite",
"rustls",
"rustls-pki-types",
"schemars",
"sentry",
"serde",
"serde_json",
"serde_repr",
"serde_yaml",
"sha1",
"sha2 0.10.9",
"similar",
"socketioxide",
"starship-battery",
"sysinfo",
@@ -4754,7 +4727,6 @@ dependencies = [
"tinyjuice",
"tinyplace",
"tokio",
"tokio-rustls",
"tokio-stream",
"tokio-tungstenite 0.24.0",
"tokio-util",
@@ -4770,7 +4742,6 @@ dependencies = [
"uuid 1.23.1",
"wait-timeout",
"walkdir",
"webpki-roots 1.0.7",
"whisper-rs",
"windows-sys 0.61.2",
"wiremock",
@@ -5537,7 +5508,6 @@ version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
@@ -5902,7 +5872,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
@@ -5916,7 +5885,6 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
@@ -5943,46 +5911,6 @@ dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "reqwest"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http 1.4.0",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -6144,7 +6072,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6185,33 +6113,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys 0.8.7",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -6432,16 +6333,12 @@ checksum = "eb25f439f97d26fea01d717fa626167ceffcd981addaa670001e70505b72acbb"
dependencies = [
"cfg_aliases",
"httpdate",
"reqwest 0.13.1",
"rustls",
"sentry-backtrace",
"sentry-contexts",
"sentry-core",
"sentry-debug-images",
"sentry-panic",
"sentry-tracing",
"tokio",
"ureq",
]
[[package]]
@@ -6772,12 +6669,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -6814,7 +6705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7088,27 +6979,6 @@ dependencies = [
"windows 0.57.0",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys 0.8.7",
"libc",
]
[[package]]
name = "tap"
version = "1.0.1"
@@ -7136,7 +7006,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7149,7 +7019,7 @@ dependencies = [
"parking_lot",
"rustix",
"signal-hook",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7340,7 +7210,7 @@ dependencies = [
"bytes",
"chrono",
"futures",
"reqwest 0.12.28",
"reqwest",
"rhai",
"rusqlite",
"serde",
@@ -7371,7 +7241,7 @@ dependencies = [
"parking_lot",
"prost",
"rand 0.10.1",
"reqwest 0.12.28",
"reqwest",
"rusqlite",
"rustls",
"rustls-pki-types",
@@ -7409,7 +7279,7 @@ dependencies = [
"parking_lot",
"rand 0.10.1",
"regex",
"reqwest 0.12.28",
"reqwest",
"rusqlite",
"schemars",
"serde",
@@ -7443,11 +7313,10 @@ dependencies = [
[[package]]
name = "tinyhumans-sdk"
version = "0.1.0"
source = "git+https://github.com/tinyhumansai/sdk.git?rev=3ee4123ba3b7a76c5f167d7bc2c72fca86671292#3ee4123ba3b7a76c5f167d7bc2c72fca86671292"
dependencies = [
"base64 0.22.1",
"percent-encoding",
"reqwest 0.12.28",
"reqwest",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -7493,7 +7362,7 @@ dependencies = [
"hkdf",
"hmac",
"rand 0.8.6",
"reqwest 0.12.28",
"reqwest",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -8626,15 +8495,6 @@ dependencies = [
"url",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -8908,7 +8768,7 @@ dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-strings",
"windows-targets 0.52.6",
]
@@ -8962,17 +8822,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.1.2"
@@ -8991,15 +8840,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
@@ -9010,15 +8850,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
+23 -19
View File
@@ -66,6 +66,16 @@ name = "openhuman_core"
crate-type = ["rlib"]
[dependencies]
# tinyhumans-sdk — the typed Rust client for the TinyHumans backend API
# (https://github.com/tinyhumansai/sdk). Vendored as a git submodule under
# `vendor/` beside the other tiny* crates and consumed by path: the crate is not
# published to crates.io, so there is no `[patch.crates-io]` entry for it.
# This is the single source of truth for backend routes — `src/api/` is the
# OpenHuman-local adapter layer (session tokens, config, observability) that
# sits on top of it, not a second HTTP client. Anything missing here belongs
# upstream in the SDK repo, not re-implemented in `src/api/`.
# After cloning: `git submodule update --init vendor/tinyhumans-sdk`.
tinyhumans-sdk = { path = "vendor/tinyhumans-sdk" }
# tiny.place A2A social network SDK — published on crates.io and patched below
# to the vendored tiny.place submodule so OpenHuman can test SDK changes before
# publishing.
@@ -144,11 +154,10 @@ dhat = { version = "0.3", optional = true }
# AV root CAs. So the two TLS backends coexist only on Windows, never on
# the RAM-sensitive platforms.
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream", "http2", "multipart", "socks"] }
tinyhumans-sdk = { git = "https://github.com/tinyhumansai/sdk.git", rev = "3ee4123ba3b7a76c5f167d7bc2c72fca86671292" }
# Already in-tree via reqwest/hyper; named directly so `IntegrationClient::get_bytes`
# can return `bytes::Bytes` without a copy.
bytes = "1"
tokio = { version = "1", features = ["full", "sync"] }
tokio = { version = "1", features = ["full"] }
once_cell = "1.19"
parking_lot = "0.12"
log = "0.4"
@@ -161,15 +170,10 @@ argon2 = "0.5"
rand = "0.10"
dirs = "5"
sha2 = "0.10"
# Line-level text diffs for the memory_diff module (modified-item unified diffs).
similar = "2"
# Git-backed change ledger for the memory_diff module: snapshots are commits,
# checkpoints are tags, read markers are refs, diffs are git tree diffs.
# Vendored libgit2 (no system git dependency on end-user machines).
git2 = { version = "0.21", default-features = false, features = ["vendored-libgit2"] }
# Legacy SHA-1 only used for Tencent COS HMAC-SHA1 signing (yuanbao
# channel media upload). Not used for any new security-sensitive work.
sha1 = "0.10"
hmac = "0.12"
# Archive extraction for the Node.js runtime bootstrap. Unix Node
# distributions ship as .tar.xz, Windows as .zip. `xz2` with `static`
@@ -218,7 +222,6 @@ thiserror = "2.0"
ring = "0.17"
chrono-tz = "0.10"
dotenvy = "0.15"
console = "0.16"
regex = "1.10"
# Multi-pattern fixed-string DFA matcher used by `routing::quality` for
# refusal/empty-noise detection on local-model responses. Already in the
@@ -229,10 +232,7 @@ aho-corasick = "1.1"
walkdir = "2"
glob = "0.3"
hostname = "0.4.2"
rustls = { version = "0.23", features = ["ring"] }
rustls-pki-types = "1.14.0"
tokio-rustls = "0.26.4"
webpki-roots = "1.0.6"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
clap = { version = "4.5", features = ["derive"] }
@@ -245,12 +245,11 @@ lettre = { version = "0.11.22", default-features = false, features = ["builder",
# services and answers over the CLI/native dispatch surface. See the
# `http-server` feature note below and `src/core/http_server_status.rs`.
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"], optional = true }
sentry = { version = "0.47.0", default-features = false, optional = true, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
sentry = { version = "0.47.0", default-features = false, optional = true, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "httpdate"] }
tokio-stream = { version = "0.1.18", default-features = false, features = ["sync"] }
url = "2"
socketioxide = { version = "0.15", features = ["extensions"], optional = true }
whisper-rs = { version = "0.16", optional = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
tempfile = "3"
cpal = { version = "0.15", optional = true }
hound = { version = "3.5", optional = true }
@@ -460,10 +459,9 @@ web3 = ["dep:bitcoin", "dep:curve25519-dalek"]
# contracts scaffold. Default-ON. Slim builds opt out via
# `--no-default-features --features "<explicit list without media>"`.
# Composes with the runtime `DomainSet::media` flag (#4796).
# NOTE: this gate sheds NO exclusive dependencies — media generation is
# backend-proxied (reqwest, shared) and the `image` crate is shared with
# channel media upload. It is a surface-only gate (drops the tool code +
# module from the compile), not a dependency-shedding one. There are no
# NOTE: this gate sheds no exclusive dependencies — media generation is
# backend-proxied (reqwest, shared). It is a surface-only gate (drops the tool
# code + module from the compile), not a dependency-shedding one. There are no
# controllers / stores / subscribers tagged `Media` (agent tools only), and
# `openhuman::image` is currently unwired scaffold (added #2997).
media = []
@@ -671,6 +669,12 @@ while_let_loop = "allow"
# See: https://github.com/tinyhumansai/openhuman/issues/273
[patch.crates-io]
whisper-rs-sys = { git = "https://github.com/tinyhumansai/whisper-rs-sys.git", branch = "main" }
# Upstream 0.2.1 enables reqwest's default native-tls backend even though it
# also requests rustls. Patch the official release locally so Linux/macOS do
# not inherit OpenSSL; the only source delta disables reqwest defaults.
# Windows still enables native-tls through the target-specific dependency
# above. Remove this patch after upstream publishes the same manifest fix.
motosan-ai-oauth = { path = "vendor/motosan-ai-oauth" }
# TinyAgents is vendored as a git submodule (pinned at the released tag) so
# migration work can change the SDK source in-tree, test it against OpenHuman
# immediately, and PR the diff upstream from the submodule. Keep the submodule
+1 -50
View File
@@ -3690,11 +3690,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -4764,9 +4762,7 @@ dependencies = [
[[package]]
name = "motosan-ai-oauth"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16994a67367076b08479af83ca05503c4d423fc6631f849fb92fa787956ad557"
version = "0.2.1"
dependencies = [
"base64 0.22.1",
"percent-encoding",
@@ -5544,7 +5540,6 @@ dependencies = [
"chrono-tz",
"clap",
"coins-bip39",
"console",
"cpal",
"cron",
"curve25519-dalek",
@@ -5569,7 +5564,6 @@ dependencies = [
"hostname",
"hound",
"iana-time-zone",
"image",
"keyring",
"lettre",
"libc",
@@ -5590,16 +5584,13 @@ dependencies = [
"ring",
"rusqlite",
"rustls",
"rustls-pki-types",
"schemars 1.2.1",
"sentry",
"serde",
"serde_json",
"serde_repr",
"serde_yaml",
"sha1",
"sha2 0.10.9",
"similar",
"socketioxide",
"starship-battery",
"sysinfo",
@@ -5614,7 +5605,6 @@ dependencies = [
"tinyjuice",
"tinyplace",
"tokio",
"tokio-rustls",
"tokio-stream",
"tokio-tungstenite 0.24.0",
"tokio-util",
@@ -5628,7 +5618,6 @@ dependencies = [
"uuid 1.23.1",
"wait-timeout",
"walkdir",
"webpki-roots 1.0.7",
"whisper-rs",
"windows-sys 0.61.2",
"x25519-dalek",
@@ -6523,7 +6512,6 @@ version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
@@ -6844,7 +6832,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
@@ -6858,7 +6845,6 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
@@ -6893,10 +6879,8 @@ checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
@@ -6907,7 +6891,6 @@ dependencies = [
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
@@ -7491,16 +7474,12 @@ checksum = "eb25f439f97d26fea01d717fa626167ceffcd981addaa670001e70505b72acbb"
dependencies = [
"cfg_aliases",
"httpdate",
"reqwest 0.13.1",
"rustls",
"sentry-backtrace",
"sentry-contexts",
"sentry-core",
"sentry-debug-images",
"sentry-panic",
"sentry-tracing",
"tokio",
"ureq",
]
[[package]]
@@ -7886,12 +7865,6 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simplecss"
version = "0.2.2"
@@ -8325,27 +8298,6 @@ dependencies = [
"windows 0.57.0",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys 0.8.7",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -9077,7 +9029,6 @@ dependencies = [
[[package]]
name = "tinyhumans-sdk"
version = "0.1.0"
source = "git+https://github.com/tinyhumansai/sdk.git?rev=3ee4123ba3b7a76c5f167d7bc2c72fca86671292#3ee4123ba3b7a76c5f167d7bc2c72fca86671292"
dependencies = [
"base64 0.22.1",
"percent-encoding",
+4 -1
View File
@@ -103,7 +103,7 @@ env_logger = "0.11"
# can be overridden at runtime via the same env var. Feature set mirrors the
# core sidecar (`Cargo.toml` at repo root) minus `tracing` since the shell
# uses `log` + `env_logger`.
sentry = { version = "0.47.0", default-features = false, features = ["backtrace", "contexts", "panic", "debug-images", "reqwest", "rustls"] }
sentry = { version = "0.47.0", default-features = false, features = ["backtrace", "contexts", "panic", "debug-images", "httpdate"] }
# Used by the imessage_scanner module.
anyhow = "1.0"
@@ -262,6 +262,9 @@ e2e-test-support = ["openhuman_core/e2e-test-support"]
# whisper.cpp to use MSVC's static runtime (/MT), matching CEF and avoiding
# LNK2038/LNK1169 CRT conflicts on Windows.
whisper-rs-sys = { git = "https://github.com/tinyhumansai/whisper-rs-sys.git", branch = "main" }
# Keep reqwest defaults disabled in this independent Cargo world too. Without
# this patch, the registry release reintroduces native-tls/OpenSSL on Linux.
motosan-ai-oauth = { path = "../../vendor/motosan-ai-oauth" }
# TinyAgents vendored submodule (repo-root vendor/tinyagents, pinned at the
# released tag) — same patch as the root Cargo world so both resolve the
# in-tree SDK source. `git submodule update --init vendor/tinyagents` first.
+3
View File
@@ -2609,6 +2609,9 @@ pub fn run() {
Some(event)
})),
sample_rate: 1.0,
transport: Some(std::sync::Arc::new(
openhuman_core::core::sentry_transport::factory,
)),
..sentry::ClientOptions::default()
});
// Tag every Sentry event with CPU architecture and OS so Intel-specific
@@ -161,7 +161,7 @@ export default function WorkflowCopilotPanel({
fullWidth = false,
}: Props) {
const { t } = useT();
const { threadId, sending, proposal, pendingApproval, capped, error, send, clearProposal } =
const { threadId, sending, proposal, pendingApproval, capped, error, send, stop, clearProposal } =
useWorkflowBuilderChat(seedThreadId);
const [text, setText] = useState('');
@@ -644,6 +644,7 @@ export default function WorkflowCopilotPanel({
fileInputRef={fileInputRef}
composerInteractionBlocked={sending}
isSending={sending}
onStopGeneration={stop}
attachments={[]}
onAttachFiles={noopAttach}
onRemoveAttachment={noop}
@@ -615,7 +615,7 @@ export const ChatThreadView = forwardRef<ChatThreadViewHandle, ChatThreadViewPro
isAgentTextMode ? 'w-full max-w-full' : 'w-fit max-w-[75%]'
}`}>
{msg.sender === 'agent' ? (
<div className="space-y-1">
<div className="space-y-1" data-testid="agent-message">
<div className="relative space-y-1">
{agentMessageViewMode === 'text' ? (
<AgentMessageText content={displayContent} />
+177 -1
View File
@@ -12,7 +12,13 @@ import { useWorkflowBuilderChat, type WorkflowBuilderSendResult } from './useWor
// The hook now runs the builder server-side via `openhuman.flows_build`.
const buildWorkflow = vi.hoisted(() => vi.fn());
vi.mock('../services/api/flowsApi', () => ({ buildWorkflow }));
// Stop button cancels the in-flight turn via `openhuman.flows_build_cancel`
// (the RPC that actually signals the running agent turn to stop — unlike the
// shared `chatCancel`/`channel_web_cancel` primitive, which silently no-ops
// for a `flows_build` turn since it runs inline and is never registered
// anywhere that primitive looks).
const flowsBuildCancel = vi.hoisted(() => vi.fn());
vi.mock('../services/api/flowsApi', () => ({ buildWorkflow, flowsBuildCancel }));
// Socket status is configurable per test (reset to 'connected' in beforeEach)
// so the no-op/`skipped` path (socket not connected) can be exercised.
@@ -73,6 +79,7 @@ const okResult = (over: Partial<BuilderTurnResult> = {}): BuilderTurnResult => (
describe('useWorkflowBuilderChat', () => {
beforeEach(() => {
buildWorkflow.mockReset().mockResolvedValue(okResult());
flowsBuildCancel.mockReset().mockResolvedValue(true);
socketStatus.current = 'connected';
selectorState.proposals = {};
selectorState.messagesByThreadId = {};
@@ -708,4 +715,173 @@ describe('useWorkflowBuilderChat', () => {
expect(result.current.threadId).toBeNull();
});
});
describe('stop (Stop button)', () => {
it('cancels the in-flight builder turn via flowsBuildCancel; sending clears once the RPC settles', async () => {
let resolveBuild: (v: BuilderTurnResult) => void = () => {};
buildWorkflow.mockReset().mockImplementation(
() =>
new Promise<BuilderTurnResult>(res => {
resolveBuild = res;
})
);
const { result } = renderHook(() => useWorkflowBuilderChat('builder-1'));
// Kick off a turn but do NOT await it — it stays in flight, so `sending`
// is true and the composer shows the Stop button.
let sendPromise: Promise<WorkflowBuilderSendResult> | undefined;
act(() => {
sendPromise = result.current.send({
displayText: 'hi',
request: { mode: 'create', instruction: 'x' },
});
});
await waitFor(() => expect(result.current.sending).toBe(true));
// Hitting Stop signals the server to actually cancel the running
// `workflow_builder` turn on the copilot's dedicated thread.
await act(async () => {
result.current.stop();
});
expect(flowsBuildCancel).toHaveBeenCalledWith('builder-1');
// `stop()` does NOT eagerly clear `sending` — with real cancellation the
// in-flight `buildWorkflow` call this triggered now returns promptly, and
// its own `finally` is the single place that clears `sending` (avoids the
// early-clear race the review bots flagged).
expect(result.current.sending).toBe(true);
// The server-side cancel makes the in-flight `buildWorkflow` call settle
// promptly — once it does, `send`'s `finally` clears `sending`.
await act(async () => {
resolveBuild(okResult());
await sendPromise;
});
expect(result.current.sending).toBe(false);
});
it('is a no-op when nothing is in flight', async () => {
const { result } = renderHook(() => useWorkflowBuilderChat('builder-1'));
await act(async () => {
result.current.stop();
});
expect(flowsBuildCancel).not.toHaveBeenCalled();
});
it('is a no-op before a thread is bound (never sent)', async () => {
// No seed thread id and no send() yet, so threadId is null: stop() hits
// the no-bound-thread guard and never calls the cancel RPC.
const { result } = renderHook(() => useWorkflowBuilderChat());
expect(result.current.threadId).toBeNull();
await act(async () => {
result.current.stop();
});
expect(flowsBuildCancel).not.toHaveBeenCalled();
});
it('swallows a rejected cancel RPC without an unhandled rejection', async () => {
let resolveBuild: (v: BuilderTurnResult) => void = () => {};
buildWorkflow.mockReset().mockImplementation(
() =>
new Promise<BuilderTurnResult>(res => {
resolveBuild = res;
})
);
flowsBuildCancel.mockRejectedValue(new Error('cancel rpc down'));
const { result } = renderHook(() => useWorkflowBuilderChat('builder-1'));
let sendPromise: Promise<WorkflowBuilderSendResult> | undefined;
act(() => {
sendPromise = result.current.send({
displayText: 'hi',
request: { mode: 'create', instruction: 'x' },
});
});
await waitFor(() => expect(result.current.sending).toBe(true));
// Stop with a cancel RPC that rejects: the fire-and-forget `.catch` must
// handle it (no throw, no unhandled rejection).
await act(async () => {
result.current.stop();
});
expect(flowsBuildCancel).toHaveBeenCalledWith('builder-1');
// Settle the underlying build so no promise dangles past the test.
await act(async () => {
resolveBuild(okResult());
await sendPromise;
});
});
it("re-send race: an earlier (Stop-cancelled) send settling does not clear a newer send's `sending` flag (attempt guard)", async () => {
// Regression test for the P2/Major re-send race the review bots
// flagged. `send()` normally can't dispatch a second turn while
// `sending` is already `true` (the `if (localSending)` guard at its
// top) — but that guard reads `localSending` from the closure `send`
// was memoized with on the LAST commit, so two `send()` calls fired
// back-to-back in the same synchronous tick (e.g. a fast double-click
// on Send, or clicking Send again immediately after Stop before the
// button has re-rendered) both observe the stale pre-turn value and
// both dispatch — exactly the overlap the attempt guard exists to
// survive: WITHOUT it, whichever of the two `buildWorkflow` calls
// settles FIRST would unconditionally clear `sending` in its `finally`,
// even while the other one is still genuinely in flight.
let resolveA: (v: BuilderTurnResult) => void = () => {};
let resolveB: (v: BuilderTurnResult) => void = () => {};
buildWorkflow
.mockReset()
.mockImplementationOnce(
() =>
new Promise<BuilderTurnResult>(res => {
resolveA = res;
})
)
.mockImplementationOnce(
() =>
new Promise<BuilderTurnResult>(res => {
resolveB = res;
})
);
const { result } = renderHook(() => useWorkflowBuilderChat('builder-1'));
// Fire A then, in the SAME synchronous batch (no `await` in between, no
// re-render has committed yet), fire B — both dispatch.
let sendAPromise: Promise<WorkflowBuilderSendResult> | undefined;
let sendBPromise: Promise<WorkflowBuilderSendResult> | undefined;
act(() => {
sendAPromise = result.current.send({
displayText: 'first',
request: { mode: 'create', instruction: 'a' },
});
sendBPromise = result.current.send({
displayText: 'second',
request: { mode: 'create', instruction: 'b' },
});
});
// Both calls already made their (stale-closure) `if (localSending)`
// decision synchronously above, before either awaited anything — the
// `waitFor` below just lets those already-decided calls' pending
// microtasks (e.g. the `addMessageLocal` dispatch) drain through to
// their `buildWorkflow` call.
await waitFor(() => expect(buildWorkflow).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.sending).toBe(true));
// A (the earlier/superseded attempt) settles first — e.g. it was the
// one the user hit Stop on. WITHOUT the attempt guard this would clear
// `sending` even though B is still genuinely in flight.
await act(async () => {
resolveA(okResult());
await sendAPromise;
});
expect(result.current.sending).toBe(true);
// B (the latest attempt) settling is what actually clears `sending`.
await act(async () => {
resolveB(okResult());
await sendBPromise;
});
expect(result.current.sending).toBe(false);
});
});
});
+78 -1
View File
@@ -31,6 +31,7 @@ import {
type BuilderTurnRequest,
type BuilderTurnResult,
buildWorkflow,
flowsBuildCancel,
} from '../services/api/flowsApi';
import {
beginInferenceTurn,
@@ -178,6 +179,13 @@ interface UseWorkflowBuilderChat {
* knows whether the instruction is still unresolved.
*/
send: (params: WorkflowBuilderSendParams) => Promise<WorkflowBuilderSendResult>;
/**
* Cancel the in-flight builder turn for the current thread. The copilot's
* Send button morphs into a Stop button while `sending` is true (mirrors the
* main chat's `handleStopGeneration`); clicking it calls this. No-op when
* there is no bound thread or nothing is in flight.
*/
stop: () => void;
/** Clear the current proposal (e.g. after Accept/Reject) without persisting. */
clearProposal: () => void;
}
@@ -216,6 +224,17 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
// against the unnecessary refetch itself, not a correctness requirement.
const createdThreadIdRef = useRef<string | null>(null);
// Attempt guard (P2/Major re-send race): bumped at the start of every
// `send()` call. A `send`'s `finally` only clears `localSending` when its
// captured attempt is still the latest one recorded here — so an EARLIER
// turn that was Stop-cancelled (and whose in-flight `buildWorkflow` promise
// is now just settling in the background) can never clear the `sending`
// flag out from under a NEWER turn the user started right after clicking
// Stop. Without this guard, turn A's `finally` racing turn B's still-active
// RPC would flip `sending` back to `false` while B is genuinely in flight,
// dropping the Stop-button UI for a turn that's still running.
const sendAttemptRef = useRef(0);
const proposalsByThread = useAppSelector(
state => state.chatRuntime.pendingWorkflowProposalsByThread
);
@@ -359,6 +378,11 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
setError('offline');
return { outcome: 'skipped', proposed: false };
}
// Attempt guard (P2/Major re-send race): claim this call as the latest
// attempt BEFORE anything async happens, so this send's own `finally`
// below can tell whether it's still the most recent one by the time it
// settles.
const attempt = ++sendAttemptRef.current;
setLocalSending(true);
setError(null);
// A fresh turn supersedes any prior cap-hit signal, same as the
@@ -483,12 +507,64 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
// the turn) — `failed` is distinct from the retryable `skipped`.
return { outcome: 'failed', proposed };
} finally {
setLocalSending(false);
// Only clear `sending` if THIS attempt is still the latest one — a
// Stop-cancelled earlier turn settling here after a newer send()
// started must not clear the newer turn's in-flight indicator (the
// re-send race the attempt guard exists to close).
if (sendAttemptRef.current === attempt) {
setLocalSending(false);
} else {
log(
'send: finally skipped clearing sending — a newer attempt (%d) has superseded this one (%d)',
sendAttemptRef.current,
attempt
);
}
}
},
[dispatch, localSending, socketStatus, threadId]
);
const stop = useCallback(() => {
if (!threadId) {
log('stop: no bound thread — noop');
return;
}
if (!localSending) {
log('stop: nothing in flight — noop');
return;
}
log('stop: cancelling builder turn thread=%s', threadId);
// `flows_build_cancel` (not the shared `chatCancel`/`channel_web_cancel`
// primitive) actually signals the in-flight `workflow_builder` agent turn
// to stop server-side — `flows_build` runs inline and is never registered
// anywhere `channel_web_cancel` looks, so `chatCancel` used to resolve
// `true` without touching the running turn at all (the FE-only Stop
// button the review bots flagged as cosmetic).
//
// This hook doesn't mint/track a per-turn `request_id` client-side (the
// server generates one when `flows_build` streams without one), so the
// cancel is unscoped here — it cancels whatever build turn is currently
// registered on this thread, same scope `chatCancel(threadId)` had.
//
// Deliberately NOT eagerly clearing `sending` here: with cancellation now
// real, the in-flight `buildWorkflow` call this triggered a cancel for
// returns promptly once the server-side turn settles, and ITS `finally`
// (gated by the attempt guard above) is the single place `sending` is
// cleared — clearing it here too would race that settle and could clear
// a NEWER turn's `sending` if the user re-sent immediately after Stop.
void flowsBuildCancel(threadId)
.then(cancelled => {
log('stop: flowsBuildCancel thread=%s cancelled=%s', threadId, cancelled);
})
.catch(err => {
// Fire-and-forget: a rejected cancel (network/RPC failure) must not
// become an unhandled rejection. The in-flight turn's own settle still
// clears `sending`; we just log the failed cancel attempt.
log('stop: flowsBuildCancel failed thread=%s err=%o', threadId, err);
});
}, [threadId, localSending]);
const clearProposal = useCallback(() => {
if (threadId) dispatch(clearWorkflowProposalForThread({ threadId }));
}, [dispatch, threadId]);
@@ -506,6 +582,7 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
liveResponse,
error,
send,
stop,
clearProposal,
};
}
+38
View File
@@ -5,6 +5,7 @@ import {
buildWorkflow,
discoverWorkflows,
dismissSuggestion,
flowsBuildCancel,
type FlowSuggestion,
getFlowRun,
listAllFlowRuns,
@@ -82,6 +83,43 @@ describe('flowsApi', () => {
});
});
describe('flowsBuildCancel', () => {
it('calls openhuman.flows_build_cancel with thread_id + null request_id and returns cancelled', async () => {
mockCallCoreRpc.mockResolvedValue(cliEnvelope({ cancelled: true }));
const cancelled = await flowsBuildCancel('t1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.flows_build_cancel',
params: { thread_id: 't1', request_id: null },
});
expect(cancelled).toBe(true);
});
it('scopes the cancel with request_id when given', async () => {
mockCallCoreRpc.mockResolvedValue(cliEnvelope({ cancelled: false }));
const cancelled = await flowsBuildCancel('t1', 'req-9');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.flows_build_cancel',
params: { thread_id: 't1', request_id: 'req-9' },
});
// `false` is not an error — it just means nothing was in flight to cancel.
expect(cancelled).toBe(false);
});
it('defaults to false when the payload omits `cancelled`', async () => {
mockCallCoreRpc.mockResolvedValue(cliEnvelope({}));
await expect(flowsBuildCancel('t1')).resolves.toBe(false);
});
it('propagates rejection from callCoreRpc', async () => {
mockCallCoreRpc.mockRejectedValue(new Error('rpc down'));
await expect(flowsBuildCancel('t1')).rejects.toThrow('rpc down');
});
});
describe('listFlowRuns', () => {
it('calls openhuman.flows_list_runs with id', async () => {
mockCallCoreRpc.mockResolvedValue(cliEnvelope([]));
+26
View File
@@ -1018,6 +1018,32 @@ export async function buildWorkflow(
};
}
/**
* Cancel the in-flight `flows_build` (Workflow Copilot) turn streaming into
* `threadId` via `openhuman.flows_build_cancel` — the real cancellation
* behind the composer's Stop button (the RPC actually signals the running
* `workflow_builder` agent turn to stop, unlike the shared `chatCancel`
* primitive, which only ever tore down a spawned interactive chat turn and
* silently no-ops for a `flows_build` turn since it runs inline and was never
* registered anywhere `channel_web_cancel` looks).
*
* `requestId`, when given, scopes the cancel so a stale Stop click for a
* superseded/earlier request can't kill a newer turn that has since started
* on the same thread (mirrors the server's `cancel_build_turn_scoped`).
* Returns the server's `cancelled` field — `false` is not an error, it just
* means nothing was in flight to cancel.
*/
export async function flowsBuildCancel(threadId: string, requestId?: string): Promise<boolean> {
log('flowsBuildCancel: request thread=%s requestId=%s', threadId, requestId ?? '<none>');
const response = await callCoreRpc<unknown>({
method: 'openhuman.flows_build_cancel',
params: { thread_id: threadId, request_id: requestId ?? null },
});
const result = unwrapCliEnvelope<{ cancelled: boolean }>(response);
log('flowsBuildCancel: response cancelled=%s', result.cancelled);
return result.cancelled ?? false;
}
/**
* Dismiss a suggestion via `openhuman.flows_dismiss_suggestion` (the user
* rejected the card). The row is kept server-side so a later discovery run
+27
View File
@@ -204,6 +204,33 @@ export async function textExists(text: string): Promise<boolean> {
}
}
/**
* Non-blocking check: is matching text rendered and visible right now?
*
* Unlike {@link textExists}, this deliberately ignores matching text retained
* inside a collapsed processing transcript.
*/
export async function visibleTextExists(text: string): Promise<boolean> {
try {
if (isTauriDriver()) {
const literal = xpathStringLiteral(text);
const matches = await browser.$$(`//*[contains(text(),${literal})]`);
for (const match of matches) {
if (await match.isDisplayed()) return true;
}
return false;
}
const matches = await browser.$$(xpathContainsText(text));
for (const match of matches) {
if (await match.isDisplayed()) return true;
}
return false;
} catch {
return false;
}
}
/**
* Wait for the app window to be visible.
*
@@ -35,6 +35,8 @@
* transitions through `phase: 'subagent'` at some point.
* - `chatRuntime.toolTimelineByThread[<thread>]` records an
* entry whose `id` starts with `<thread>:subagent:`.
* - Or the async delegation acknowledgement renders in the
* conversation (the durable signal after the parent turn ends).
* - The final orchestrator text (canary) renders in the DOM.
*
* Rust:
@@ -58,7 +60,7 @@ import {
waitForSocketConnected,
} from '../helpers/chat-harness';
import { callOpenhumanRpc } from '../helpers/core-rpc';
import { textExists } from '../helpers/element-helpers';
import { textExists, visibleTextExists } from '../helpers/element-helpers';
import { resetApp } from '../helpers/reset-app';
import { navigateViaHash } from '../helpers/shared-flows';
import { getRequestLog, setMockBehavior, startMockServer, stopMockServer } from '../mock-server';
@@ -72,6 +74,7 @@ const PROMPT = 'Please delegate a llama-research task and return the marker.';
const DELEGATE_PROMPT = 'Return the coded marker phrase.';
const RESEARCHER_REPLY = 'The researcher trace signal is FORTY-TWO.';
const CANARY_FINAL = 'subagent-canary-final-7afe2';
const ASYNC_DELEGATION_ACK = 'Accepted async sub-agent';
// Content-addressed keyword rules — never depleted, immune to extra
// ancillary /chat/completions calls (#4517).
@@ -202,6 +205,7 @@ describe('Chat harness — orchestrator → subagent flow', () => {
// entry should appear.
let sawSubagentPhase = false;
let sawSubagentTimeline = false;
let sawAsyncDelegationAck = false;
const deadline = Date.now() + 45_000;
while (Date.now() < deadline) {
const snap = await snapshotRuntime(threadId);
@@ -212,9 +216,13 @@ describe('Chat harness — orchestrator → subagent flow', () => {
) {
sawSubagentTimeline = true;
}
if (sawSubagentPhase && sawSubagentTimeline) break;
if (await textExists(CANARY_FINAL)) {
sawAsyncDelegationAck =
sawAsyncDelegationAck || (await visibleTextExists(ASYNC_DELEGATION_ACK));
if ((sawSubagentPhase && sawSubagentTimeline) || sawAsyncDelegationAck) break;
if (await visibleTextExists(CANARY_FINAL)) {
sawSubagentTimeline = sawSubagentTimeline || (await hasRenderedSubagentTimeline());
sawAsyncDelegationAck =
sawAsyncDelegationAck || (await visibleTextExists(ASYNC_DELEGATION_ACK));
break;
}
await browser.pause(200);
@@ -222,10 +230,10 @@ describe('Chat harness — orchestrator → subagent flow', () => {
sawSubagentTimeline = sawSubagentTimeline || (await hasRenderedSubagentTimeline());
// At least ONE of the two signals must have fired — the timeline
// entry is the more durable check (the live phase can flip back to
// 'thinking' or 'idle' before our 200ms poll catches it).
expect(sawSubagentPhase || sawSubagentTimeline).toBe(true);
// At least one delegation signal must have fired. Async delegation
// completes the parent tool call immediately, so its phase/timeline can
// disappear before the 200 ms poll; the acknowledgement remains visible.
expect(sawSubagentPhase || sawSubagentTimeline || sawAsyncDelegationAck).toBe(true);
// Final canary must land in the DOM after the orchestrator wraps up.
await browser.waitUntil(async () => await textExists(CANARY_FINAL), {
@@ -0,0 +1,9 @@
import type { Locator, Page } from '@playwright/test';
/**
* Locate text in the rendered assistant answer, excluding matching text kept
* in the collapsed processing transcript for the same turn.
*/
export function agentMessageText(page: Page, text: string | RegExp): Locator {
return page.getByTestId('agent-message').getByText(text).last();
}
@@ -1,5 +1,6 @@
import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
dismissWalkthroughIfPresent,
@@ -309,7 +310,7 @@ test.describe('Chat Harness - Subagent', () => {
// so this is the full sequence — no extra calls should consume keyword
// rules out from under the next-expected matcher.
await expect.poll(completionRequestCount, { timeout: 90_000 }).toBeGreaterThanOrEqual(3);
await expect(page.getByText(CANARY_FINAL)).toBeVisible({ timeout: 30_000 });
await expect(agentMessageText(page, CANARY_FINAL)).toBeVisible({ timeout: 30_000 });
const runtimeSnapshot = await diagnosticsSnapshot(page);
expect(
@@ -323,6 +324,6 @@ test.describe('Chat Harness - Subagent', () => {
// Re-assert after the runtime probe so the persisted message survives the
// turn-completion store transition rather than only being visible mid-stream.
await expect(page.getByText(CANARY_FINAL)).toBeVisible({ timeout: 15_000 });
await expect(agentMessageText(page, CANARY_FINAL)).toBeVisible({ timeout: 15_000 });
});
});
@@ -1,5 +1,6 @@
import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
callCoreRpc,
@@ -194,11 +195,10 @@ test.describe('Chat Harness - Wallet Flow', () => {
await sendMessage(page, WALLET_PROMPT);
await expect(
page
.getByText(
/Prepared a wallet quote for John\..*wallet-quote-canary-8d13|Done\.\s*wallet-quote-canary-8d13/i
)
.first()
agentMessageText(
page,
/Prepared a wallet quote for John\..*wallet-quote-canary-8d13|Done\.\s*wallet-quote-canary-8d13/i
)
).toBeVisible({ timeout: 40_000 });
});
});
@@ -1,5 +1,6 @@
import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
dismissWalkthroughIfPresent,
@@ -177,7 +178,7 @@ test.describe('Chat Multi Tool Round', () => {
const threadId = await createNewThread(page);
await sendMessage(page, PROMPT);
await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 50_000 });
await expect(agentMessageText(page, CANARY_FINAL)).toBeVisible({ timeout: 50_000 });
await expect
.poll(
@@ -1,5 +1,6 @@
import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
dismissWalkthroughIfPresent,
@@ -169,7 +170,7 @@ test.describe('Chat Tool Call Flow', () => {
const threadId = await createNewThread(page);
await sendMessage(page, PROMPT);
await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 40_000 });
await expect(agentMessageText(page, CANARY_FINAL)).toBeVisible({ timeout: 40_000 });
await expect
.poll(
@@ -1,5 +1,6 @@
import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
dismissWalkthroughIfPresent,
@@ -135,8 +136,10 @@ test.describe('Harness - Cross-channel bridge flow', () => {
await createNewThread(page);
await sendMessage(page, 'set up a daily standup reminder at 9am');
await expect(page.getByText(CANARY)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/I created a daily 9am standup reminder for you\./i)).toBeVisible();
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(
agentMessageText(page, /I created a daily 9am standup reminder for you\./i)
).toBeVisible();
});
test.skip('telegram inbound/outbound bridge scenarios require a live listener restart in this lane', async () => {});
@@ -1,5 +1,6 @@
import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
dismissWalkthroughIfPresent,
@@ -170,8 +171,8 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'check my email');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/Q3 Budget Review/i).first()).toBeVisible();
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, /Q3 Budget Review/i)).toBeVisible();
const log = await requests();
const llmHits = log.filter(
@@ -210,8 +211,8 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'list my GitHub repos');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/openhuman/i).first()).toBeVisible();
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, /openhuman/i)).toBeVisible();
const log = await requests();
const llmHits = log.filter(
@@ -244,8 +245,8 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'check my email inbox please');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/unable to fetch your emails/i)).toBeVisible();
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, /unable to fetch your emails/i)).toBeVisible();
});
test('linear create issue flow confirms creation in the final reply', async ({ page }) => {
@@ -286,9 +287,9 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'create a linear issue titled Fix authentication timeout');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
// `.first()` — the confirmation text also appears in the tool-result echo
// pane, so an unscoped match trips Playwright strict mode (2 elements).
await expect(page.getByText(/I have created the Linear issue/i).first()).toBeVisible();
await expect(agentMessageText(page, /I have created the Linear issue/i)).toBeVisible();
});
});
@@ -1,5 +1,6 @@
import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
dismissWalkthroughIfPresent,
@@ -142,8 +143,10 @@ test.describe('Harness - Cron prompt-flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'remind me every morning at 9am');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/Done! I have set up a daily 9am morning reminder/i)).toBeVisible();
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(
agentMessageText(page, /Done! I have set up a daily 9am morning reminder/i)
).toBeVisible();
const log = await requests();
const llmHits = log.filter(
request => request.method === 'POST' && request.url.includes('/chat/completions')
@@ -164,7 +167,7 @@ test.describe('Harness - Cron prompt-flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'what are my scheduled tasks');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/You have 2 scheduled tasks/i)).toBeVisible();
});
@@ -192,8 +195,8 @@ test.describe('Harness - Cron prompt-flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'change my morning reminder to 8am');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/changed your morning reminder to 8am/i).first()).toBeVisible();
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, /changed your morning reminder to 8am/i)).toBeVisible();
});
test('delete flow yields a final reply', async ({ page }) => {
@@ -217,7 +220,7 @@ test.describe('Harness - Cron prompt-flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'delete the morning reminder');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/deleted the morning reminder/i).first()).toBeVisible();
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, /deleted the morning reminder/i)).toBeVisible();
});
});
@@ -1,5 +1,6 @@
import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
dismissWalkthroughIfPresent,
@@ -154,8 +155,8 @@ test.describe('Harness - Search tool-flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'what did we discuss about project Atlas');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/Based on my memory search/i)).toBeVisible();
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, /Based on my memory search/i)).toBeVisible();
const log = await requests();
const llmHits = log.filter(
@@ -186,9 +187,9 @@ test.describe('Harness - Search tool-flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'search for Rust async best practices');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(
page.getByText(/Here are the top results for Rust async best practices/i)
agentMessageText(page, /Here are the top results for Rust async best practices/i)
).toBeVisible();
const log = await requests();
@@ -219,8 +220,8 @@ test.describe('Harness - Search tool-flow', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await sendMessage(page, 'read the README');
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/OpenHuman is an AI assistant/i).first()).toBeVisible();
await expect(agentMessageText(page, CANARY)).toBeVisible({ timeout: 60_000 });
await expect(agentMessageText(page, /OpenHuman is an AI assistant/i)).toBeVisible();
const log = await requests();
const llmHits = log.filter(
@@ -1,5 +1,6 @@
import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
callCoreRpc,
@@ -135,7 +136,7 @@ test.describe('User journey - full research task', () => {
expect(typeof threadId).toBe('string');
await sendMessage(page, PROMPT);
await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 45_000 });
await expect(agentMessageText(page, CANARY_FINAL)).toBeVisible({ timeout: 45_000 });
// Navigate away and back to confirm the thread (and its messages) persist.
// Home folded into the unified chat surface, so /home now redirects to
@@ -152,6 +153,6 @@ test.describe('User journey - full research task', () => {
await page.goto('/#/chat');
await waitForAppReady(page);
await page.getByTestId(`thread-row-${threadId}`).click({ force: true });
await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 15_000 });
await expect(agentMessageText(page, CANARY_FINAL)).toBeVisible({ timeout: 15_000 });
});
});
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
target="${1:-x86_64-unknown-linux-gnu}"
forbidden='^(native-tls|openssl|openssl-sys) v'
check_world() {
local label="$1"
local manifest="$2"
local tree
tree="$(cargo tree --locked --manifest-path "$manifest" --target "$target" --prefix none)"
if [[ "$label" == core ]] &&
matches="$(printf '%s\n' "$tree" | grep -E "$forbidden")"; then
printf 'error: native TLS/OpenSSL dependencies found in %s for %s:\n%s\n' \
"$label" "$target" "$matches" >&2
exit 1
fi
# Tauri legitimately owns reqwest 0.13 for its dev proxy/updater. Sentry
# must never own that tree: OpenHuman supplies its reqwest 0.12 transport.
mapfile -t reqwest_013_versions < <(
printf '%s\n' "$tree" |
sed -nE 's/^reqwest v(0\.13\.[^ ]+).*/\1/p' |
sort -u
)
for version in "${reqwest_013_versions[@]}"; do
owners="$(
cargo tree --locked --manifest-path "$manifest" --target "$target" \
--invert "reqwest@$version" 2>/dev/null || true
)"
if printf '%s\n' "$owners" | grep -Eq '^sentry v'; then
printf 'error: Sentry owns reqwest %s in %s\n%s\n' \
"$version" "$label" "$owners" >&2
exit 1
fi
done
for package in native-tls openssl openssl-sys; do
owners="$(
cargo tree --locked --manifest-path "$manifest" --target "$target" \
--invert "$package" 2>/dev/null || true
)"
if printf '%s\n' "$owners" | grep -Eq '^sentry v'; then
printf 'error: Sentry owns %s in %s\n%s\n' \
"$package" "$label" "$owners" >&2
exit 1
fi
if [[ "$label" == tauri ]] &&
printf '%s\n' "$owners" | grep -Eq '^motosan-ai-oauth v'; then
printf 'error: motosan-ai-oauth owns %s in %s\n%s\n' \
"$package" "$label" "$owners" >&2
exit 1
fi
done
}
check_world core Cargo.toml
check_world tauri app/src-tauri/Cargo.toml
printf 'Linux TLS/Sentry dependency policy passed for both Cargo worlds (%s)\n' "$target"
@@ -158,6 +158,52 @@ smoke_extracted_apprun() {
[ -n "$secret_name" ] && unset_args+=(-u "$secret_name")
done < <(compgen -v OPENAI_ || true)
# ── D-Bus session bus for the smoke window ─────────────────────────────────
#
# A real desktop user always launches the AppImage inside a session that has a
# D-Bus session bus; a CI runner does not. `tauri-plugin-single-instance` calls
# `zbus::blocking::connection::Builder::session().unwrap()` in its `setup()`,
# so with no usable bus it panics before any window exists:
#
# thread 'main' panicked at plugins/single-instance/src/platform_impl/linux.rs:57
# called `Result::unwrap()` on an `Err` value:
# Address("unsupported transport 'disabled'")
#
# ...which is exactly how the Release Production ubuntu smoke failed (exit 101,
# actions/runs/30393949588). Chromium logs the same underlying condition as
# "Failed to connect to the bus: Could not parse server address".
#
# `app/scripts/e2e-run-session.sh` already solves this for the desktop e2e
# runner by starting a bus with `dbus-launch`; the AppImage smoke never got the
# same treatment. Wrapping in `dbus-run-session` is the modern equivalent and
# needs no explicit teardown — the bus dies with the wrapped command, so the
# 15s `timeout` still bounds everything.
#
# Echo the inherited value first: whether it arrives as `disabled`, `disabled:`
# or unset changes which code path the app takes, and the failing log gave us
# no way to tell.
echo "[appimage-runtime] Inherited DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-<unset>}"
# Always drop the inherited value: the wrapper below supplies a real one, and
# if no wrapper is available a poisoned `disabled` must not reach the app —
# with the variable absent the app's own `can_register_single_instance_plugin()`
# probe falls back to inspecting `$XDG_RUNTIME_DIR/bus`, whereas the literal
# string `disabled` is precisely what zbus refuses to parse.
unset_args+=(-u DBUS_SESSION_BUS_ADDRESS)
local -a dbus_wrapper=()
if command -v dbus-run-session >/dev/null 2>&1; then
dbus_wrapper=(dbus-run-session --)
echo "[appimage-runtime] Providing a session bus via dbus-run-session"
elif command -v dbus-launch >/dev/null 2>&1; then
# Older images ship dbus-launch (from dbus-x11) but not dbus-run-session.
dbus_wrapper=(dbus-launch --exit-with-session)
echo "[appimage-runtime] Providing a session bus via dbus-launch"
else
echo "[appimage-runtime] WARNING: neither dbus-run-session nor dbus-launch found;" \
"smoking without a session bus (single-instance will be skipped)"
fi
local status
if (
cd "$foreign_cwd"
@@ -170,6 +216,7 @@ smoke_extracted_apprun() {
XDG_CACHE_HOME="$smoke_cache" \
OPENHUMAN_CEF_PREWARM=0 \
OPENHUMAN_DISABLE_GPU=1 \
${dbus_wrapper[@]+"${dbus_wrapper[@]}"} \
"$appdir/AppRun"
) >"$log_file" 2>&1; then
status=0
@@ -197,6 +244,17 @@ smoke_extracted_apprun() {
forbidden=1
fi
# Name this failure explicitly. It exits 101 like any other Rust panic, and the
# generic "status 101" message sent the last investigation looking at the
# non-fatal `libcef.so => not found` ldd lines instead of the actual cause.
if grep -Eq "unsupported transport 'disabled'|single-instance/src/platform_impl/linux\.rs" \
"$log_file"; then
forbidden=1
echo "[appimage-runtime] Detected the single-instance D-Bus panic — the smoke" \
"environment has no usable session bus. Check the dbus-run-session wrapper" \
"above and that 'dbus'/'dbus-x11' are installed on the runner." >&2
fi
# The desktop process is expected to remain alive until timeout ends the
# startup window. An earlier clean exit is a failure as well as loader output.
if [ "$forbidden" -ne 0 ] || [ "$status" -ne 124 ]; then
+19 -51
View File
@@ -1,28 +1,20 @@
//! Session JWT load and `Authorization` helpers for the TinyHumans API.
//!
//! Parsing and header formatting live in the vendored SDK
//! (`tinyhumans_sdk::jwt`) — they are properties of the backend's token, not of
//! this client, and every host needs them. Re-exported here so existing call
//! sites keep one import path.
//!
//! What stays OpenHuman-specific is *where the token lives*: the credentials
//! store, keyring, and auth-profile names below.
use base64::Engine;
use chrono::{DateTime, Utc};
pub use tinyhumans_sdk::jwt::{bearer_authorization_value, decode_jwt_payload};
pub use crate::openhuman::credentials::session_support::get_session_token;
pub use crate::openhuman::credentials::{APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME};
/// Value for `Authorization: Bearer …` (matches backend expectations).
pub fn bearer_authorization_value(token: &str) -> String {
format!("Bearer {}", token.trim())
}
/// Best-effort decode of a JWT payload without verifying the signature.
pub fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
// JWT = header.payload.signature (base64url, no padding). Only the payload
// segment is needed.
let payload_b64 = token.trim().split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
.ok()?;
serde_json::from_slice(&bytes).ok()
}
/// Best-effort decode of a JWT's `exp` (expiry) claim into a UTC timestamp.
///
/// The backend app-session token is a JWT but is stored bare — the client
@@ -37,49 +29,25 @@ pub fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
/// still 401s, caught by the `flatten_authed_error` net). Returns `None` for any
/// non-JWT / malformed / `exp`-less token, in which case expiry tracking
/// degrades to the previous behaviour (no local precheck).
///
/// The SDK returns Unix seconds so it needs no datetime dependency; this wraps
/// that in the `chrono` type the credentials store already uses.
pub fn decode_jwt_exp(token: &str) -> Option<DateTime<Utc>> {
let claims = decode_jwt_payload(token)?;
// `exp` is a NumericDate (seconds since epoch); accept int or float shapes.
let exp = claims
.get("exp")
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))?;
DateTime::<Utc>::from_timestamp(exp, 0)
DateTime::<Utc>::from_timestamp(tinyhumans_sdk::jwt::decode_jwt_exp_unix(token)?, 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bearer_authorization_value() {
// Standard token
assert_eq!(bearer_authorization_value("my_token"), "Bearer my_token");
// Token with leading/trailing spaces
assert_eq!(
bearer_authorization_value(" spaced_token "),
"Bearer spaced_token"
);
// Empty string
assert_eq!(bearer_authorization_value(""), "Bearer ");
// Whitespace only string
assert_eq!(bearer_authorization_value(" "), "Bearer ");
// Token with internal spaces (should not be trimmed)
assert_eq!(
bearer_authorization_value("token with spaces"),
"Bearer token with spaces"
);
}
fn jwt_with_payload(payload_json: &str) -> String {
use base64::Engine;
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json);
// header + signature are irrelevant to `decode_jwt_exp` (no verification).
format!("eyJhbGciOiJIUzI1NiJ9.{payload}.sig")
}
// The SDK owns the parsing rules and tests them directly. What matters here
// is that the `chrono` conversion this crate depends on stays correct.
#[test]
fn decode_jwt_exp_reads_integer_exp() {
let token = jwt_with_payload(r#"{"sub":"u1","exp":1700000000}"#);
@@ -108,8 +76,8 @@ mod tests {
fn decode_jwt_exp_none_for_non_jwt_or_garbage() {
assert_eq!(decode_jwt_exp("not-a-jwt"), None);
assert_eq!(decode_jwt_exp(""), None);
assert_eq!(decode_jwt_exp("a.b"), None); // payload "b" isn't valid base64 JSON
// local offline session sentinel (not a JWT) must not panic / must be None
assert_eq!(decode_jwt_exp("a.b"), None);
// Local offline session sentinel (not a JWT) must not panic.
assert_eq!(decode_jwt_exp("local-session-xyz"), None);
}
}
+34 -1
View File
@@ -224,6 +224,39 @@ fn build_backend_reqwest_client() -> Result<Client> {
.map_err(|e| anyhow::anyhow!("failed to build HTTP client: {e}"))
}
/// Normalize the backend envelope while preserving OpenHuman's historical
/// response shape. In particular, `/auth/me` returns `{success,user}` rather
/// than `{success,data}`; SDK transport must not expose that envelope detail to
/// existing callers.
fn parse_api_response_value(value: Value) -> Result<Value> {
let Some(object) = value.as_object() else {
return Ok(value);
};
if let Some(user) = object.get("user").filter(|user| !user.is_null()) {
return Ok(user.clone());
}
let Some(success) = object.get("success").and_then(Value::as_bool) else {
return Ok(value);
};
if !success {
let message = object
.get("message")
.or_else(|| object.get("error"))
.and_then(Value::as_str)
.unwrap_or("request unsuccessful");
anyhow::bail!("API request failed: {message}");
}
if let Some(data) = object.get("data").filter(|data| !data.is_null()) {
return Ok(data.clone());
}
if let Some(user) = object.get("user").filter(|user| !user.is_null()) {
return Ok(user.clone());
}
let mut unwrapped = object.clone();
unwrapped.remove("success");
Ok(Value::Object(unwrapped))
}
fn user_id_from_object(obj: &serde_json::Map<String, Value>) -> Option<String> {
for key in ["id", "_id", "userId"] {
if let Some(s) = obj.get(key).and_then(|x| x.as_str()) {
@@ -490,7 +523,7 @@ impl BackendOAuthClient {
.send(method.clone(), path, &[], body.as_ref(), true)
.await;
let value = match response {
Ok(value) => return Ok(value),
Ok(value) => return parse_api_response_value(value),
Err(SdkError::Http(e)) => {
// Walk the error source chain so transient markers hidden in nested
// causes (reqwest -> hyper -> rustls TLS EOF, etc.) still classify
+102
View File
@@ -937,3 +937,105 @@ async fn authed_json_patch_404_with_base_path_prefix_does_not_report() {
assert_eq!(provider, "telegram");
assert_eq!(message_id, "9999");
}
// The channel methods below now reach the backend through the vendored
// `tinyhumans-sdk` transport instead of `authed_json`. The routes they call
// still classify expected backend states the same way — a route must not
// change its Sentry or session-expiry behaviour just because it moved onto a
// typed SDK method. These pin that equivalence.
#[tokio::test]
async fn sdk_backed_channel_delete_surfaces_message_not_found_on_404() {
let app = Router::new().route(
"/channels/telegram/messages/1103",
axum::routing::delete(|| async { (axum::http::StatusCode::NOT_FOUND, "Not Found") }),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = BackendOAuthClient::new(&format!("http://{addr}")).unwrap();
let err = client
.send_channel_delete("telegram", "1103", "mock-jwt")
.await
.unwrap_err();
let typed = err.downcast_ref::<BackendApiError>().unwrap();
let BackendApiError::MessageNotFound {
provider,
message_id,
} = typed
else {
panic!("expected MessageNotFound, got {typed:?}");
};
assert_eq!(provider, "telegram");
assert_eq!(message_id, "1103");
}
#[tokio::test]
async fn sdk_backed_channel_typing_surfaces_unauthorized_on_401() {
let app = Router::new().route(
"/channels/telegram/typing",
post(|| async { (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized") }),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = BackendOAuthClient::new(&format!("http://{addr}")).unwrap();
let err = client
.send_channel_typing("telegram", "mock-jwt")
.await
.unwrap_err();
let typed = err.downcast_ref::<BackendApiError>().unwrap();
let BackendApiError::Unauthorized { method, path } = typed else {
panic!("expected Unauthorized, got {typed:?}");
};
assert_eq!(method, "POST");
assert_eq!(path, "/channels/telegram/typing");
// The session-expiry sentinel must still be derivable, so the dispatcher
// keeps routing this to re-sign-in rather than to Sentry.
assert!(flatten_authed_error(err).starts_with("SESSION_EXPIRED:"));
}
// The SDK transport must inherit this crate's client, so the version headers
// and timeouts apply to SDK-backed calls exactly as they do to `authed_json`.
#[tokio::test]
async fn sdk_backed_calls_send_the_core_version_header() {
let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let app = Router::new()
.route(
"/channels/telegram/typing",
post(
|State(state): State<Arc<Mutex<Option<String>>>>, headers: HeaderMap| async move {
*state.lock().unwrap() = headers
.get("x-core-version")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
Json(json!({"success": true, "data": {}}))
},
),
)
.with_state(seen.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = BackendOAuthClient::new(&format!("http://{addr}")).unwrap();
client
.send_channel_typing("telegram", "mock-jwt")
.await
.unwrap();
assert_eq!(
seen.lock().unwrap().as_deref(),
Some(env!("CARGO_PKG_VERSION"))
);
}
@@ -6,7 +6,7 @@ use chrono::{TimeZone, Utc};
use openhuman_core::core::event_bus::init_global;
use openhuman_core::openhuman::memory::ingest_pipeline::ingest_chat;
use openhuman_core::openhuman::memory_queue::drain_until_idle;
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::harness::{fixture, measure, ProfileResult};
+1 -1
View File
@@ -204,7 +204,7 @@ async fn main() -> Result<()> {
if cli.seal_probe {
use chrono::{Duration, Utc};
use openhuman_core::openhuman::memory::ingest_pipeline::ingest_chat;
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
let connection_id = cli.connection_id.clone().ok_or_else(|| {
anyhow::anyhow!(
+2
View File
@@ -25,6 +25,8 @@ pub mod memory_cli;
pub mod observability;
pub mod rpc_log;
pub mod runtime;
#[cfg(feature = "crash-reporting")]
pub mod sentry_transport;
pub mod shutdown;
pub mod socketio;
pub mod subconscious_cli;
+233
View File
@@ -0,0 +1,233 @@
//! Sentry envelope transport backed by the core's shared reqwest 0.12 stack.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
use std::thread::JoinHandle;
use std::time::Duration;
use sentry::transports::{RateLimiter, RateLimitingCategory};
use sentry::{sentry_debug, ClientOptions, Envelope, Transport};
enum Task {
Send(Envelope),
Flush(mpsc::SyncSender<()>),
Shutdown,
}
/// A bounded, nonblocking Sentry transport using reqwest 0.12.
pub struct SharedReqwestTransport {
sender: mpsc::SyncSender<Task>,
shutdown: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl SharedReqwestTransport {
/// Build a transport from the options consumed by Sentry's HTTP transport.
pub fn new(options: &ClientOptions) -> Self {
let mut builder = reqwest::blocking::Client::builder();
if options.accept_invalid_certs {
builder = builder.danger_accept_invalid_certs(true);
}
if let Some(url) = options.http_proxy.as_ref() {
match reqwest::Proxy::http(url.as_ref()) {
Ok(proxy) => builder = builder.proxy(proxy),
Err(error) => sentry_debug!("invalid HTTP proxy: {error:?}"),
}
}
if let Some(url) = options.https_proxy.as_ref() {
match reqwest::Proxy::https(url.as_ref()) {
Ok(proxy) => builder = builder.proxy(proxy),
Err(error) => sentry_debug!("invalid HTTPS proxy: {error:?}"),
}
}
let client = builder
.build()
.expect("reqwest 0.12 TLS client must be available");
let dsn = options
.dsn
.as_ref()
.expect("Sentry transport requires a DSN");
let auth = dsn.to_auth(Some(&options.user_agent)).to_string();
let url = dsn.envelope_api_url().to_string();
let (sender, receiver) = mpsc::sync_channel(30);
let shutdown = Arc::new(AtomicBool::new(false));
let worker_shutdown = shutdown.clone();
let handle = std::thread::Builder::new()
.name("sentry-transport".into())
.spawn(move || {
let mut rate_limiter = RateLimiter::new();
for task in receiver {
if worker_shutdown.load(Ordering::SeqCst) {
return;
}
let envelope = match task {
Task::Send(envelope) => envelope,
Task::Flush(done) => {
let _ = done.send(());
continue;
}
Task::Shutdown => return,
};
if rate_limiter
.is_disabled(RateLimitingCategory::Any)
.is_some()
{
sentry_debug!("Sentry envelope discarded due to global rate limit");
continue;
}
let Some(envelope) = rate_limiter.filter_envelope(envelope) else {
sentry_debug!("Sentry envelope discarded due to category rate limit");
continue;
};
let mut body = Vec::new();
if let Err(error) = envelope.to_writer(&mut body) {
sentry_debug!("failed to serialize Sentry envelope: {error}");
continue;
}
match client
.post(&url)
.header("X-Sentry-Auth", &auth)
.body(body)
.send()
{
Ok(response) => {
if let Some(value) = response
.headers()
.get("x-sentry-rate-limits")
.and_then(|value| value.to_str().ok())
{
rate_limiter.update_from_sentry_header(value);
} else if let Some(value) = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
{
rate_limiter.update_from_retry_after(value);
} else if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
rate_limiter.update_from_429();
}
if response.status() == reqwest::StatusCode::PAYLOAD_TOO_LARGE {
sentry_debug!("Sentry envelope rejected as too large");
}
}
Err(error) => sentry_debug!("failed to send Sentry envelope: {error}"),
}
}
})
.ok();
Self {
sender,
shutdown,
handle,
}
}
}
impl Transport for SharedReqwestTransport {
fn send_envelope(&self, envelope: Envelope) {
if let Err(error) = self.sender.try_send(Task::Send(envelope)) {
sentry_debug!("Sentry envelope dropped: {error}");
}
}
fn flush(&self, timeout: Duration) -> bool {
let (done_tx, done_rx) = mpsc::sync_channel(1);
self.sender.send(Task::Flush(done_tx)).is_ok() && done_rx.recv_timeout(timeout).is_ok()
}
fn shutdown(&self, timeout: Duration) -> bool {
let flushed = self.flush(timeout);
self.shutdown.store(true, Ordering::SeqCst);
let _ = self.sender.send(Task::Shutdown);
flushed
}
}
impl Drop for SharedReqwestTransport {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
let _ = self.sender.send(Task::Shutdown);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
/// Factory suitable for [`ClientOptions::transport`].
pub fn factory(options: &ClientOptions) -> Arc<dyn Transport> {
Arc::new(SharedReqwestTransport::new(options))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
fn event_envelope(message: &str) -> Envelope {
let mut envelope = Envelope::new();
envelope.add_item(sentry::protocol::Event {
message: Some(message.to_owned()),
..Default::default()
});
envelope
}
#[test]
fn sends_authenticated_envelope_and_applies_category_rate_limit() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let request = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut bytes = Vec::new();
let mut buffer = [0; 4096];
loop {
let read = stream.read(&mut buffer).unwrap();
bytes.extend_from_slice(&buffer[..read]);
if read < buffer.len() {
break;
}
}
stream
.write_all(
b"HTTP/1.1 200 OK\r\nX-Sentry-Rate-Limits: 60:error:project\r\nContent-Length: 0\r\n\r\n",
)
.unwrap();
String::from_utf8(bytes).unwrap()
});
let options = ClientOptions {
dsn: Some(format!("http://public@{address}/1").parse().unwrap()),
..Default::default()
};
let transport = SharedReqwestTransport::new(&options);
transport.send_envelope(event_envelope("first"));
assert!(transport.flush(Duration::from_secs(3)));
transport.send_envelope(event_envelope("rate-limited"));
assert!(transport.flush(Duration::from_secs(3)));
let request = request.join().unwrap();
assert!(request.contains("POST /api/1/envelope/ HTTP/1.1"));
assert!(request.contains("x-sentry-auth: Sentry"));
assert!(request.contains("\"message\":\"first\""));
assert!(!request.contains("rate-limited"));
}
#[test]
fn send_failure_does_not_prevent_flush() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
drop(listener);
let options = ClientOptions {
dsn: Some(format!("http://public@{address}/1").parse().unwrap()),
..Default::default()
};
let transport = SharedReqwestTransport::new(&options);
transport.send_envelope(event_envelope("unreachable"));
assert!(transport.flush(Duration::from_secs(3)));
assert!(transport.shutdown(Duration::from_secs(3)));
}
}
+2 -2
View File
@@ -8,8 +8,8 @@
//! ```no_run
//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
//! use std::sync::Arc;
//! use openhuman::embed::Core;
//! use openhuman::{CoreBuilder, DomainSet, HostKind, ServiceSet};
//! use openhuman_core::embed::Core;
//! use openhuman_core::{CoreBuilder, DomainSet, HostKind, ServiceSet};
//!
//! let runtime = CoreBuilder::new(HostKind::detect_standalone())
//! .domains(DomainSet::full())
+3
View File
@@ -277,6 +277,9 @@ fn main() {
Some(event)
})),
sample_rate: 1.0,
transport: Some(std::sync::Arc::new(
openhuman_core::core::sentry_transport::factory,
)),
..sentry::ClientOptions::default()
});
@@ -6,7 +6,7 @@ use super::types::ArchivistHook;
use crate::openhuman::config::Config;
use crate::openhuman::memory::ingest_pipeline;
use crate::openhuman::memory_store::fts5;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
impl ArchivistHook {
/// Pipe a closed segment's raw prose turns into the memory tree as
@@ -279,6 +279,7 @@ async fn profile_allowed_tools_restrict_shared_session_builder() {
let tmp = tempfile::TempDir::new().unwrap();
let config = test_config(&tmp);
let orchestrator = builtin_def("orchestrator");
let mut profile = crate::openhuman::profiles::store::built_in_default_profile();
profile.id = "alice".to_string();
profile.built_in = false;
@@ -287,7 +288,7 @@ async fn profile_allowed_tools_restrict_shared_session_builder() {
let agent = Agent::build_session_agent_inner(
&config,
"orchestrator",
None,
Some(&orchestrator),
None,
None,
false,
+1 -1
View File
@@ -11,9 +11,9 @@ use serde_json::{json, Map, Value};
use crate::core::event_bus::BackendMeetTurn;
use crate::openhuman::meet::ops::validate_display_name;
use crate::openhuman::memory::ingest_pipeline;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::socket::global_socket_manager;
use crate::rpc::RpcOutcome;
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
use super::types::{
BackendMeetHarnessResponseRequest, BackendMeetJoinRequest, BackendMeetJoinResponse,
@@ -414,8 +414,14 @@ async fn channel_processed_event_records_resolved_agent_route() {
for _ in 0..50 {
let event = tokio::time::timeout(Duration::from_millis(200), events.recv())
.await
.expect("ChannelMessageProcessed event should be published")
.expect("event receiver should stay open");
.expect("ChannelMessageProcessed event should be published");
let event = match event {
Ok(event) => event,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
panic!("event receiver should stay open")
}
};
if let DomainEvent::ChannelMessageProcessed {
message_id,
+8 -4
View File
@@ -2081,10 +2081,14 @@ async fn composio_set_api_key_validates_candidate_key_even_when_stored_key_exist
Some("ck_old_valid".to_string()),
"failed replacement must leave the old stored key intact"
);
assert_eq!(
seen_keys.lock().unwrap().as_slice(),
["ck_new_invalid"],
"validation must probe the candidate key, not the stored key"
assert!(
seen_keys
.lock()
.unwrap()
.iter()
.any(|key| key == "ck_new_invalid"),
"validation must probe the candidate key even when other parallel direct-mode tests \
share the process-wide mock base URL"
);
}
+2
View File
@@ -31,6 +31,8 @@ pub use loader::{
// expose internal helpers needed by tests (ops_tests.rs uses super::*)
#[cfg(test)]
pub(crate) use crate::openhuman::config::Config;
#[cfg(all(test, windows))]
pub(crate) use loader::reset_local_data_remove_error;
#[cfg(test)]
pub(crate) use loader::{
active_workspace_marker_path, config_openhuman_dir, default_openhuman_dir, env_flag_enabled,
+2 -4
View File
@@ -1460,15 +1460,13 @@ async fn load_and_resolve_api_url_returns_api_url_in_response() {
#[test]
fn resolve_api_url_keeps_inference_overrides_away_from_backend_credentials() {
let mut config = Config::default();
let expected_backend = crate::api::config::effective_backend_api_url(&None);
for inference_url in ["http://localhost:11434/v1", "https://openrouter.ai/api/v1"] {
config.api_url = Some(inference_url.to_string());
let resolved = resolve_backend_api_url(&config);
assert_ne!(resolved, inference_url);
assert!(
resolved.contains("tinyhumans.ai"),
"expected hosted backend fallback, got {resolved}"
);
assert_eq!(resolved, expected_backend);
}
}
+250
View File
@@ -0,0 +1,250 @@
//! Process-local registry of in-flight `flows_build` (Workflow Copilot)
//! authoring turns, keyed by `thread_id`, so the copilot's Stop button can
//! actually cancel the agent turn instead of merely hiding its own button
//! (issue: PR #5223 review found the FE-only Stop was cosmetic).
//!
//! `flows_build` runs the `workflow_builder` agent turn **awaited inline**
//! inside its RPC handler — wrapped in `with_origin` /
//! `APPROVAL_CHAT_CONTEXT.scope` / `APPROVAL_COPILOT_STREAM_CONTEXT.scope` /
//! `with_thread_id`, all task-local context that would be lost if the run were
//! `tokio::spawn`ed onto a fresh task. So it can't register in
//! `web_chat::IN_FLIGHT` (only tracks *spawned* interactive chat turns) or
//! `task_dispatcher::ACTIVE_RUNS` (aborts a detached tokio task via
//! `JoinHandle::abort`) — neither fits an inline-awaited future. This module
//! mirrors `flows::run_registry` (used by `flows_cancel_run` for live
//! `flows_run`/`flows_resume` executions — see that module's doc for the same
//! register-a-token / `tokio::select!` shape), but is scoped by `thread_id` +
//! `request_id` instead of `run_id`, matching
//! `task_dispatcher::cancel_session_scoped`'s stale-cancel guard (#4760): a
//! Stop click for a superseded/earlier request must not tear down a newer
//! builder turn that has since started on the same thread.
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use tokio_util::sync::CancellationToken;
/// One in-flight builder turn: the `request_id` it was registered under (for
/// scoped-cancel matching) and the token `flows_build` selects against.
struct BuildTurn {
request_id: Option<String>,
token: CancellationToken,
}
/// The live in-flight builder turns: `thread_id` -> its turn entry.
static BUILD_TURNS: LazyLock<Mutex<HashMap<String, BuildTurn>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Registers an in-flight `flows_build` turn for `thread_id`, storing its
/// `request_id` (for scoped-cancel matching) and the `token` the turn selects
/// against.
///
/// Overwrites any prior entry on this thread unconditionally: `flows_build`
/// unregisters on every exit path (success, error, timeout, cancel), so a
/// still-present entry here means an earlier turn's cleanup hasn't run yet —
/// the newer registration should win either way, matching `run_registry`'s
/// duplicate-key handling.
pub(crate) fn register_build_turn(
thread_id: String,
request_id: Option<String>,
token: CancellationToken,
) {
tracing::debug!(
target: "flows",
thread_id = %thread_id,
request_id = request_id.as_deref().unwrap_or("<none>"),
"[flows] build_registry: registered in-flight build turn"
);
BUILD_TURNS
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(thread_id, BuildTurn { request_id, token });
}
/// Signals the in-flight `flows_build` turn on `thread_id` to cancel, scoped by
/// `request_id` exactly like `task_dispatcher::cancel_session_scoped`: when
/// `request_id` is `Some`, the cancel fires only if it matches the turn
/// currently registered on the thread — a stale Stop for a superseded/unrelated
/// request is a no-op rather than killing a newer turn. `None` cancels
/// unscoped (whatever turn is on the thread). Returns whether a turn was found
/// and signalled.
pub(crate) fn cancel_build_turn_scoped(thread_id: &str, request_id: Option<&str>) -> bool {
let guard = BUILD_TURNS.lock().unwrap_or_else(|e| e.into_inner());
let Some(turn) = guard.get(thread_id) else {
tracing::debug!(
target: "flows",
thread_id = %thread_id,
"[flows] build_registry: cancel ignored — no in-flight build turn on thread"
);
return false;
};
if let Some(rid) = request_id {
if turn.request_id.as_deref() != Some(rid) {
tracing::debug!(
target: "flows",
thread_id = %thread_id,
request_id = %rid,
active_request_id = turn.request_id.as_deref().unwrap_or("<none>"),
"[flows] build_registry: cancel ignored — request_id mismatch \
(superseded/unrelated request)"
);
return false;
}
}
turn.token.cancel();
tracing::info!(
target: "flows",
thread_id = %thread_id,
"[flows] build_registry: signalled in-flight build turn to cancel"
);
true
}
/// Removes the registered turn for `thread_id`, but only if it still matches
/// `request_id` (when `Some`) — guards the same race
/// `task_dispatcher::take_active_run_if` guards against: a newer turn may have
/// already re-registered on this thread by the time an older turn's own
/// cleanup runs, and unregistering unconditionally would clobber that newer
/// turn's entry out from under it. `None` unregisters unconditionally. Call on
/// every `flows_build` exit path (success, error, timeout, cancel).
pub(crate) fn unregister_build_turn(thread_id: &str, request_id: Option<&str>) {
let mut guard = BUILD_TURNS.lock().unwrap_or_else(|e| e.into_inner());
if let Some(rid) = request_id {
match guard.get(thread_id) {
Some(turn) if turn.request_id.as_deref() == Some(rid) => {}
_ => {
tracing::debug!(
target: "flows",
thread_id = %thread_id,
request_id = %rid,
"[flows] build_registry: unregister skipped — thread now owned by a \
different (or no) turn"
);
return;
}
}
}
guard.remove(thread_id);
tracing::debug!(
target: "flows",
thread_id = %thread_id,
"[flows] build_registry: deregistered build turn"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cancel_build_turn_scoped_cancels_a_registered_token() {
let thread_id = "build-registry-test:thread-1";
let token = CancellationToken::new();
register_build_turn(
thread_id.to_string(),
Some("req-1".to_string()),
token.clone(),
);
assert!(!token.is_cancelled());
assert!(cancel_build_turn_scoped(thread_id, Some("req-1")));
assert!(token.is_cancelled());
unregister_build_turn(thread_id, Some("req-1"));
}
#[test]
fn cancel_build_turn_scoped_returns_false_when_nothing_registered() {
assert!(!cancel_build_turn_scoped(
"build-registry-test:never-registered",
Some("req-x")
));
assert!(!cancel_build_turn_scoped(
"build-registry-test:never-registered",
None
));
}
#[test]
fn cancel_build_turn_scoped_ignores_a_mismatched_request_id() {
let thread_id = "build-registry-test:thread-2";
let token = CancellationToken::new();
register_build_turn(
thread_id.to_string(),
Some("req-1".to_string()),
token.clone(),
);
// A stale/unrelated request_id must not cancel a still-live turn.
assert!(!cancel_build_turn_scoped(
thread_id,
Some("some-other-request")
));
assert!(!token.is_cancelled());
// The real request_id still works.
assert!(cancel_build_turn_scoped(thread_id, Some("req-1")));
assert!(token.is_cancelled());
unregister_build_turn(thread_id, Some("req-1"));
}
#[test]
fn cancel_build_turn_scoped_unscoped_none_cancels_whatever_is_registered() {
let thread_id = "build-registry-test:thread-3";
let token = CancellationToken::new();
register_build_turn(
thread_id.to_string(),
Some("req-1".to_string()),
token.clone(),
);
assert!(cancel_build_turn_scoped(thread_id, None));
assert!(token.is_cancelled());
unregister_build_turn(thread_id, None);
}
#[test]
fn unregister_build_turn_is_match_guarded() {
let thread_id = "build-registry-test:thread-4";
let token_a = CancellationToken::new();
register_build_turn(
thread_id.to_string(),
Some("req-a".to_string()),
token_a.clone(),
);
// A newer turn supersedes the entry (mirrors `flows_build` starting a
// second turn on the same thread before the first one's cleanup ran).
let token_b = CancellationToken::new();
register_build_turn(
thread_id.to_string(),
Some("req-b".to_string()),
token_b.clone(),
);
// An unregister scoped to the OLD (superseded) request must not clobber
// the newer turn's entry.
unregister_build_turn(thread_id, Some("req-a"));
assert!(
cancel_build_turn_scoped(thread_id, Some("req-b")),
"the newer turn's entry must still be registered after a stale unregister"
);
assert!(token_b.is_cancelled());
assert!(!token_a.is_cancelled());
unregister_build_turn(thread_id, Some("req-b"));
assert!(!cancel_build_turn_scoped(thread_id, None));
}
#[test]
fn unregister_build_turn_none_removes_unconditionally() {
let thread_id = "build-registry-test:thread-5";
let token = CancellationToken::new();
register_build_turn(thread_id.to_string(), Some("req-1".to_string()), token);
unregister_build_turn(thread_id, None);
assert!(!cancel_build_turn_scoped(thread_id, None));
}
}
+1
View File
@@ -8,6 +8,7 @@
//! controller surface in `schemas` (private, re-exported below).
pub mod agents;
mod build_registry;
pub mod builder_tools;
pub mod bus;
pub mod discovery_tools;
+141 -8
View File
@@ -9,6 +9,7 @@ use std::sync::{Arc, LazyLock};
use chrono::Utc;
use serde_json::{json, Value};
use tinyflows::model::{NodeKind, TriggerKind, WorkflowGraph};
use tokio_util::sync::CancellationToken;
use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin, TrustedAutomationSource};
use crate::openhuman::approval::{
@@ -16,6 +17,7 @@ use crate::openhuman::approval::{
APPROVAL_FLOW_RUN_CONTEXT,
};
use crate::openhuman::config::Config;
use crate::openhuman::flows::build_registry;
use crate::openhuman::flows::bus;
use crate::openhuman::flows::draft_store;
use crate::openhuman::flows::run_registry;
@@ -6235,6 +6237,21 @@ pub async fn flows_build(
// — the gate auto-allows `external_effect` tools under that origin, which
// is why `restrict_builder_toolset` above must keep the full hide-list on
// this path; there is no routable approval surface here to park against.
// Outcome of racing the run future against its wall-clock timeout and
// (streaming only) a user Stop-button cancellation. Kept as one enum so
// both branches below (and the settle match after) share one shape.
enum BuildRunOutcome {
/// The agent run itself finished (or errored) before the timeout or a
/// cancel raced it.
Ran(anyhow::Result<String>),
/// `FLOW_BUILD_TIMEOUT_SECS` elapsed first.
TimedOut,
/// The user cancelled the turn (`flows_build_cancel`) before it
/// finished. Streaming-only — the headless/CLI branch never
/// registers a token, so it can never produce this.
Cancelled,
}
let timed = match &stream {
Some(target) => {
let origin = AgentTurnOrigin::WebChat {
@@ -6271,11 +6288,47 @@ pub async fn flows_build(
);
let run =
tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run);
crate::openhuman::tinyagents::thread_context::with_thread_id(
let run = crate::openhuman::tinyagents::thread_context::with_thread_id(
target.thread_id.clone(),
run,
)
.await
);
// Register this turn's cancellation token BEFORE racing the run,
// so a `flows_build_cancel` call landing the instant this turn
// starts can never miss the registration window. The run stays
// awaited INLINE (never spawned) — spawning it would drop the
// task-local `with_origin` / `APPROVAL_CHAT_CONTEXT.scope` /
// `APPROVAL_COPILOT_STREAM_CONTEXT.scope` / thread-id scope
// context above, which the approval gate + tracing depend on.
// `tokio::select!` races the two futures on THIS task instead, so
// every one of those scopes stays attached to the winning arm.
let token = CancellationToken::new();
build_registry::register_build_turn(
target.thread_id.clone(),
Some(target.request_id.clone()),
token.clone(),
);
let outcome = tokio::select! {
r = run => match r {
Ok(inner) => BuildRunOutcome::Ran(inner),
Err(_) => BuildRunOutcome::TimedOut,
},
_ = token.cancelled() => {
tracing::debug!(
target: "flows",
thread_id = %target.thread_id,
request_id = %target.request_id,
"[flows] flows_build: cancelled by user"
);
BuildRunOutcome::Cancelled
}
};
// Unconditional — covers every exit the `select!` above can take
// (ran to completion, errored, timed out, or was cancelled); there
// is no early return between `register_build_turn` and here that
// could skip it.
build_registry::unregister_build_turn(&target.thread_id, Some(&target.request_id));
outcome
}
None => {
tracing::debug!(
@@ -6284,19 +6337,25 @@ pub async fn flows_build(
auto-allows external_effect tools (run-advancing tools stay hidden)"
);
let run = with_origin(AgentTurnOrigin::Cli, agent.run_single(&prompt));
tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run).await
match tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run)
.await
{
Ok(inner) => BuildRunOutcome::Ran(inner),
Err(_) => BuildRunOutcome::TimedOut,
}
}
};
let (assistant_text, run_error) = match timed {
Ok(Ok(text)) => (text, None),
Ok(Err(e)) => {
let (assistant_text, run_error, cancelled) = match timed {
BuildRunOutcome::Ran(Ok(text)) => (text, None, false),
BuildRunOutcome::Ran(Err(e)) => {
tracing::warn!(target: "flows", error = %e, "[flows] flows_build: agent run failed");
(
String::new(),
Some(format!("workflow_builder run failed: {e:#}")),
false,
)
}
Err(_) => {
BuildRunOutcome::TimedOut => {
tracing::warn!(
target: "flows",
timeout_secs = FLOW_BUILD_TIMEOUT_SECS,
@@ -6307,8 +6366,14 @@ pub async fn flows_build(
Some(format!(
"workflow_builder run timed out after {FLOW_BUILD_TIMEOUT_SECS}s"
)),
false,
)
}
// A user Stop is not an error (`run_error = None`) — it must not be
// reported as a failed turn, nor fall into the trail-off backstop
// below that synthesizes a "continue?" question for a turn that
// quietly ran out of steam; a deliberate cancel is neither.
BuildRunOutcome::Cancelled => (String::new(), None, true),
};
// Capture the proposal from the run's tool history (propose/revise/save all
@@ -6322,6 +6387,38 @@ pub async fn flows_build(
// staring at the original silent/status-only text.
let proposal = extract_workflow_proposal(agent.history());
// A user-cancelled turn settles here, clean and separate from the
// error/trail-off paths below: `finalize_flow_stream` gets an `Ok(...)` (a
// Stop is not an error) so the copilot pane receives the same `chat_done`
// terminal event a normal completion would — `ChatRuntimeProvider` ends
// the inference turn / detaches the streaming state on that event exactly
// as it does for any other settle, so nothing is left dangling on the FE.
// Whatever `proposal`/`assistant_text` the turn produced before the
// cancel raced it (e.g. it had already called `propose_workflow`) is
// still returned — cancelling doesn't discard partial progress.
if cancelled {
if let Some(target) = &stream {
let terminal: Result<String, String> = Ok(assistant_text.clone());
finalize_flow_stream(target, &terminal, &prompt).await;
}
tracing::info!(
target: "flows",
flow_id = req.flow_id.as_deref().unwrap_or("<none>"),
has_proposal = proposal.is_some(),
"[flows] flows_build: workflow builder turn cancelled by user"
);
return Ok(RpcOutcome::single_log(
json!({
"proposal": proposal,
"assistant_text": assistant_text,
"error": Value::Null,
"capped": false,
"trail_off": false,
}),
"workflow builder turn cancelled by user",
));
}
// A run that both errored AND produced no proposal is a hard failure; a run
// that proposed before erroring still returns the proposal for review.
if proposal.is_none() {
@@ -6408,6 +6505,42 @@ pub async fn flows_build(
))
}
/// Cancel the in-flight `flows_build` (Workflow Copilot) turn streaming into
/// `thread_id`, scoped by `request_id` — the real, working half of the
/// composer's Stop button (issue: the original FE-only version hid the
/// button but never touched the running turn, since `flows_build` runs the
/// agent inline and never registers in `web_chat::IN_FLIGHT` or
/// `task_dispatcher::ACTIVE_RUNS`).
///
/// When `request_id` is `Some`, the cancel only fires if it matches the turn
/// currently registered on `thread_id` — a stale Stop click for a
/// superseded/earlier request can't kill a newer turn that has since started
/// on the same thread (mirrors `task_dispatcher::cancel_session_scoped`,
/// #4760). `None` cancels whatever turn is on the thread. Returns whether a
/// turn was found and signalled; `false` is not an error — it just means
/// nothing was in flight to cancel (already settled, or never started).
pub async fn flows_build_cancel(
thread_id: &str,
request_id: Option<&str>,
) -> Result<RpcOutcome<Value>, String> {
let cancelled = build_registry::cancel_build_turn_scoped(thread_id, request_id);
tracing::info!(
target: "flows",
thread_id,
request_id = request_id.unwrap_or("<none>"),
cancelled,
"[flows] flows_build_cancel: cancel request handled"
);
Ok(RpcOutcome::single_log(
json!({ "cancelled": cancelled }),
if cancelled {
"workflow builder turn cancellation requested"
} else {
"no in-flight workflow builder turn to cancel"
},
))
}
/// Heuristic: does `text` already contain a clear, answerable question in its
/// final paragraph? Conservative by design (issue: builder convergence) — a
/// false negative (an actual question this misses) no longer discards the
+77 -1
View File
@@ -294,6 +294,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("get_run"),
schemas("prune_runs"),
schemas("build"),
schemas("build_cancel"),
schemas("discover"),
schemas("list_suggestions"),
schemas("dismiss_suggestion"),
@@ -386,6 +387,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("build"),
handler: handle_build,
},
RegisteredController {
schema: schemas("build_cancel"),
handler: handle_build_cancel,
},
RegisteredController {
schema: schemas("discover"),
handler: handle_discover,
@@ -938,6 +943,48 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"build_cancel" => ControllerSchema {
namespace: "flows",
function: "build_cancel",
description: "Cancel the in-flight `flows_build` (Workflow Copilot) turn streaming \
into `thread_id` the real cancellation behind the composer's Stop \
button. When `request_id` is given, the cancel only fires if it \
matches the turn currently registered on the thread (a stale Stop for \
a superseded request can't kill a newer turn); omit it to cancel \
whatever turn is on the thread. `cancelled: false` is not an error it \
just means nothing was in flight (already settled, or never started).",
inputs: vec![
FieldSchema {
name: "thread_id",
ty: TypeSchema::String,
comment: "The copilot's dedicated chat thread id (the same `thread_id` \
passed to `flows.build`'s streaming params).",
required: true,
},
FieldSchema {
name: "request_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Per-turn correlation id to scope the cancel to (matches the \
`request_id` `flows.build` streamed with). Omit to cancel \
unscoped.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Object {
fields: vec![FieldSchema {
name: "cancelled",
ty: TypeSchema::Bool,
comment: "True when an in-flight build turn was found and signalled to \
cancel.",
required: true,
}],
},
comment: "Cancellation result payload.",
required: true,
}],
},
"discover" => ControllerSchema {
namespace: "flows",
function: "discover",
@@ -1472,6 +1519,17 @@ fn handle_build(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_build_cancel(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let thread_id = read_required::<String>(&params, "thread_id")?;
let request_id = params
.get("request_id")
.and_then(Value::as_str)
.map(str::to_string);
to_json(ops::flows_build_cancel(thread_id.trim(), request_id.as_deref()).await?)
})
}
fn handle_discover(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
@@ -1710,6 +1768,7 @@ mod tests {
"get_run",
"prune_runs",
"build",
"build_cancel",
"discover",
"list_suggestions",
"dismiss_suggestion",
@@ -1732,7 +1791,7 @@ mod tests {
#[test]
fn all_registered_controllers_has_handler_per_schema() {
let controllers = all_registered_controllers();
assert_eq!(controllers.len(), 33);
assert_eq!(controllers.len(), 34);
let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect();
assert_eq!(
names,
@@ -1755,6 +1814,7 @@ mod tests {
"get_run",
"prune_runs",
"build",
"build_cancel",
"discover",
"list_suggestions",
"dismiss_suggestion",
@@ -1945,6 +2005,22 @@ mod tests {
assert!(!request.required);
}
#[test]
fn schemas_build_cancel_requires_thread_id_but_not_request_id() {
let s = schemas("build_cancel");
assert_eq!(s.namespace, "flows");
assert_eq!(s.function, "build_cancel");
let required: Vec<_> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert_eq!(required, vec!["thread_id"]);
let request = s.inputs.iter().find(|f| f.name == "request_id").unwrap();
assert!(!request.required);
}
#[test]
fn schemas_discover_exposes_optional_stream_params() {
let s = schemas("discover");
-135
View File
@@ -7,23 +7,6 @@
use crate::openhuman::config::{Config, LocalAiConfig};
use serde::{Deserialize, Serialize};
fn strip_think_tags(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut rest = input;
loop {
let Some(start) = rest.find("<think>") else {
result.push_str(rest);
break;
};
result.push_str(&rest[..start]);
let Some(end) = rest[start..].find("</think>") else {
break;
};
rest = &rest[start + end + "</think>".len()..];
}
result.trim().to_string()
}
pub(crate) const DEFAULT_LM_STUDIO_BASE_URL: &str = "http://localhost:1234/v1";
pub(crate) fn lm_studio_base_url(config: &Config) -> String {
@@ -170,97 +153,6 @@ pub(crate) struct LmStudioModel {
pub owned_by: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct LmStudioChatCompletionRequest {
pub model: String,
pub messages: Vec<LmStudioChatMessage>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LmStudioChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct LmStudioChatCompletionResponse {
#[serde(default)]
pub choices: Vec<LmStudioChatChoice>,
#[serde(default)]
pub usage: Option<LmStudioUsage>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct LmStudioChatChoice {
pub message: LmStudioChatResponseMessage,
}
#[derive(Debug, Deserialize)]
pub(crate) struct LmStudioChatResponseMessage {
#[serde(default)]
pub content: Option<String>,
/// Local reasoning models expose chain-of-thought as `reasoning_content`
/// or `reasoning` depending on the runtime — accept both field names.
#[serde(default, alias = "reasoning")]
pub reasoning_content: Option<String>,
}
impl LmStudioChatResponseMessage {
pub(crate) fn effective_content(&self) -> String {
let content = self
.content
.as_deref()
.map(strip_think_tags)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
if !content.is_empty() {
tracing::trace!(
source = "content",
output_chars = content.chars().count(),
"[lm-studio] effective content selected"
);
return content;
}
let reasoning = self
.reasoning_content
.as_deref()
.map(strip_think_tags)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
if !reasoning.is_empty() {
tracing::trace!(
source = "reasoning_content",
output_chars = reasoning.chars().count(),
"[lm-studio] effective content selected"
);
return reasoning;
}
tracing::trace!(
source = "none",
output_chars = 0,
"[lm-studio] effective content empty"
);
String::new()
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct LmStudioUsage {
#[serde(default)]
pub prompt_tokens: Option<u32>,
#[serde(default)]
pub completion_tokens: Option<u32>,
}
/// LM Studio **native** REST (`GET /api/v0/models`) model entry.
///
/// Unlike the OpenAI-compatible `/v1/models` (which returns only
@@ -444,31 +336,4 @@ mod tests {
Some("http://127.0.0.1:1234/v1")
);
}
#[test]
fn effective_content_falls_back_to_reasoning_content() {
let msg = LmStudioChatResponseMessage {
content: Some("".into()),
reasoning_content: Some("thinking text".into()),
};
assert_eq!(msg.effective_content(), "thinking text");
}
#[test]
fn effective_content_strips_think_tags() {
let msg = LmStudioChatResponseMessage {
content: Some("<think>hidden</think>Visible reply".into()),
reasoning_content: None,
};
assert_eq!(msg.effective_content(), "Visible reply");
}
#[test]
fn reasoning_content_accepts_reasoning_alias() {
// Local runtimes that name the field `reasoning` must still be captured
// (issue #3094) so reasoning round-trips like the canonical field.
let msg: LmStudioChatResponseMessage =
serde_json::from_str(r#"{"content":null,"reasoning":"local cot"}"#).unwrap();
assert_eq!(msg.reasoning_content.as_deref(), Some("local cot"));
}
}
-32
View File
@@ -472,44 +472,12 @@ pub(crate) struct OllamaGenerateResponse {
pub eval_duration: Option<u64>,
}
#[derive(Debug, Serialize)]
pub(crate) struct OllamaEmbedRequest {
pub model: String,
pub input: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OllamaEmbedResponse {
#[serde(default)]
pub embeddings: Vec<Vec<f32>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub(crate) struct OllamaChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct OllamaChatRequest {
pub model: String,
pub messages: Vec<OllamaChatMessage>,
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<OllamaGenerateOptions>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct OllamaChatResponse {
pub message: OllamaChatMessage,
#[allow(dead_code)]
pub done: Option<bool>,
pub prompt_eval_count: Option<u32>,
pub prompt_eval_duration: Option<u64>,
pub eval_count: Option<u32>,
pub eval_duration: Option<u64>,
}
pub(crate) fn ns_to_tps(tokens: f32, duration_ns: u64) -> Option<f32> {
if duration_ns == 0 || tokens <= 0.0 {
return None;
@@ -1,11 +1,8 @@
use crate::openhuman::config::Config;
use crate::openhuman::inference::local::lm_studio::{
apply_lm_studio_auth, lm_studio_base_url, ollama_tags_fallback_url,
LmStudioChatCompletionRequest, LmStudioChatCompletionResponse, LmStudioChatMessage,
LmStudioModelsResponse,
apply_lm_studio_auth, lm_studio_base_url, ollama_tags_fallback_url, LmStudioModelsResponse,
};
use crate::openhuman::inference::local::ollama::{OllamaModelTag, OllamaTagsResponse};
use crate::openhuman::inference::model_ids;
use super::LocalAiService;
@@ -18,12 +15,6 @@ fn diagnostic_body_snippet(body: &str) -> String {
snippet
}
pub(in crate::openhuman::inference::local::service) struct LmStudioCompletionOutcome {
pub reply: String,
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
}
impl LocalAiService {
pub(in crate::openhuman::inference::local::service) async fn ensure_lm_studio_available(
&self,
@@ -267,113 +258,4 @@ impl LocalAiService {
.into_iter()
.any(|m| m.name.to_ascii_lowercase() == target))
}
pub(in crate::openhuman::inference::local::service) async fn lm_studio_chat_completion(
&self,
config: &Config,
messages: Vec<LmStudioChatMessage>,
max_tokens: Option<u32>,
temperature: f32,
allow_empty: bool,
) -> Result<LmStudioCompletionOutcome, String> {
let base = lm_studio_base_url(config);
let url = format!("{base}/chat/completions");
let model = model_ids::effective_chat_model_id(config);
tracing::debug!(
target: "local_ai::lm_studio",
%url,
%model,
message_count = messages.len(),
max_tokens = ?max_tokens,
"[local_ai:lm_studio] chat completion: sending POST"
);
let body = LmStudioChatCompletionRequest {
model,
messages,
stream: false,
temperature: Some(temperature),
max_tokens,
};
let request = self
.http
.post(&url)
.timeout(std::time::Duration::from_secs(120))
.json(&body);
let response = apply_lm_studio_auth(request, config)
.send()
.await
.map_err(|e| {
tracing::debug!(
target: "local_ai::lm_studio",
%url,
error = %e,
"[local_ai:lm_studio] chat completion: request failed"
);
format!("lm studio chat request failed: {e}")
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let detail = body.trim();
tracing::debug!(
target: "local_ai::lm_studio",
%url,
%status,
body = %diagnostic_body_snippet(&body),
"[local_ai:lm_studio] chat completion: non-success response"
);
return Err(format!(
"lm studio chat failed with status {}{}",
status,
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
));
}
let body = response.text().await.map_err(|e| {
tracing::debug!(
target: "local_ai::lm_studio",
%url,
error = %e,
"[local_ai:lm_studio] chat completion: body read failed"
);
format!("lm studio chat response body read failed: {e}")
})?;
let payload: LmStudioChatCompletionResponse = serde_json::from_str(&body).map_err(|e| {
tracing::debug!(
target: "local_ai::lm_studio",
%url,
error = %e,
body = %diagnostic_body_snippet(&body),
"[local_ai:lm_studio] chat completion: parse failed"
);
format!("lm studio chat response parse failed: {e}")
})?;
let reply = payload
.choices
.first()
.map(|choice| choice.message.effective_content())
.unwrap_or_default();
if reply.is_empty() && !allow_empty {
return Err("lm studio returned empty content".to_string());
}
Ok(LmStudioCompletionOutcome {
reply,
prompt_tokens: payload.usage.as_ref().and_then(|usage| usage.prompt_tokens),
completion_tokens: payload
.usage
.as_ref()
.and_then(|usage| usage.completion_tokens),
})
}
}
@@ -3,6 +3,7 @@
mod assets;
mod bootstrap;
mod lm_studio;
mod model_rpc;
pub(crate) mod ollama_admin;
mod public_infer;
pub(crate) mod spawn_marker;
@@ -0,0 +1,271 @@
//! Thin local-model RPC boundary backed by TinyAgents.
//!
//! Local runtimes execute out of process. OpenHuman only resolves their HTTP
//! endpoint and adapts the legacy local-AI call shape to TinyAgents' provider-
//! neutral `ChatModel` interface.
use crate::openhuman::config::Config;
use crate::openhuman::inference::local::lm_studio::lm_studio_base_url;
use crate::openhuman::inference::local::ollama::{
ollama_base_url_from_config, redact_ollama_base_url,
};
use crate::openhuman::inference::local::provider::{provider_from_config, LocalAiProvider};
use tinyagents::harness::message::Message;
use tinyagents::harness::model::{ChatModel, ModelRequest};
use tinyagents::harness::providers::openai::OpenAiModel;
pub(super) struct ModelRpcOutcome {
pub reply: String,
pub prompt_tokens: Option<u64>,
pub completion_tokens: Option<u64>,
pub prompt_toks_per_sec: Option<f32>,
pub gen_toks_per_sec: Option<f32>,
}
fn throughput(raw: Option<&serde_json::Value>, count: &str, duration: &str) -> Option<f32> {
let raw = raw?;
let count = raw.get(count)?.as_u64()?;
let duration = raw.get(duration)?.as_u64()?;
crate::openhuman::inference::local::ollama::ns_to_tps(count as f32, duration)
}
fn local_model(config: &Config, model_id: &str) -> Result<OpenAiModel, String> {
let provider = provider_from_config(config);
let model = match provider {
LocalAiProvider::LmStudio => {
let base = lm_studio_base_url(config);
tracing::debug!(
provider = provider.as_str(),
endpoint = %redact_ollama_base_url(&base),
has_api_key = config.local_ai.api_key.as_deref().is_some_and(|key| !key.trim().is_empty()),
model = %model_id,
"[local_ai:model_rpc] selecting LM Studio RPC model"
);
OpenAiModel::lm_studio(
base,
config.local_ai.api_key.as_deref().unwrap_or_default(),
model_id,
)
}
LocalAiProvider::Ollama => {
let base = ollama_base_url_from_config(config);
tracing::debug!(
provider = provider.as_str(),
endpoint = %redact_ollama_base_url(&base),
model = %model_id,
"[local_ai:model_rpc] selecting Ollama RPC model"
);
OpenAiModel::ollama_at(base, model_id)
}
};
model.map_err(|error| {
tracing::warn!(
provider = provider.as_str(),
error = %error,
"[local_ai:model_rpc] model construction failed"
);
format!("invalid local model RPC configuration: {error}")
})
}
pub(super) async fn invoke(
config: &Config,
client: reqwest::Client,
messages: Vec<Message>,
max_tokens: Option<u32>,
temperature: f32,
allow_empty: bool,
) -> Result<ModelRpcOutcome, String> {
let model_id = crate::openhuman::inference::model_ids::effective_chat_model_id(config);
let model = local_model(config, &model_id)?.with_client(client);
let provider = provider_from_config(config);
tracing::debug!(
provider = provider.as_str(),
model = %model_id,
message_count = messages.len(),
?max_tokens,
allow_empty,
"[local_ai:model_rpc] invoking local model"
);
let mut request = ModelRequest::new(messages)
.with_model(&model_id)
.with_temperature(temperature as f64);
if let Some(max_tokens) = max_tokens {
request = request.with_max_tokens(max_tokens);
}
let response = model.invoke(&(), request).await.map_err(|error| {
tracing::warn!(
provider = provider.as_str(),
model = %model_id,
error = %error,
"[local_ai:model_rpc] local model call failed"
);
if provider == LocalAiProvider::Ollama
&& error.to_string().contains("error sending request")
{
format!(
"external Ollama endpoint is unavailable; ensure Ollama is already running: {error}"
)
} else {
format!("local model RPC failed: {error}")
}
})?;
let outcome = model_outcome(response, allow_empty)?;
tracing::debug!(
provider = provider.as_str(),
model = %model_id,
reply_len = outcome.reply.len(),
prompt_tokens = ?outcome.prompt_tokens,
completion_tokens = ?outcome.completion_tokens,
"[local_ai:model_rpc] local model call completed"
);
Ok(outcome)
}
fn model_outcome(
response: tinyagents::harness::model::ModelResponse,
allow_empty: bool,
) -> Result<ModelRpcOutcome, String> {
let mut reply = response.text();
if reply.trim().is_empty() {
reply = response
.message
.content
.iter()
.filter_map(|block| match block {
tinyagents::harness::message::ContentBlock::Thinking { text, .. } => {
Some(text.as_str())
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
}
let reply = reply.trim().to_owned();
if reply.is_empty() && !allow_empty {
return Err("local model RPC returned empty content".to_owned());
}
let prompt_tokens = response
.usage
.as_ref()
.and_then(|usage| (usage.input_tokens > 0).then_some(usage.input_tokens));
let completion_tokens = response
.usage
.as_ref()
.and_then(|usage| (usage.output_tokens > 0).then_some(usage.output_tokens));
let prompt_toks_per_sec = throughput(
response.raw.as_ref(),
"prompt_eval_count",
"prompt_eval_duration",
);
let gen_toks_per_sec = throughput(response.raw.as_ref(), "eval_count", "eval_duration");
Ok(ModelRpcOutcome {
reply,
prompt_tokens,
completion_tokens,
prompt_toks_per_sec,
gen_toks_per_sec,
})
}
#[cfg(test)]
mod tests {
use super::{local_model, model_outcome, throughput};
use crate::openhuman::config::Config;
use tinyagents::harness::message::{AssistantMessage, ContentBlock};
use tinyagents::harness::model::ModelResponse;
use tinyagents::harness::usage::Usage;
#[test]
fn throughput_reads_ollama_timing_metadata() {
let raw = serde_json::json!({
"eval_count": 25,
"eval_duration": 500_000_000_u64,
});
assert_eq!(
throughput(Some(&raw), "eval_count", "eval_duration"),
Some(50.0)
);
assert_eq!(
throughput(Some(&raw), "prompt_eval_count", "prompt_eval_duration"),
None
);
}
#[test]
fn local_model_selects_configured_provider() {
let mut config = Config::default();
config.local_ai.provider = "ollama".to_string();
let ollama = local_model(&config, "qwen3").unwrap();
assert_eq!(ollama.provider(), "ollama");
config.local_ai.provider = "lm_studio".to_string();
let lm_studio = local_model(&config, "local-model").unwrap();
assert_eq!(lm_studio.provider(), "lm_studio");
}
#[test]
fn model_outcome_enforces_empty_and_normalizes_usage() {
let response = |text: &str, usage: Usage| ModelResponse {
message: AssistantMessage {
id: None,
content: vec![ContentBlock::Text(text.to_string())],
tool_calls: Vec::new(),
usage: Some(usage),
},
usage: Some(usage),
finish_reason: None,
raw: None,
resolved_model: None,
continue_turn: None,
};
assert!(model_outcome(response(" ", Usage::default()), false).is_err());
let empty = model_outcome(response(" ", Usage::default()), true).unwrap();
assert_eq!(empty.reply, "");
assert_eq!(empty.prompt_tokens, None);
assert_eq!(empty.completion_tokens, None);
let populated = model_outcome(
response(
"done",
Usage {
input_tokens: 7,
output_tokens: 3,
..Usage::default()
},
),
false,
)
.unwrap();
assert_eq!(populated.prompt_tokens, Some(7));
assert_eq!(populated.completion_tokens, Some(3));
let reasoning_only = ModelResponse {
message: AssistantMessage {
id: None,
content: vec![ContentBlock::Thinking {
text: "reasoning fallback".to_string(),
signature: None,
}],
tool_calls: Vec::new(),
usage: None,
},
usage: None,
finish_reason: None,
raw: None,
resolved_model: None,
continue_turn: None,
};
assert_eq!(
model_outcome(reasoning_only, false).unwrap().reply,
"reasoning fallback"
);
}
}
@@ -1,30 +1,10 @@
use crate::openhuman::config::Config;
use crate::openhuman::inference::local::ollama::{
ns_to_tps, ollama_base_url, ollama_base_url_from_config, redact_ollama_base_url,
OllamaGenerateOptions, OllamaGenerateRequest,
};
use crate::openhuman::inference::local::provider::{provider_from_config, LocalAiProvider};
use crate::openhuman::inference::model_ids;
use crate::openhuman::inference::parse::sanitize_inline_completion;
use tinyagents::harness::message::Message;
use super::LocalAiService;
fn external_ollama_request_error_with_url(
prefix: &str,
error: &reqwest::Error,
base_url: &str,
) -> String {
let safe_base_url = redact_ollama_base_url(base_url);
format!(
"{prefix}: OpenHuman routes inference through an external Ollama endpoint. \
Make sure Ollama is already running and reachable at {safe_base_url} ({error})"
)
}
fn external_ollama_request_error(prefix: &str, error: &reqwest::Error) -> String {
external_ollama_request_error_with_url(prefix, error, &ollama_base_url())
}
impl LocalAiService {
pub async fn summarize(
&self,
@@ -276,129 +256,44 @@ impl LocalAiService {
None
};
if provider_from_config(config) == LocalAiProvider::LmStudio {
let started = std::time::Instant::now();
let lm_messages = messages
.into_iter()
.map(
|message| crate::openhuman::inference::local::lm_studio::LmStudioChatMessage {
role: message.role,
content: message.content,
},
)
.collect();
let outcome = self
.lm_studio_chat_completion(
config,
lm_messages,
max_tokens,
config.default_temperature as f32,
false,
)
.await?;
let elapsed_ms = started.elapsed().as_millis() as u64;
{
let mut status = self.status.lock();
status.state = "ready".to_string();
status.last_latency_ms = Some(elapsed_ms);
status.prompt_toks_per_sec = None;
status.gen_toks_per_sec = None;
status.warning = None;
}
tracing::debug!(
elapsed_ms,
prompt_tokens = ?outcome.prompt_tokens,
completion_tokens = ?outcome.completion_tokens,
reply_len = outcome.reply.len(),
"[local_ai:chat] lm studio /v1/chat/completions done"
);
return Ok(outcome.reply);
}
tracing::debug!(
message_count = messages.len(),
model = %crate::openhuman::inference::model_ids::effective_chat_model_id(config),
"[local_ai:chat] sending to ollama /api/chat"
);
let started = std::time::Instant::now();
let body = crate::openhuman::inference::local::ollama::OllamaChatRequest {
model: crate::openhuman::inference::model_ids::effective_chat_model_id(config),
let messages = messages
.into_iter()
.map(|message| match message.role.as_str() {
"system" => Message::system(message.content),
"assistant" => Message::assistant(message.content),
_ => Message::user(message.content),
})
.collect();
let outcome = super::model_rpc::invoke(
config,
self.http.clone(),
messages,
stream: false,
options: Some(
crate::openhuman::inference::local::ollama::OllamaGenerateOptions {
temperature: Some(config.default_temperature as f32),
top_k: Some(40),
top_p: Some(0.9),
num_predict: max_tokens.map(|v| v as i32),
},
),
};
let base_url = ollama_base_url_from_config(config);
let response = self
.http
.post(format!("{base_url}/api/chat"))
.json(&body)
.send()
.await
.map_err(|e| {
external_ollama_request_error_with_url("ollama chat request failed", &e, &base_url)
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let detail = body.trim();
return Err(format!(
"ollama chat failed with status {}{}",
status,
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
));
}
let payload: crate::openhuman::inference::local::ollama::OllamaChatResponse = response
.json()
.await
.map_err(|e| format!("ollama chat response parse failed: {e}"))?;
max_tokens,
config.default_temperature as f32,
false,
)
.await?;
let elapsed_ms = started.elapsed().as_millis() as u64;
let prompt_tps = payload
.prompt_eval_count
.zip(payload.prompt_eval_duration)
.and_then(|(count, dur_ns)| ns_to_tps(count as f32, dur_ns));
let gen_tps = payload
.eval_count
.zip(payload.eval_duration)
.and_then(|(count, dur_ns)| ns_to_tps(count as f32, dur_ns));
{
let mut status = self.status.lock();
status.state = "ready".to_string();
status.last_latency_ms = Some(elapsed_ms);
status.prompt_toks_per_sec = prompt_tps;
status.gen_toks_per_sec = gen_tps;
status.prompt_toks_per_sec = outcome.prompt_toks_per_sec;
status.gen_toks_per_sec = outcome.gen_toks_per_sec;
status.warning = None;
}
tracing::debug!(
elapsed_ms,
reply_len = payload.message.content.len(),
"[local_ai:chat] ollama /api/chat done"
prompt_tokens = ?outcome.prompt_tokens,
completion_tokens = ?outcome.completion_tokens,
reply_len = outcome.reply.len(),
"[local_ai:chat] tinyagents local model RPC done"
);
let reply = payload.message.content.trim().to_string();
if reply.is_empty() {
Err("ollama returned empty reply".to_string())
} else {
Ok(reply)
}
Ok(outcome.reply)
}
pub(crate) async fn inference(
@@ -545,115 +440,38 @@ impl LocalAiService {
system.to_string()
};
if provider_from_config(config) == LocalAiProvider::LmStudio {
let messages = vec![
crate::openhuman::inference::local::lm_studio::LmStudioChatMessage {
role: "system".to_string(),
content: effective_system,
},
crate::openhuman::inference::local::lm_studio::LmStudioChatMessage {
role: "user".to_string(),
content: prompt.to_string(),
},
];
let outcome = self
.lm_studio_chat_completion(config, messages, max_tokens, temperature, allow_empty)
.await?;
let elapsed_ms = started.elapsed().as_millis() as u64;
{
let mut status = self.status.lock();
status.state = "ready".to_string();
status.last_latency_ms = Some(elapsed_ms);
status.prompt_toks_per_sec = None;
status.gen_toks_per_sec = None;
status.warning = None;
}
tracing::debug!(
elapsed_ms,
prompt_tokens = ?outcome.prompt_tokens,
completion_tokens = ?outcome.completion_tokens,
reply_len = outcome.reply.len(),
"[local_ai:infer] lm studio /v1/chat/completions done"
);
return Ok(outcome.reply);
}
let body = OllamaGenerateRequest {
model: model_id,
prompt: prompt.to_string(),
system: Some(effective_system),
images: None,
stream: false,
options: Some(OllamaGenerateOptions {
temperature: Some(temperature),
top_k: Some(40),
top_p: Some(0.9),
num_predict: max_tokens.map(|v| v as i32),
}),
};
let base_url = ollama_base_url_from_config(config);
log::debug!(
"[local_ai:infer] inference_with_temperature_internal: using base_url={}",
redact_ollama_base_url(&base_url)
);
let response = self
.http
.post(format!("{base_url}/api/generate"))
.json(&body)
.send()
.await
.map_err(|e| {
external_ollama_request_error_with_url("ollama request failed", &e, &base_url)
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let detail = body.trim();
return Err(format!(
"ollama request failed with status {}{}",
status,
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
));
}
let payload: crate::openhuman::inference::local::ollama::OllamaGenerateResponse = response
.json()
.await
.map_err(|e| format!("ollama response parse failed: {e}"))?;
let outcome = super::model_rpc::invoke(
config,
self.http.clone(),
vec![
Message::system(effective_system),
Message::user(prompt.to_owned()),
],
max_tokens,
temperature,
allow_empty,
)
.await?;
let elapsed_ms = started.elapsed().as_millis() as u64;
let prompt_tps = payload
.prompt_eval_count
.zip(payload.prompt_eval_duration)
.and_then(|(count, dur_ns)| ns_to_tps(count as f32, dur_ns));
let gen_tps = payload
.eval_count
.zip(payload.eval_duration)
.and_then(|(count, dur_ns)| ns_to_tps(count as f32, dur_ns));
{
let mut status = self.status.lock();
status.state = "ready".to_string();
status.last_latency_ms = Some(elapsed_ms);
status.prompt_toks_per_sec = prompt_tps;
status.gen_toks_per_sec = gen_tps;
status.prompt_toks_per_sec = outcome.prompt_toks_per_sec;
status.gen_toks_per_sec = outcome.gen_toks_per_sec;
status.warning = None;
}
if payload.response.trim().is_empty() {
if allow_empty {
Ok(String::new())
} else {
Err("ollama returned empty content".to_string())
}
} else {
Ok(payload.response)
}
tracing::debug!(
elapsed_ms,
prompt_tokens = ?outcome.prompt_tokens,
completion_tokens = ?outcome.completion_tokens,
reply_len = outcome.reply.len(),
"[local_ai:infer] tinyagents local model RPC done"
);
Ok(outcome.reply)
}
}
@@ -15,6 +15,17 @@ fn enabled_config() -> Config {
config
}
fn openai_response(content: &str) -> serde_json::Value {
json!({
"id": "chatcmpl-test",
"choices": [{
"message": { "role": "assistant", "content": content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8 }
})
}
fn lm_studio_config(base: &str) -> Config {
let mut config = enabled_config();
config.local_ai.provider = "lm_studio".to_string();
@@ -37,22 +48,18 @@ fn ready_service(config: &Config) -> LocalAiService {
}
#[tokio::test]
async fn inference_hits_ollama_generate_and_returns_response() {
async fn inference_hits_ollama_chat_completions_and_returns_response() {
let _guard = crate::openhuman::inference::inference_test_guard();
let app = Router::new().route(
"/api/generate",
"/v1/chat/completions",
post(|Json(_body): Json<serde_json::Value>| async move {
Json(json!({
"model": "test",
"response": "hello from mock",
"done": true,
"total_duration": 1_000_000u64,
"prompt_eval_count": 5,
"prompt_eval_duration": 100_000u64,
"eval_count": 3,
"eval_duration": 500_000u64
}))
let mut response = openai_response("hello from mock");
response["prompt_eval_count"] = json!(5);
response["prompt_eval_duration"] = json!(100_000u64);
response["eval_count"] = json!(3);
response["eval_duration"] = json!(500_000u64);
Json(response)
}),
);
let base = spawn_mock(app).await;
@@ -78,7 +85,7 @@ async fn inference_errors_on_non_success_status() {
let _guard = crate::openhuman::inference::inference_test_guard();
let app = Router::new().route(
"/api/generate",
"/v1/chat/completions",
post(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "boom") }),
);
let base = spawn_mock(app).await;
@@ -124,14 +131,8 @@ async fn inference_errors_on_empty_response_when_allow_empty_false() {
let _guard = crate::openhuman::inference::inference_test_guard();
let app = Router::new().route(
"/api/generate",
post(|| async {
Json(json!({
"model": "test",
"response": " ",
"done": true
}))
}),
"/v1/chat/completions",
post(|| async { Json(openai_response(" ")) }),
);
let base = spawn_mock(app).await;
unsafe {
@@ -164,7 +165,7 @@ async fn lm_studio_prompt_hits_openai_chat_completions() {
"/v1/chat/completions",
post(|Json(body): Json<serde_json::Value>| async move {
assert_eq!(body["model"], "local-model");
assert_eq!(body["stream"], false);
assert!(body.get("stream").is_none());
assert_eq!(body["max_tokens"], 16);
assert_eq!(body["messages"][0]["role"], "system");
assert_eq!(body["messages"][1]["role"], "user");
@@ -209,7 +210,7 @@ async fn lm_studio_prompt_errors_on_non_success_status() {
let err = service.prompt(&config, "hi", None, true).await.unwrap_err();
assert!(err.contains("lm studio chat failed with status 502"));
assert!(err.contains("502"));
}
#[tokio::test]
@@ -273,14 +274,8 @@ async fn inline_complete_interactive_does_not_block_on_held_permit() {
.expect("test must start with a free permit; previous test leaked one");
let app = Router::new().route(
"/api/generate",
post(|Json(_body): Json<serde_json::Value>| async move {
Json(json!({
"model": "test",
"response": "ip",
"done": true
}))
}),
"/v1/chat/completions",
post(|Json(_body): Json<serde_json::Value>| async move { Json(openai_response("ip")) }),
);
let base = spawn_mock(app).await;
unsafe {
@@ -318,13 +313,9 @@ async fn prompt_interactive_does_not_block_on_held_permit() {
.expect("test must start with a free permit");
let app = Router::new().route(
"/api/generate",
"/v1/chat/completions",
post(|Json(_body): Json<serde_json::Value>| async move {
Json(json!({
"model": "test",
"response": "hello from mock",
"done": true
}))
Json(openai_response("hello from mock"))
}),
);
let base = spawn_mock(app).await;
@@ -359,9 +350,9 @@ async fn summarize_interactive_does_not_block_on_held_permit() {
.expect("test must start with a free permit");
let app = Router::new().route(
"/api/generate",
"/v1/chat/completions",
post(|Json(body): Json<serde_json::Value>| async move {
let prompt = body["prompt"].as_str().unwrap_or_default();
let prompt = body["messages"][1]["content"].as_str().unwrap_or_default();
assert!(
prompt.contains("commitments.\n\ntext to summarize"),
"summary prompt should use real newlines, got: {prompt:?}"
@@ -370,11 +361,7 @@ async fn summarize_interactive_does_not_block_on_held_permit() {
!prompt.contains(r"commitments.\n\ntext to summarize"),
"summary prompt must not contain literal backslash-n separators"
);
Json(json!({
"model": "test",
"response": "summary from mock",
"done": true
}))
Json(openai_response("summary from mock"))
}),
);
let base = spawn_mock(app).await;
@@ -409,13 +396,9 @@ async fn chat_with_history_interactive_does_not_block_on_held_permit() {
.expect("test must start with a free permit");
let app = Router::new().route(
"/api/chat",
"/v1/chat/completions",
post(|Json(_body): Json<serde_json::Value>| async move {
Json(json!({
"model": "test",
"message": { "role": "assistant", "content": "history reply" },
"done": true
}))
Json(openai_response("history reply"))
}),
);
let base = spawn_mock(app).await;
@@ -470,14 +453,8 @@ async fn gated_inline_complete_blocks_on_held_permit() {
.expect("test must start with a free permit");
let app = Router::new().route(
"/api/generate",
post(|Json(_body): Json<serde_json::Value>| async move {
Json(json!({
"model": "test",
"response": "x",
"done": true
}))
}),
"/v1/chat/completions",
post(|Json(_body): Json<serde_json::Value>| async move { Json(openai_response("x")) }),
);
let base = spawn_mock(app).await;
unsafe {
@@ -1,15 +1,32 @@
use crate::openhuman::agent::multimodal;
use crate::openhuman::config::Config;
use crate::openhuman::inference::local::ollama::{
ollama_base_url_from_config, redact_ollama_base_url, OllamaEmbedRequest, OllamaEmbedResponse,
OllamaGenerateOptions, OllamaGenerateRequest,
ollama_base_url_from_config, redact_ollama_base_url, OllamaGenerateOptions,
OllamaGenerateRequest,
};
use crate::openhuman::inference::model_ids;
use crate::openhuman::inference::presets::{self, VisionMode};
use crate::openhuman::inference::types::LocalAiEmbeddingResult;
use tinyagents::harness::embeddings::{
EmbeddingModel, OllamaEmbeddingModel, DEFAULT_OLLAMA_DIMENSIONS,
RECOMMENDED_OLLAMA_CONTEXT_TOKENS,
};
use super::LocalAiService;
fn embedding_dimensions(model_id: &str) -> Option<usize> {
let normalized = model_id.trim().to_ascii_lowercase();
if normalized.starts_with("all-minilm") {
Some(384)
} else if normalized.contains("bge-m3") || normalized.starts_with("mxbai-embed-large") {
Some(DEFAULT_OLLAMA_DIMENSIONS)
} else if normalized.starts_with("nomic-embed-text") {
Some(768)
} else {
None
}
}
impl LocalAiService {
pub async fn vision_prompt(
&self,
@@ -157,50 +174,45 @@ impl LocalAiService {
let _gate_permit = crate::openhuman::scheduler_gate::wait_for_capacity().await;
let embed_base = ollama_base_url_from_config(config);
let dimensions = embedding_dimensions(&embedding_model);
log::debug!(
"[local_ai:embed] embed: using base_url={}",
"[local_ai:embed] embed: using model={} dimensions={} base_url={}",
embedding_model,
dimensions
.map(|value| value.to_string())
.unwrap_or_else(|| "dynamic".to_string()),
redact_ollama_base_url(&embed_base)
);
let response = self
.http
.post(format!("{embed_base}/api/embed"))
.json(&OllamaEmbedRequest {
model: embedding_model.clone(),
input: items.clone(),
})
.send()
let (dims, vectors) = if let Some(dimensions) = dimensions {
let model = OllamaEmbeddingModel::try_new(&embed_base, &embedding_model, dimensions)
.map_err(|error| format!("invalid local embedding RPC configuration: {error}"))?
.with_client(self.http.clone())
.with_context_options(
RECOMMENDED_OLLAMA_CONTEXT_TOKENS,
RECOMMENDED_OLLAMA_CONTEXT_TOKENS,
);
let vectors = model
.embed(&items)
.await
.map_err(|error| format!("local embedding RPC failed: {error}"))?;
(model.dimensions(), vectors)
} else {
OllamaEmbeddingModel::embed_discovering_dimensions(
&embed_base,
&embedding_model,
self.http.clone(),
&items,
RECOMMENDED_OLLAMA_CONTEXT_TOKENS,
RECOMMENDED_OLLAMA_CONTEXT_TOKENS,
)
.await
.map_err(|e| format!("ollama embed request failed: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let detail = body.trim();
return Err(format!(
"ollama embed request failed with status {}{}",
status,
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
));
}
let payload: OllamaEmbedResponse = response
.json()
.await
.map_err(|e| format!("ollama embed parse failed: {e}"))?;
if payload.embeddings.is_empty() {
return Err("ollama embed returned no embeddings".to_string());
}
let dims = payload.embeddings.first().map(|v| v.len()).unwrap_or(0);
.map_err(|error| format!("local embedding RPC failed: {error}"))?
};
self.status.lock().embedding_state = "ready".to_string();
Ok(LocalAiEmbeddingResult {
model_id: embedding_model,
dimensions: dims,
vectors: payload.embeddings,
vectors,
})
}
}
@@ -308,6 +320,14 @@ mod tests {
assert!(err.contains("local ai is disabled"));
}
#[test]
fn embedding_dimensions_match_supported_legacy_models() {
assert_eq!(embedding_dimensions("bge-m3"), Some(1024));
assert_eq!(embedding_dimensions("all-minilm:latest"), Some(384));
assert_eq!(embedding_dimensions("nomic-embed-text"), Some(768));
assert_eq!(embedding_dimensions("user-managed-model"), None);
}
#[tokio::test]
async fn vision_prompt_disabled_returns_error() {
let mut config = Config::default();
@@ -496,6 +496,13 @@ mod tests {
const TEST_SLOT_ENGINE_A: &str = "__test_slot_engine_a__";
const TEST_SLOT_ENGINE_B: &str = "__test_slot_engine_b__";
fn slot_test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Best-effort drain of a test slot so the global set is clean across
/// runs. Tests that leave a slot held (e.g. by forgetting it) would
/// pollute subsequent runs in the same `cargo test` invocation.
@@ -507,6 +514,7 @@ mod tests {
#[test]
fn try_acquire_install_slot_grants_then_blocks_then_releases() {
let _test_guard = slot_test_lock();
drain_test_slot(TEST_SLOT_ENGINE_A);
// First caller gets the slot.
@@ -534,6 +542,7 @@ mod tests {
#[test]
fn install_slot_keys_are_independent_per_engine() {
let _test_guard = slot_test_lock();
drain_test_slot(TEST_SLOT_ENGINE_A);
drain_test_slot(TEST_SLOT_ENGINE_B);
@@ -561,7 +570,8 @@ mod tests {
/// same time and both spawn install tasks" — the bug CodeRabbit
/// flagged on PR #1755.
#[tokio::test]
async fn concurrent_acquire_grants_exactly_one_slot() {
async fn concurrent_install_slot_acquire_grants_exactly_one() {
let _test_guard = slot_test_lock();
drain_test_slot(TEST_SLOT_ENGINE_A);
// 32 concurrent acquirers — high enough to make a non-atomic
+1 -1
View File
@@ -40,7 +40,7 @@ one job. memory orchestrates and routes between them.
| [`remember.rs`](remember.rs) | High-level remember source classification (`chat_history`, `uploaded_data`, `llm_thought`). |
| [`ingest_pipeline.rs`](ingest_pipeline.rs) | Source-agnostic ingest orchestration. Called by sync pipelines and tree ingest RPC. |
| [`ingestion/`](ingestion/) | Document ingestion queue + extraction (entities, relations, embeddings) — feeds UnifiedMemory documents. |
| [`canonicalize/`](../memory_sync/canonicalize/) | Source → canonical markdown (chat / email / document). Implemented in `memory_sync/canonicalize` and used at ingest time. |
| [`tinycortex::memory::ingest::canonicalize`](https://github.com/tinyhumansai/tinycortex/tree/main/src/memory/ingest/canonicalize) | Source → canonical markdown (chat / email / document), owned by TinyCortex and used at ingest time. |
| [`chat/`](chat.rs) | Chat-source canonicalisation helpers. |
| [`read_rpc/`](read_rpc/) | RPC handlers for memory reads. |
| [`schemas/`](schemas/) + [`schema/`](schema/) | Controller schema definitions for the memory + memory_tree RPC namespaces. |
+1 -1
View File
@@ -5,7 +5,7 @@ use anyhow::Result;
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::config::Config;
use crate::openhuman::memory_store::chunks::store::RawRef;
use crate::openhuman::memory_sync::canonicalize::{
use tinycortex::memory::ingest::canonicalize::{
chat::{self, ChatBatch},
document::{self, DocumentInput},
email::{self, EmailThread},
@@ -1,11 +1,11 @@
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::memory_store::chunks::types::SourceKind;
use crate::openhuman::memory_sync::canonicalize::document::DocumentInput;
use crate::openhuman::memory_tree::tree::rpc;
use crate::openhuman::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use chrono::Utc;
use serde_json::json;
use tinycortex::memory::ingest::canonicalize::document::DocumentInput;
pub struct MemoryTreeIngestDocumentTool;
+1 -1
View File
@@ -5,11 +5,11 @@ use crate::openhuman::memory::ingest_pipeline::ingest_chat;
use crate::openhuman::memory_queue::drain_until_idle;
use crate::openhuman::memory_store::content::raw::{write_raw_items, RawItem, RawKind};
use crate::openhuman::memory_store::namespace_store::UnifiedMemory;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use chrono::{TimeZone, Utc};
use rusqlite::params;
use std::sync::Arc;
use tempfile::TempDir;
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
@@ -31,9 +31,9 @@ use crate::openhuman::memory_store::chunks::store::{
count_chunks, count_chunks_by_lifecycle_status, CHUNK_STATUS_BUFFERED,
};
use crate::openhuman::memory_store::trees::{store as tree_store, types::TreeKind};
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory_tree::retrieval::{query_source, search_entities};
use crate::openhuman::memory_tree::score::store::lookup_entity;
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
// ── helpers ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -20,9 +20,9 @@ use crate::openhuman::config::Config;
use crate::openhuman::memory::chat::{test_override, ChatProvider, StaticChatProvider};
use crate::openhuman::memory::ingest_pipeline::ingest_chat;
use crate::openhuman::memory_queue::drain_until_idle;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory_tree::retrieval::{query_source, search_entities};
use crate::openhuman::memory_tree::score::embed::build_embedder_from_config;
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
+1 -1
View File
@@ -81,7 +81,7 @@ No `bus.rs` / `EventHandler` of its own. It **publishes** `DomainEvent::MemorySy
- `openhuman::memory::ingest_pipeline::ingest_document` — ingests reader-backed source items into memory.
- `openhuman::memory::sync` (`emit_sync_stage`, `MemorySyncStage`, `MemorySyncTrigger`) — sync-progress event emission.
- `openhuman::memory_sync::composio` — Composio sync delegate (`run_connection_sync`, `scan_active_sync_targets`, `SyncReason`).
- `openhuman::memory_sync::canonicalize::document::DocumentInput` — document shape for ingestion.
- `tinycortex::memory::ingest::canonicalize::document::DocumentInput` — document shape for ingestion.
- `openhuman::memory_store::chunks::store::with_connection` — SQLite access for status queries against `mem_tree_chunks`.
- `core::all` (`ControllerFuture`, `RegisteredController`) and `core` schema types (`ControllerSchema`, `FieldSchema`, `TypeSchema`) — controller registry wiring.
- `rpc::RpcOutcome` — RPC return contract.
+5 -5
View File
@@ -465,7 +465,7 @@ pub struct ReconcileResponse {
/// sync; this RPC exposes it for inspection and manual triggering.
pub async fn reconcile_rpc(req: ReconcileRequest) -> Result<RpcOutcome<ReconcileResponse>, String> {
use crate::openhuman::memory_sources::sync::derive_scopes;
use crate::openhuman::memory_sync::sources::rebuild::{raw_coverage, rebuild_tree_from_raw};
use crate::openhuman::tinycortex::{raw_coverage, rebuild_tree_from_raw};
tracing::info!(
source_id = ?req.source_id,
@@ -594,12 +594,12 @@ pub async fn supported_toolkits_rpc() -> Result<RpcOutcome<SupportedToolkitsResp
#[derive(Debug, serde::Serialize)]
pub struct SyncAuditLogResponse {
pub entries: Vec<crate::openhuman::memory_sync::sources::audit::SyncAuditEntry>,
pub entries: Vec<crate::openhuman::tinycortex::SyncAuditEntry>,
}
pub async fn sync_audit_log_rpc() -> Result<RpcOutcome<SyncAuditLogResponse>, String> {
let config = config_rpc::load_config_with_timeout().await?;
let entries = crate::openhuman::memory_sync::sources::audit::read_audit_log(&config);
let entries = crate::openhuman::tinycortex::read_audit_log(&config);
Ok(RpcOutcome::new(SyncAuditLogResponse { entries }, vec![]))
}
@@ -639,7 +639,7 @@ pub async fn estimate_sync_cost_rpc(
let estimated_input_tokens = item_count as u64 * 500;
let estimated_output_tokens = item_count as u64 * 100;
let estimated_tokens = estimated_input_tokens + estimated_output_tokens;
let estimated_cost_usd = crate::openhuman::memory_sync::sources::audit::estimate_cost_usd(
let estimated_cost_usd = crate::openhuman::tinycortex::estimate_cost_usd(
estimated_input_tokens,
estimated_output_tokens,
);
@@ -672,7 +672,7 @@ pub struct MonthlyCostSummaryResponse {
pub async fn monthly_cost_summary_rpc() -> Result<RpcOutcome<MonthlyCostSummaryResponse>, String> {
tracing::debug!("[memory_sources] monthly_cost_summary_rpc: entry");
let config = config_rpc::load_config_with_timeout().await?;
let entries = crate::openhuman::memory_sync::sources::audit::read_audit_log(&config);
let entries = crate::openhuman::tinycortex::read_audit_log(&config);
let now = chrono::Utc::now();
let month_str = now.format("%Y-%m").to_string();
+3 -7
View File
@@ -141,9 +141,7 @@ pub async fn sync_source(source: MemorySourceEntry, config: Config) -> Result<()
Some(&source.id),
);
use crate::openhuman::memory_sync::sources::audit::{
append_audit_entry, SyncAuditEntry,
};
use crate::openhuman::tinycortex::{append_audit_entry, SyncAuditEntry};
append_audit_entry(
&config,
&SyncAuditEntry {
@@ -188,9 +186,7 @@ pub async fn sync_source(source: MemorySourceEntry, config: Config) -> Result<()
}
Err(error) => {
// Audit failed syncs too.
use crate::openhuman::memory_sync::sources::audit::{
append_audit_entry, SyncAuditEntry,
};
use crate::openhuman::tinycortex::{append_audit_entry, SyncAuditEntry};
append_audit_entry(
&config,
&SyncAuditEntry {
@@ -271,7 +267,7 @@ pub async fn sync_source(source: MemorySourceEntry, config: Config) -> Result<()
/// Reconcile raw files that are not yet covered by tree summaries.
pub(crate) async fn check_and_rebuild_tree(source: &MemorySourceEntry, config: &Config) {
use crate::openhuman::memory_sync::sources::rebuild::{needs_rebuild, rebuild_tree_from_raw};
use crate::openhuman::tinycortex::{needs_rebuild, rebuild_tree_from_raw};
for scope in derive_scopes(source, config) {
if !needs_rebuild(config, &scope.tree_scope, &scope.archive_source_id) {
+81 -523
View File
@@ -1,576 +1,134 @@
//! Post-extraction tag rewriting for chunk and summary `.md` files.
//! Host adapter for TinyCortex-owned markdown tag rewriting.
//!
//! After the LLM extraction job runs, it produces a list of entities. Each
//! entity is converted to an Obsidian-style hierarchical tag (`kind/Value`)
//! and written into the `tags:` block in the file's front-matter.
//!
//! The body bytes (and therefore the SHA-256) are never changed — only the
//! front-matter is rewritten.
//! Generic chunk rewrites and tag formatting live in TinyCortex. OpenHuman
//! retains only the summary adapter because it resolves product configuration,
//! content pointers, and entity-index rows.
use std::path::Path;
use super::compose::{
rewrite_summary_tags as compose_rewrite_summary_tags, rewrite_tags, scan_fm_field, source_tag,
split_front_matter,
};
use crate::openhuman::config::Config;
use crate::openhuman::memory_store::chunks::store::get_summary_content_pointers;
use crate::openhuman::memory_store::content::compose::{
rewrite_summary_tags, scan_fm_field, source_tag, split_front_matter,
};
use crate::openhuman::memory_tree::score::store::list_entity_ids_for_node;
/// Rewrite the `tags:` block in a chunk's on-disk `.md` file.
pub use tinycortex::memory::store::content::tags::{
entity_tag, slugify_tag_kind, slugify_tag_value, update_chunk_tags,
};
/// Rewrite a summary's tags from its authoritative entity-index rows.
///
/// `abs_path` — absolute path to the chunk file.
/// `tags` — new list of tag strings (Obsidian `kind/Value` format).
///
/// The operation is atomic: the new file is written to a sibling temp path and
/// then renamed over the original. If the file does not exist, the call is a
/// no-op (returns `Ok(())`).
///
/// Note: unlike the initial chunk write, tag rewrites MAY overwrite an
/// existing file. The immutability contract covers the **body** only; tags are
/// explicitly designed to be updated post-extraction.
pub fn update_chunk_tags(abs_path: &Path, tags: &[String]) -> anyhow::Result<()> {
if !abs_path.exists() {
log::debug!(
"[content_store::tags] skipping tag update — file not found: {}",
abs_path.display()
);
return Ok(());
}
let old_bytes =
std::fs::read(abs_path).map_err(|e| anyhow::anyhow!("read {:?}: {e}", abs_path))?;
// Re-seed the `source/<slug>` tag so it survives every rewrite.
// Pulled from the existing frontmatter's `source_id:` field — the
// body is already on disk, so we don't need the caller to know.
let augmented = augment_with_source_tag_for_chunk(&old_bytes, tags);
let new_bytes = rewrite_tags(&old_bytes, &augmented)
.map_err(|e| anyhow::anyhow!("rewrite_tags {:?}: {e}", abs_path))?;
// The tag rewrite must only ever touch front-matter. Verify the body is
// byte-identical before committing so a front-matter parse regression (or a
// newline-injected field) fails loud here instead of silently drifting the
// on-disk body from the DB content_sha256 and truncating retrieval (#4689).
// Mirrors the post-rewrite guard in `update_summary_tags`.
ensure_tag_rewrite_preserves_body(&old_bytes, &new_bytes, abs_path)?;
// Write the new content atomically via a sibling temp file.
let parent = abs_path.parent().unwrap_or_else(|| Path::new("."));
let tmp_name = format!(".tmp_tags_{}.md", crate_temp_id());
let tmp_path = parent.join(&tmp_name);
{
use std::io::Write;
let mut f = std::fs::File::create(&tmp_path)
.map_err(|e| anyhow::anyhow!("create tag-rewrite tempfile {:?}: {e}", tmp_path))?;
f.write_all(&new_bytes)
.map_err(|e| anyhow::anyhow!("write tag-rewrite tempfile {:?}: {e}", tmp_path))?;
f.sync_all()
.map_err(|e| anyhow::anyhow!("fsync tag-rewrite tempfile {:?}: {e}", tmp_path))?;
}
std::fs::rename(&tmp_path, abs_path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
anyhow::anyhow!("rename tag-rewrite {:?} -> {:?}: {e}", tmp_path, abs_path)
})?;
log::debug!(
"[content_store::tags] updated tags in {}",
abs_path.display()
);
Ok(())
}
/// Rewrite the `tags:` block in a summary's on-disk `.md` file.
///
/// Reads entity rows from `mem_tree_entity_index` for `summary_id`, converts
/// them to `kind/Value` Obsidian tags, rewrites the YAML `tags:` block
/// atomically (tempfile + fsync + rename), and verifies the body SHA-256 is
/// unchanged afterwards.
///
/// Best-effort: tag-rewrite failures should not fail the extraction job. Callers
/// should log a warning and continue — the entity index is the authoritative source.
/// This is host-owned glue: TinyCortex performs the generic markdown rewrite,
/// while OpenHuman supplies configuration and entity-index lookup.
pub fn update_summary_tags(config: &Config, summary_id: &str) -> anyhow::Result<()> {
// 1. Fetch content_path from SQLite.
let pointers = get_summary_content_pointers(config, summary_id)?;
let (rel_path, expected_sha) = match pointers {
Some(p) => p,
None => {
log::debug!(
"[content_store::tags] update_summary_tags: no content_path for summary {summary_id} — skipping"
);
return Ok(());
}
};
let content_root = config.memory_tree_content_root();
let abs_path = {
let mut p = content_root;
for component in rel_path.split('/') {
p.push(component);
}
p
let Some((rel_path, expected_sha)) = get_summary_content_pointers(config, summary_id)? else {
log::debug!(
"[content_store::tags] update_summary_tags: no content_path for summary {summary_id} — skipping"
);
return Ok(());
};
let mut abs_path = config.memory_tree_content_root();
for component in rel_path.split('/') {
abs_path.push(component);
}
if !abs_path.exists() {
log::debug!(
"[content_store::tags] update_summary_tags: file missing for summary {summary_id} \
at {} skipping",
"[content_store::tags] update_summary_tags: file missing for summary {summary_id} at {} — skipping",
abs_path.display()
);
return Ok(());
}
// 2. Fetch entity_index rows and build the merged tag list.
let entity_ids = list_entity_ids_for_node(config, summary_id)?;
let tags: Vec<String> = entity_ids
let mut tags = list_entity_ids_for_node(config, summary_id)?
.iter()
.filter_map(|eid| {
// entity_id format: "kind:surface"
let (kind, surface) = eid.split_once(':')?;
.filter_map(|entity_id| {
let (kind, surface) = entity_id.split_once(':')?;
Some(entity_tag(kind, surface))
})
.collect();
// Sort + dedup for stability.
let mut tags = tags;
.collect::<Vec<_>>();
tags.sort();
tags.dedup();
// 3. Read + atomic rewrite of the front-matter `tags:` block.
let old_bytes = std::fs::read(&abs_path)
.map_err(|e| anyhow::anyhow!("read summary {:?}: {e}", abs_path))?;
.map_err(|error| anyhow::anyhow!("read summary {:?}: {error}", abs_path))?;
let tags = augment_with_source_tag(&old_bytes, &tags);
let new_bytes = rewrite_summary_tags(&old_bytes, &tags)
.map_err(|error| anyhow::anyhow!("rewrite_summary_tags {:?}: {error}", abs_path))?;
// Re-seed `source/<slug>` for source-tree summaries. Skip for
// global / topic trees where the source isn't a single value.
let tags = augment_with_source_tag_for_summary(&old_bytes, &tags);
let new_bytes = compose_rewrite_summary_tags(&old_bytes, &tags)
.map_err(|e| anyhow::anyhow!("rewrite_summary_tags {:?}: {e}", abs_path))?;
write_atomically(&abs_path, &new_bytes)?;
let parent = abs_path.parent().unwrap_or_else(|| Path::new("."));
let tmp_name = format!(".tmp_sum_tags_{}.md", crate_temp_id());
let tmp_path = parent.join(&tmp_name);
{
use std::io::Write;
let mut f = std::fs::File::create(&tmp_path).map_err(|e| {
anyhow::anyhow!("create summary tag-rewrite tempfile {:?}: {e}", tmp_path)
})?;
f.write_all(&new_bytes).map_err(|e| {
anyhow::anyhow!("write summary tag-rewrite tempfile {:?}: {e}", tmp_path)
})?;
f.sync_all().map_err(|e| {
anyhow::anyhow!("fsync summary tag-rewrite tempfile {:?}: {e}", tmp_path)
})?;
}
std::fs::rename(&tmp_path, &abs_path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
anyhow::anyhow!(
"rename summary tag-rewrite {:?} -> {:?}: {e}",
tmp_path,
abs_path
)
})?;
// 4. Sanity check: body sha must still match after the rewrite.
let verify_bytes = std::fs::read(&abs_path)
.map_err(|e| anyhow::anyhow!("re-read after tag rewrite {:?}: {e}", abs_path))?;
.map_err(|error| anyhow::anyhow!("re-read after tag rewrite {:?}: {error}", abs_path))?;
let content = std::str::from_utf8(&verify_bytes)
.map_err(|e| anyhow::anyhow!("UTF-8 after tag rewrite {:?}: {e}", abs_path))?;
let body_after = super::compose::split_front_matter(content)
.map_err(|error| anyhow::anyhow!("UTF-8 after tag rewrite {:?}: {error}", abs_path))?;
let body = split_front_matter(content)
.ok_or_else(|| anyhow::anyhow!("no front-matter after tag rewrite {:?}", abs_path))?
.1;
let actual_sha = super::atomic::sha256_hex(body_after.as_bytes());
let actual_sha = super::atomic::sha256_hex(body.as_bytes());
if actual_sha != expected_sha {
return Err(anyhow::anyhow!(
"[content_store::tags] update_summary_tags body mutated after rewrite \
summary_id={summary_id} expected_sha={expected_sha} actual_sha={actual_sha}"
"[content_store::tags] update_summary_tags body mutated after rewrite summary_id={summary_id} expected_sha={expected_sha} actual_sha={actual_sha}"
));
}
log::debug!(
"[content_store::tags] updated {} tags in summary file summary_id={summary_id} n_tags={}",
tags.len(),
"[content_store::tags] updated summary tags summary_id={summary_id} n_tags={}",
tags.len()
);
Ok(())
}
/// Guard for [`update_chunk_tags`]: the body (front-matter excluded) must be
/// byte-identical before and after a tag rewrite. A drift here would desync the
/// on-disk body from the DB `content_sha256` and silently truncate retrieval
/// (#4689), so surface it as a loud error rather than committing the rewrite.
fn ensure_tag_rewrite_preserves_body(
old_bytes: &[u8],
new_bytes: &[u8],
abs_path: &Path,
) -> anyhow::Result<()> {
let body = |bytes: &[u8]| -> Option<String> {
std::str::from_utf8(bytes)
.ok()
.and_then(split_front_matter)
.map(|(_, body)| body.to_string())
};
// Require BOTH sides to parse AND match. Comparing `Option`s directly would
// let two un-parseable sides (`None == None`) pass — the exact silent-drift
// case this guard exists to catch — so treat an unparseable body as a failure.
match (body(old_bytes), body(new_bytes)) {
(Some(a), Some(b)) if a == b => Ok(()),
_ => Err(anyhow::anyhow!(
"[content_store::tags] update_chunk_tags would mutate or invalidate the body for {:?} — aborting rewrite",
abs_path
)),
}
}
/// Slugify an entity kind string for use in an Obsidian hierarchical tag.
///
/// Output: lowercase, spaces and non-alphanumeric chars replaced with `-`,
/// consecutive dashes collapsed, leading/trailing dashes stripped.
///
/// Example: `"Person"` → `"person"`, `"GitHub Repo"` → `"github-repo"`
pub fn slugify_tag_kind(kind: &str) -> String {
slugify_tag_component(kind)
}
/// Slugify an entity value string for use in an Obsidian hierarchical tag.
///
/// Like `slugify_tag_kind`, but capitalises the first letter of each word
/// so values are visually distinct from kinds:
///
/// `"alice johnson"` → `"Alice-Johnson"`,
/// `"project Phoenix"` → `"Project-Phoenix"`
pub fn slugify_tag_value(value: &str) -> String {
// Split on non-alphanumeric boundaries, capitalise first letter of each word.
let mut parts: Vec<String> = Vec::new();
let mut current = String::new();
for ch in value.chars() {
if ch.is_alphanumeric() || ch == '_' {
current.push(ch);
} else if !current.is_empty() {
parts.push(capitalise(&current));
current.clear();
}
}
if !current.is_empty() {
parts.push(capitalise(&current));
}
let joined = parts.join("-");
if joined.is_empty() {
"unknown".to_string()
} else {
joined
}
}
/// Build an Obsidian-style `kind/Value` tag string from raw entity kind + surface.
pub fn entity_tag(kind: &str, surface: &str) -> String {
format!("{}/{}", slugify_tag_kind(kind), slugify_tag_value(surface))
}
fn slugify_tag_component(s: &str) -> String {
let lower = s.to_lowercase();
let mut out = String::new();
let mut last_dash = true;
for ch in lower.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
out.push(ch);
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
let trimmed = out.trim_end_matches('-');
if trimmed.is_empty() {
"unknown".to_string()
} else {
trimmed.to_string()
}
}
fn capitalise(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let upper: String = first.to_uppercase().collect();
upper + chars.as_str()
}
}
}
/// Read `path_scope:` / `source_id:` out of a chunk file's existing frontmatter and
/// return `[source/<slug>, ...tags]` (deduped). Falls back to `tags`
/// unchanged if the frontmatter can't be parsed — better to keep the
/// caller's tags than to error out a best-effort rewrite path.
fn augment_with_source_tag_for_chunk(file_bytes: &[u8], tags: &[String]) -> Vec<String> {
let Ok(text) = std::str::from_utf8(file_bytes) else {
return tags.to_vec();
};
let Some((fm, _body)) = split_front_matter(text) else {
return tags.to_vec();
};
let Some(source_scope) =
scan_fm_field(fm, "path_scope").or_else(|| scan_fm_field(fm, "source_id"))
fn augment_with_source_tag(file_bytes: &[u8], tags: &[String]) -> Vec<String> {
let Some(front_matter) = std::str::from_utf8(file_bytes)
.ok()
.and_then(split_front_matter)
.map(|(front_matter, _)| front_matter)
else {
return tags.to_vec();
};
let st = source_tag(&source_scope);
let mut out = Vec::with_capacity(tags.len() + 1);
out.push(st.clone());
for t in tags {
if t != &st {
out.push(t.clone());
}
}
out
}
/// Same as `augment_with_source_tag_for_chunk` but for summary files —
/// pulls `tree_scope:` and only seeds the source tag when `tree_kind:`
/// is `source`. Global / topic trees pass through unchanged.
fn augment_with_source_tag_for_summary(file_bytes: &[u8], tags: &[String]) -> Vec<String> {
let Ok(text) = std::str::from_utf8(file_bytes) else {
let Some(tree_kind) = scan_fm_field(front_matter, "tree_kind") else {
return tags.to_vec();
};
let Some((fm, _body)) = split_front_matter(text) else {
if tree_kind != "source" {
return tags.to_vec();
}
let Some(tree_scope) = scan_fm_field(front_matter, "tree_scope") else {
return tags.to_vec();
};
if scan_fm_field(fm, "tree_kind").as_deref() != Some("source") {
return tags.to_vec();
}
let Some(scope) = scan_fm_field(fm, "tree_scope") else {
return tags.to_vec();
};
let st = source_tag(&scope);
let mut out = Vec::with_capacity(tags.len() + 1);
out.push(st.clone());
for t in tags {
if t != &st {
out.push(t.clone());
}
}
out
let source = source_tag(&tree_scope);
std::iter::once(source.clone())
.chain(tags.iter().filter(|tag| *tag != &source).cloned())
.collect()
}
fn crate_temp_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
format!("{ns:08x}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory_store::chunks::types::{Chunk, Metadata, SourceKind};
use crate::openhuman::memory_store::content::atomic::{sha256_hex, write_if_new};
use crate::openhuman::memory_store::content::compose::compose_chunk_file;
use chrono::TimeZone;
use tempfile::TempDir;
fn sample_chunk() -> Chunk {
let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
Chunk {
id: "tags_test".into(),
content: "hello from tags test".into(),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: "slack:#eng".into(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec!["old/Tag".into()],
source_ref: None,
path_scope: None,
},
token_count: 4,
seq_in_source: 0,
created_at: ts,
partial_message: false,
}
}
#[test]
fn update_chunk_tags_replaces_tag_block() {
let dir = TempDir::new().unwrap();
let chunk = sample_chunk();
let (full, _) = compose_chunk_file(&chunk);
let path = dir.path().join("0.md");
write_if_new(&path, &full).unwrap();
update_chunk_tags(
&path,
&["person/Alice-Smith".into(), "project/Phoenix".into()],
)
.unwrap();
let updated = std::fs::read_to_string(&path).unwrap();
assert!(updated.contains(" - person/Alice-Smith"));
assert!(updated.contains(" - project/Phoenix"));
assert!(!updated.contains(" - old/Tag"));
// Source tag re-seeded automatically from the existing frontmatter.
assert!(updated.contains(" - source/slack-eng"));
// Body unchanged.
assert!(updated.ends_with("hello from tags test"));
}
#[test]
fn update_chunk_tags_prefers_path_scope_for_source_tag() {
let dir = TempDir::new().unwrap();
let mut chunk = sample_chunk();
chunk.metadata.source_id = "notion:conn-1:page-123".into();
chunk.metadata.path_scope = Some("notion:conn-1".into());
let (full, _) = compose_chunk_file(&chunk);
let path = dir.path().join("0.md");
write_if_new(&path, &full).unwrap();
update_chunk_tags(&path, &["project/Phoenix".into()]).unwrap();
let updated = std::fs::read_to_string(&path).unwrap();
assert!(updated.contains("path_scope: \"notion:conn-1\""));
assert!(updated.contains(" - source/notion-conn-1"));
assert!(!updated.contains(" - source/notion-conn-1-page-123"));
assert!(updated.contains(" - project/Phoenix"));
}
#[test]
fn compose_chunk_file_seeds_source_tag() {
let chunk = sample_chunk();
let (full, _) = compose_chunk_file(&chunk);
let text = std::str::from_utf8(&full).unwrap();
assert!(text.contains(" - source/slack-eng"), "{text}");
// Existing meta tag survives alongside the seed.
assert!(text.contains(" - old/Tag"), "{text}");
}
#[test]
fn update_chunk_tags_is_noop_for_missing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nonexistent.md");
assert!(update_chunk_tags(&path, &["p/X".into()]).is_ok());
}
#[test]
fn ensure_tag_rewrite_preserves_body_accepts_equal_bodies() {
let p = std::path::Path::new("x.md");
// Same body, different front-matter → allowed.
let old = b"---\nk: v\n---\nBODY";
let new = b"---\nk: other\ntags:\n - t\n---\nBODY";
assert!(ensure_tag_rewrite_preserves_body(old, new, p).is_ok());
}
#[test]
fn ensure_tag_rewrite_preserves_body_rejects_body_drift() {
let p = std::path::Path::new("x.md");
let old = b"---\nk: v\n---\nBODY";
let drifted = b"---\nk: v\n---\nDIFFERENT BODY";
assert!(ensure_tag_rewrite_preserves_body(old, drifted, p).is_err());
}
#[test]
fn ensure_tag_rewrite_preserves_body_rejects_unparseable_bodies() {
// Both sides lack front-matter → both parse to None. The guard must still
// fail rather than let `None == None` pass silently.
let p = std::path::Path::new("x.md");
assert!(ensure_tag_rewrite_preserves_body(b"no front matter", b"still none", p).is_err());
}
#[test]
fn slugify_tag_kind_examples() {
assert_eq!(slugify_tag_kind("Person"), "person");
assert_eq!(slugify_tag_kind("GitHub Repo"), "github-repo");
assert_eq!(slugify_tag_kind("EMAIL"), "email");
}
#[test]
fn slugify_tag_value_capitalises_words() {
assert_eq!(slugify_tag_value("alice johnson"), "Alice-Johnson");
assert_eq!(slugify_tag_value("project Phoenix"), "Project-Phoenix");
assert_eq!(slugify_tag_value("OPENAI"), "OPENAI");
}
#[test]
fn entity_tag_builds_obsidian_tag() {
assert_eq!(
entity_tag("person", "Alice Johnson"),
"person/Alice-Johnson"
);
assert_eq!(entity_tag("ORG", "Tinyhumans AI"), "org/Tinyhumans-AI");
}
// ─── update_summary_tags tests ────────────────────────────────────────────
/// Write a summary .md file to disk with empty tags and verify rewriting works.
#[test]
fn rewrite_summary_tags_preserves_body_and_replaces_tags() {
use crate::openhuman::memory_store::content::compose::{
compose_summary_md, SummaryComposeInput,
};
use crate::openhuman::memory_store::content::paths::SummaryTreeKind;
let dir = TempDir::new().unwrap();
let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
let body = "summary body for tag test\n";
let children = vec!["c1".to_string()];
let input = SummaryComposeInput {
summary_id: "sum:L1:tagtest",
tree_kind: SummaryTreeKind::Source,
tree_id: "t1",
tree_scope: "gmail:alice@x.com",
level: 1,
child_ids: &children,
child_basenames: None,
child_count: 1,
time_range_start: ts,
time_range_end: ts,
sealed_at: ts,
body,
};
let composed = compose_summary_md(&input);
let path = dir.path().join("sum.md");
write_if_new(&path, composed.full.as_bytes()).unwrap();
// Original starts with the seeded source tag for the source tree.
let original = std::fs::read_to_string(&path).unwrap();
assert!(original.contains(" - source/"), "{original}");
// Rewrite the tags block
let new_tags = vec!["person/Alice-Smith".to_string(), "topic/Memory".to_string()];
let file_bytes = std::fs::read(&path).unwrap();
let rewritten = super::compose_rewrite_summary_tags(&file_bytes, &new_tags).unwrap();
// Write rewritten bytes back (simulating atomic rewrite)
let tmp = dir.path().join("sum.tmp.md");
{
use std::io::Write;
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(&rewritten).unwrap();
}
std::fs::rename(&tmp, &path).unwrap();
let updated = std::fs::read_to_string(&path).unwrap();
assert!(updated.contains(" - person/Alice-Smith"));
assert!(updated.contains(" - topic/Memory"));
assert!(!updated.contains("tags: []"));
// Body unchanged
assert!(updated.ends_with(body));
// Body sha unchanged
use crate::openhuman::memory_store::content::compose::split_front_matter;
let (_, body_after) = split_front_matter(&updated).unwrap();
let sha = sha256_hex(body_after.as_bytes());
let expected_sha = sha256_hex(body.as_bytes());
assert_eq!(
sha, expected_sha,
"body sha must be stable after tag rewrite"
);
}
fn write_atomically(abs_path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
use std::io::Write;
let parent = abs_path.parent().unwrap_or_else(|| Path::new("."));
let tmp_path = parent.join(format!(
".tmp_sum_tags_{}.md",
uuid::Uuid::new_v4().simple()
));
let result = (|| {
let mut file = std::fs::File::create(&tmp_path)
.map_err(|error| anyhow::anyhow!("create tag tempfile {:?}: {error}", tmp_path))?;
file.write_all(bytes)
.map_err(|error| anyhow::anyhow!("write tag tempfile {:?}: {error}", tmp_path))?;
file.sync_all()
.map_err(|error| anyhow::anyhow!("fsync tag tempfile {:?}: {error}", tmp_path))?;
std::fs::rename(&tmp_path, abs_path).map_err(|error| {
anyhow::anyhow!(
"rename tag tempfile {:?} -> {:?}: {error}",
tmp_path,
abs_path
)
})
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
result
}
@@ -127,9 +127,51 @@ impl Tool for MemoryStoreRawChunksTool {
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use tempfile::TempDir;
use crate::openhuman::config::{Config, TEST_ENV_LOCK};
use crate::openhuman::tools::traits::Tool;
use serde_json::json;
struct WorkspaceEnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
previous: Option<OsString>,
}
impl WorkspaceEnvGuard {
fn set(path: &std::path::Path) -> Self {
let lock = TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner());
let previous = std::env::var_os("OPENHUMAN_WORKSPACE");
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", path);
}
Self {
_lock: lock,
previous,
}
}
}
impl Drop for WorkspaceEnvGuard {
fn drop(&mut self) {
unsafe {
if let Some(previous) = self.previous.as_ref() {
std::env::set_var("OPENHUMAN_WORKSPACE", previous);
} else {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
}
}
async fn isolated_config(tmp: &TempDir) -> (WorkspaceEnvGuard, Config) {
let guard = WorkspaceEnvGuard::set(tmp.path());
let config = Config::load_or_init().await.expect("load config");
(guard, config)
}
#[test]
fn args_deserialize_optional_filters() {
let args: Args = serde_json::from_value(json!({
@@ -192,6 +234,8 @@ mod tests {
#[tokio::test]
async fn execute_success_path_returns_json_array() {
let tmp = TempDir::new().expect("tempdir");
let (_workspace, _config) = isolated_config(&tmp).await;
let tool = MemoryStoreRawChunksTool;
let result = tool
.execute(json!({
@@ -106,9 +106,51 @@ impl Tool for MemoryStoreRawSearchTool {
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use tempfile::TempDir;
use crate::openhuman::config::{Config, TEST_ENV_LOCK};
use crate::openhuman::tools::traits::Tool;
use serde_json::json;
struct WorkspaceEnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
previous: Option<OsString>,
}
impl WorkspaceEnvGuard {
fn set(path: &std::path::Path) -> Self {
let lock = TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner());
let previous = std::env::var_os("OPENHUMAN_WORKSPACE");
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", path);
}
Self {
_lock: lock,
previous,
}
}
}
impl Drop for WorkspaceEnvGuard {
fn drop(&mut self) {
unsafe {
if let Some(previous) = self.previous.as_ref() {
std::env::set_var("OPENHUMAN_WORKSPACE", previous);
} else {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
}
}
async fn isolated_config(tmp: &TempDir) -> (WorkspaceEnvGuard, Config) {
let guard = WorkspaceEnvGuard::set(tmp.path());
let config = Config::load_or_init().await.expect("load config");
(guard, config)
}
#[test]
fn default_limit_is_five() {
assert_eq!(default_limit(), 5);
@@ -158,6 +200,8 @@ mod tests {
#[tokio::test]
async fn execute_success_path_returns_json_array() {
let tmp = TempDir::new().expect("tempdir");
let (_workspace, _config) = isolated_config(&tmp).await;
let tool = MemoryStoreRawSearchTool;
let result = tool
.execute(json!({
@@ -1,17 +0,0 @@
# canonicalize/
Source-specific adapters that normalise upstream payloads (chat batches, email threads, documents) into a single shape — `CanonicalisedSource { markdown, metadata }` — that the chunker downstream slices into bounded chunks.
Adapters do not interpret content semantically; they only normalise shape and capture provenance. Scoring / extraction / summarisation happen later in the pipeline.
## Files
- [`mod.rs`](mod.rs) — `CanonicalisedSource` struct, generic `CanonicaliseRequest<P>` envelope, and `normalize_source_ref` helper shared by all adapters.
- `vendor/tinycortex/src/memory/ingest/canonicalize/chat.rs` — chat transcripts (Slack / Discord / Telegram / WhatsApp) → Markdown of `## <ts> — <author>\n<body>` blocks. Sorts messages and captures `time_range`. Produces empty-input `Ok(None)`.
- `vendor/tinycortex/src/memory/ingest/canonicalize/document.rs` — single documents (Notion page, Drive doc, meeting note, uploaded file) → trimmed body Markdown. `time_range` collapses to a single point at `modified_at`.
- `vendor/tinycortex/src/memory/ingest/canonicalize/email.rs` — email threads (Gmail + generic) → per-message `---\nFrom: …\nSubject: …\nDate: …\n\n<cleaned-body>` blocks. Bodies pass through `email_clean::clean_body` first.
- `vendor/tinycortex/src/memory/ingest/canonicalize/email_clean.rs` — pure-string helpers: `clean_body` (strip reply chains + footer/legal boilerplate), `truncate_body`, `md_escape`, `extract_email`, `parse_message_date`. Used by both the email canonicaliser and the `gmail-fetch-emails` bin.
## Output contract
The canonicalised Markdown carries no leading `# Header` line — provider/title metadata lives in YAML front-matter written by `content_store/compose.rs`. The chunker relies on the `##` prefix followed by a space (chat) and `---\nFrom:` (email) boundaries to split at message granularity.
@@ -1,3 +0,0 @@
//! Compatibility facade for tinycortex canonical ingestion shapes.
pub use tinycortex::memory::ingest::canonicalize::*;
+70 -7
View File
@@ -63,9 +63,7 @@ use crate::openhuman::composio::client::{
create_composio_client, direct_list_connections, ComposioClientKind,
};
use crate::openhuman::composio::ops;
use crate::openhuman::memory_sync::sources::audit::{
append_audit_entry, read_audit_log, SyncAuditEntry,
};
use crate::openhuman::tinycortex::{append_audit_entry, try_read_audit_log, SyncAuditEntry};
use chrono::{DateTime, Utc};
/// How often the scheduler wakes up to look for due syncs. Independent
@@ -450,7 +448,12 @@ pub(crate) async fn run_one_tick() -> Result<(), String> {
// "Sync every 24h" gap across app restarts. We index the persisted sync
// audit log (wall-clock timestamps that survive restarts) and use it as the
// due-check fallback whenever the in-memory monotonic record is absent.
let audit_index = index_last_success_by_connection(&read_audit_log(&config));
let (audit_index, audit_available) = composio_audit_state(try_read_audit_log(&config));
if !audit_available {
tracing::warn!(
"[memory_sync:periodic] audit unavailable; sources without in-memory cadence will be skipped"
);
}
let now = Utc::now();
// Per-source registry snapshot (#2831). The periodic loop gates on the
@@ -513,11 +516,21 @@ pub(crate) async fn run_one_tick() -> Result<(), String> {
// Prefer the in-memory monotonic record (most accurate within this run);
// fall back to the persisted audit timestamp so the configured cadence
// is honoured across restarts instead of re-firing on every cold start.
let since_last_sync = {
let in_memory_since = {
let map = sync_map.lock().unwrap_or_else(|e| e.into_inner());
map.get(&key).map(|when| when.elapsed())
}
.or_else(|| persisted_since_last_sync(&audit_index, &conn.id, now));
};
let Some(since_last_sync) = cadence_from_audit(
in_memory_since,
audit_available,
persisted_since_last_sync(&audit_index, &conn.id, now),
) else {
tracing::debug!(
toolkit = %toolkit,
"[composio:periodic] source has unknown cadence while audit is unavailable; skipping"
);
continue;
};
if !connection_is_due(interval_secs, since_last_sync) {
continue;
}
@@ -624,6 +637,30 @@ pub(crate) async fn run_one_tick() -> Result<(), String> {
Ok(())
}
fn composio_audit_state(
read: anyhow::Result<Vec<SyncAuditEntry>>,
) -> (HashMap<String, DateTime<Utc>>, bool) {
match read {
Ok(entries) => (index_last_success_by_connection(&entries), true),
Err(error) => {
tracing::warn!(%error, "[memory_sync:periodic] audit read failed");
(HashMap::new(), false)
}
}
}
fn cadence_from_audit(
in_memory_since: Option<Duration>,
audit_available: bool,
persisted_since: Option<Duration>,
) -> Option<Option<Duration>> {
match in_memory_since {
Some(since) => Some(Some(since)),
None if audit_available => Some(persisted_since),
None => None,
}
}
fn periodic_source(toolkit: &str, connection_id: &str) -> MemorySourceEntry {
MemorySourceEntry {
id: format!("composio:{connection_id}"),
@@ -1033,6 +1070,32 @@ mod tests {
assert!(connection_is_due(interval, fresh));
}
#[test]
fn audit_failure_is_unavailable_and_unknown_cadence_is_skipped() {
let (index, available) =
composio_audit_state(Err(anyhow::anyhow!("simulated audit I/O failure")));
assert!(index.is_empty());
assert!(!available);
assert_eq!(cadence_from_audit(None, available, None), None);
let known = Duration::from_secs(60);
assert_eq!(
cadence_from_audit(Some(known), available, None),
Some(Some(known))
);
}
#[test]
fn readable_empty_audit_preserves_first_sync_behavior() {
let (index, available) = composio_audit_state(Ok(Vec::new()));
assert!(index.is_empty());
assert!(available);
let cadence = cadence_from_audit(None, available, None)
.expect("readable empty audit keeps the source eligible");
assert!(connection_is_due(3600, cadence));
}
/// A successful periodic tick produces a Composio-kind audit entry that
/// carries the billable-action tally + cost and zeroes the LLM-cost
/// columns (summarisation happens later in the job worker). Pins the
-17
View File
@@ -26,24 +26,7 @@
//! own retry/backoff policy. The trait gives the orchestrator a
//! single shape to call; everything else stays local.
pub mod canonicalize;
pub mod composio;
pub mod mcp;
pub mod sources;
pub mod sync_status;
pub mod traits;
pub mod workspace;
pub use traits::{SyncOutcome, SyncPipeline, SyncPipelineKind};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reexports_sync_pipeline_kind_labels() {
assert_eq!(SyncPipelineKind::Composio.as_str(), "composio");
assert_eq!(SyncPipelineKind::Workspace.as_str(), "workspace");
assert_eq!(SyncPipelineKind::Mcp.as_str(), "mcp");
}
}
@@ -1,29 +0,0 @@
//! OpenHuman configuration wrappers for tinycortex sync audit ownership.
use crate::openhuman::config::Config;
pub use tinycortex::memory::sync::{RealCostAccumulator, SyncAuditEntry};
pub fn append_audit_entry(config: &Config, entry: &SyncAuditEntry) {
let memory_config =
crate::openhuman::tinycortex::memory_config_from(config, config.workspace_dir.clone());
if let Err(error) = tinycortex::memory::sync::append_audit_entry(&memory_config, entry) {
tracing::warn!(%error, "[memory_sync:audit] tinycortex append failed");
}
}
pub fn read_audit_log(config: &Config) -> Vec<SyncAuditEntry> {
let memory_config =
crate::openhuman::tinycortex::memory_config_from(config, config.workspace_dir.clone());
match tinycortex::memory::sync::read_audit_log(&memory_config) {
Ok(entries) => entries,
Err(error) => {
tracing::warn!(%error, "[memory_sync:audit] tinycortex read failed");
Vec::new()
}
}
}
pub fn estimate_cost_usd(input_tokens: u64, output_tokens: u64) -> f64 {
tinycortex::memory::sync::estimate_cost_usd(input_tokens, output_tokens)
}
@@ -1,23 +0,0 @@
//! Compatibility wrapper for the tinycortex GitHub repository pipeline.
use crate::openhuman::config::Config;
use crate::openhuman::memory_sources::MemorySourceEntry;
pub use tinycortex::memory::sync::SyncOutcome;
pub async fn run_github_sync(
source: &MemorySourceEntry,
config: &Config,
) -> anyhow::Result<SyncOutcome> {
tracing::debug!(
source_id = %source.id,
"[memory_sync:github] dispatching through tinycortex"
);
if crate::openhuman::memory::global::client_if_ready().is_none() {
crate::openhuman::memory::global::init(config.workspace_dir.clone())
.map_err(anyhow::Error::msg)?;
}
crate::openhuman::tinycortex::run_source_pipeline(source, config)
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))
}
-7
View File
@@ -1,7 +0,0 @@
//! Memory-source sync pipelines.
//!
//! Product-owned wrappers around tinycortex source sync facilities.
pub mod audit;
pub mod github;
pub mod rebuild;
@@ -1,38 +0,0 @@
//! OpenHuman configuration and LLM wrappers for tinycortex raw rebuilds.
use crate::openhuman::config::Config;
pub use tinycortex::memory::sync::{RawCoverage, RawFileRef, RebuildOutcome};
pub fn raw_coverage(
config: &Config,
tree_scope: &str,
archive_source_id: &str,
) -> anyhow::Result<RawCoverage> {
let memory_config =
crate::openhuman::tinycortex::memory_config_from(config, config.workspace_dir.clone());
tinycortex::memory::sync::raw_coverage(&memory_config, tree_scope, archive_source_id)
}
pub fn needs_rebuild(config: &Config, tree_scope: &str, archive_source_id: &str) -> bool {
let memory_config =
crate::openhuman::tinycortex::memory_config_from(config, config.workspace_dir.clone());
tinycortex::memory::sync::needs_rebuild(&memory_config, tree_scope, archive_source_id)
}
pub async fn rebuild_tree_from_raw(
config: &Config,
tree_scope: &str,
archive_source_id: &str,
) -> anyhow::Result<RebuildOutcome> {
let memory_config =
crate::openhuman::tinycortex::memory_config_from(config, config.workspace_dir.clone());
let summariser = crate::openhuman::tinycortex::HostSummariser::new(config.clone());
tinycortex::memory::sync::rebuild_tree_from_raw(
&memory_config,
tree_scope,
archive_source_id,
&summariser,
)
.await
}
-93
View File
@@ -1,93 +0,0 @@
//! Shared sync-pipeline trait.
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::openhuman::config::Config;
/// The three flavors of sync pipeline. Knowing the kind at the orchestrator
/// is useful for surfaces like status dashboards and rate-limit budgeting.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncPipelineKind {
Composio,
Workspace,
Mcp,
}
impl SyncPipelineKind {
pub fn as_str(self) -> &'static str {
match self {
SyncPipelineKind::Composio => "composio",
SyncPipelineKind::Workspace => "workspace",
SyncPipelineKind::Mcp => "mcp",
}
}
}
/// Result of one sync tick — minimal enough that every pipeline can fill
/// it in. Detailed per-pipeline progress lives behind the pipeline's own
/// status surface.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SyncOutcome {
/// How many upstream records were ingested into memory_store during
/// this tick. May be 0 when nothing new arrived.
pub records_ingested: u32,
/// `true` when the pipeline thinks there is more to fetch and the
/// orchestrator should tick again soon.
pub more_pending: bool,
/// Free-form note for logs / status UIs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// Contract every sync pipeline implements. Lifecycle: `init` exactly
/// once when the pipeline first comes up, then `tick` on a cadence the
/// orchestrator picks.
#[async_trait]
pub trait SyncPipeline: Send + Sync {
/// Stable identifier for the pipeline — e.g. `"composio:gmail"`,
/// `"workspace:vault"`, `"mcp:filesystem"`. Used as the key in
/// status surfaces and the job-queue.
fn id(&self) -> &str;
/// Which kind of pipeline this is.
fn kind(&self) -> SyncPipelineKind;
/// Cold-start work. Idempotent — the orchestrator may call it on
/// every process boot.
async fn init(&self, config: &Config) -> anyhow::Result<()>;
/// Pull one batch from upstream and land it in memory_store. Pipeline
/// owns its own pagination / cursor state.
async fn tick(&self, config: &Config) -> anyhow::Result<SyncOutcome>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_pipeline_kind_as_str_matches_serde_names() {
let cases = [
(SyncPipelineKind::Composio, "composio"),
(SyncPipelineKind::Workspace, "workspace"),
(SyncPipelineKind::Mcp, "mcp"),
];
for (kind, label) in cases {
assert_eq!(kind.as_str(), label);
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, format!("\"{label}\""));
let decoded: SyncPipelineKind = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, kind);
}
}
#[test]
fn sync_outcome_default_is_empty_and_not_pending() {
let outcome = SyncOutcome::default();
assert_eq!(outcome.records_ingested, 0);
assert!(!outcome.more_pending);
assert!(outcome.note.is_none());
}
}
@@ -39,8 +39,8 @@ use crate::openhuman::memory_sources::types::{MemorySourceEntry, SourceKind};
use crate::openhuman::memory_sync::composio::periodic::{
connection_is_due, effective_interval_secs, periodic_pause_reason,
};
use crate::openhuman::memory_sync::sources::audit::{read_audit_log, SyncAuditEntry};
use crate::openhuman::scheduler_gate::gate::resume_notify;
use crate::openhuman::tinycortex::{try_read_audit_log, SyncAuditEntry};
/// How often the scheduler wakes up to look for due syncs. Matches the
/// Composio loop's cadence — per-source intervals (24h default) bound the
@@ -181,7 +181,12 @@ pub(crate) async fn run_one_tick() -> Result<(), String> {
return Ok(());
};
let audit_index = index_last_success_by_source_id(&read_audit_log(&config));
let (audit_index, audit_available) = workspace_audit_state(try_read_audit_log(&config));
if !audit_available {
tracing::warn!(
"[memory_sync:workspace:periodic] audit unavailable; sources without in-memory cadence will be skipped"
);
}
let now = Utc::now();
let map = fired_map();
@@ -190,11 +195,21 @@ pub(crate) async fn run_one_tick() -> Result<(), String> {
.iter()
.filter(|s| s.enabled && is_workspace_synced_kind(&s.kind))
.filter(|s| {
let since = {
let in_memory_since = {
let guard = map.lock().unwrap_or_else(|e| e.into_inner());
guard.get(&s.id).map(|when| when.elapsed())
}
.or_else(|| persisted_since_last_sync(&audit_index, &s.id, now));
};
let Some(since) = cadence_from_audit(
in_memory_since,
audit_available,
persisted_since_last_sync(&audit_index, &s.id, now),
) else {
tracing::debug!(
source_kind = %s.kind.as_str(),
"[memory_sync:workspace:periodic] source has unknown cadence while audit is unavailable; skipping"
);
return false;
};
connection_is_due(interval_secs, since)
})
.cloned()
@@ -239,6 +254,30 @@ pub(crate) async fn run_one_tick() -> Result<(), String> {
Ok(())
}
fn workspace_audit_state(
read: anyhow::Result<Vec<SyncAuditEntry>>,
) -> (HashMap<String, DateTime<Utc>>, bool) {
match read {
Ok(entries) => (index_last_success_by_source_id(&entries), true),
Err(error) => {
tracing::warn!(%error, "[memory_sync:workspace:periodic] audit read failed");
(HashMap::new(), false)
}
}
}
fn cadence_from_audit(
in_memory_since: Option<Duration>,
audit_available: bool,
persisted_since: Option<Duration>,
) -> Option<Option<Duration>> {
match in_memory_since {
Some(since) => Some(Some(since)),
None if audit_available => Some(persisted_since),
None => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -338,6 +377,35 @@ mod tests {
assert_eq!(persisted_since_last_sync(&idx, "missing", now), None);
}
#[test]
fn audit_failure_is_unavailable_and_unknown_cadence_is_excluded() {
let (index, available) =
workspace_audit_state(Err(anyhow::anyhow!("simulated audit I/O failure")));
assert!(index.is_empty());
assert!(!available);
assert_eq!(cadence_from_audit(None, available, None), None);
let known = Duration::from_secs(60);
assert_eq!(
cadence_from_audit(Some(known), available, None),
Some(Some(known))
);
}
#[test]
fn readable_empty_audit_keeps_never_synced_workspace_source_due() {
let (index, available) = workspace_audit_state(Ok(Vec::new()));
assert!(index.is_empty());
assert!(available);
let cadence = cadence_from_audit(None, available, None)
.expect("readable empty audit keeps the source eligible");
assert!(connection_is_due(
DEFAULT_MEMORY_SYNC_INTERVAL_SECS,
cadence
));
}
#[tokio::test]
async fn start_workspace_periodic_sync_is_idempotent() {
start_workspace_periodic_sync();
@@ -55,7 +55,7 @@ use tokio::sync::mpsc;
use crate::openhuman::config::{rpc as config_rpc, Config};
use crate::openhuman::memory::ingest_pipeline::ingest_document_with_scope;
use crate::openhuman::memory_sync::canonicalize::document::DocumentInput;
use tinycortex::memory::ingest::canonicalize::document::DocumentInput;
use crate::openhuman::memory_sync::workspace::watcher::state::WatcherStateStore;
use crate::openhuman::scheduler_gate::gate::current_policy;
use crate::openhuman::scheduler_gate::policy::PauseReason;
@@ -26,8 +26,8 @@ use crate::openhuman::config::Config;
use crate::openhuman::memory::ingest_pipeline::ingest_chat;
use crate::openhuman::memory_queue::testing::drain_until_idle;
use crate::openhuman::memory_store::chunks::types::SourceKind;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory_tree::retrieval::{fetch_leaves, query_source, search_entities};
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
/// Shared test config — disables embedding for deterministic inert behaviour.
fn bench_config() -> (TempDir, Config) {
@@ -16,10 +16,10 @@ use tempfile::TempDir;
use crate::openhuman::config::Config;
use crate::openhuman::memory::ingest_pipeline::ingest_chat;
use crate::openhuman::memory_store::chunks::types::SourceKind;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory_tree::retrieval::{
drill_down, fetch_leaves, query_source, search_entities,
};
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
+3 -3
View File
@@ -18,10 +18,10 @@ use crate::openhuman::memory::ingest_pipeline::{
};
use crate::openhuman::memory_store::chunks::store::{self as chunk_store, ListChunksQuery};
use crate::openhuman::memory_store::chunks::types::{Chunk, SourceKind};
use crate::openhuman::memory_sync::canonicalize::{
use crate::rpc::RpcOutcome;
use tinycortex::memory::ingest::canonicalize::{
chat::ChatBatch, document::DocumentInput, email::EmailThread,
};
use crate::rpc::RpcOutcome;
/// Unified ingest request. The `payload` shape is adapter-specific and is
/// validated inside the dispatch based on `source_kind`.
@@ -855,10 +855,10 @@ mod tests {
use super::*;
use crate::openhuman::memory_queue as jobs;
use crate::openhuman::memory_store::chunks::types::SourceKind;
use crate::openhuman::memory_sync::canonicalize::document::DocumentInput;
use chrono::Utc;
use serde_json::json;
use tempfile::TempDir;
use tinycortex::memory::ingest::canonicalize::document::DocumentInput;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
@@ -745,7 +745,7 @@ fn evict_source_id(session_id: &str, cycle_id: &str) -> String {
/// brain's own compressed summary text (which it just sent us) is stored.
pub async fn execute_evict(effect: &EvictEffect) -> Result<(), String> {
use crate::openhuman::memory::ingest_pipeline::ingest_document_with_scope;
use crate::openhuman::memory_sync::canonicalize::document::DocumentInput;
use tinycortex::memory::ingest::canonicalize::document::DocumentInput;
let config = crate::openhuman::config::Config::load_or_init()
.await
+6 -3
View File
@@ -63,7 +63,10 @@ pub use seal::{
};
pub use summariser::HostSummariser;
pub use sync::{
load_composio_sync_state, run_composio_connection, run_composio_connection_with_budgets,
run_gmail_backfill, run_slack_search_backfill, run_source_pipeline, sync_context,
HostSyncAdapter, SourcePipelineFailure, HOST_SYNC_STATE_NAMESPACE,
append_audit_entry, estimate_cost_usd, load_composio_sync_state, needs_rebuild, raw_coverage,
read_audit_log, rebuild_tree_from_raw, run_composio_connection,
run_composio_connection_with_budgets, run_github_sync, run_gmail_backfill,
run_slack_search_backfill, run_source_pipeline, sync_context, try_read_audit_log,
HostSyncAdapter, RawCoverage, RawFileRef, RealCostAccumulator, RebuildOutcome,
SourcePipelineFailure, SyncAuditEntry, HOST_SYNC_STATE_NAMESPACE,
};
+164 -2
View File
@@ -14,6 +14,9 @@ use crate::openhuman::memory_sources::{MemorySourceEntry, SourceKind};
use crate::openhuman::memory_store::MemoryClientRef;
pub const HOST_SYNC_STATE_NAMESPACE: &str = "composio-sync-state";
pub use tinycortex::memory::sync::{
RawCoverage, RawFileRef, RealCostAccumulator, RebuildOutcome, SyncAuditEntry,
};
pub struct HostSyncAdapter {
memory: MemoryClientRef,
@@ -59,6 +62,146 @@ impl HostSyncAdapter {
}
}
/// Append one host sync audit record, logging failures without exposing source identifiers.
pub fn append_audit_entry(config: &Config, entry: &SyncAuditEntry) {
tracing::debug!(
source_kind = %entry.source_kind,
success = entry.success,
items_fetched = entry.items_fetched,
"[tinycortex:sync] audit append starting"
);
let memory_config = super::memory_config_from(config, config.workspace_dir.clone());
match tinycortex::memory::sync::append_audit_entry(&memory_config, entry) {
Ok(()) => tracing::debug!(
source_kind = %entry.source_kind,
success = entry.success,
"[tinycortex:sync] audit append completed"
),
Err(error) => {
tracing::warn!(%error, source_kind = %entry.source_kind, "[tinycortex:sync] audit append failed");
}
}
}
/// Read persisted sync audit records while preserving storage failures for fail-closed callers.
pub fn try_read_audit_log(config: &Config) -> anyhow::Result<Vec<SyncAuditEntry>> {
tracing::debug!("[tinycortex:sync] audit read starting");
let memory_config = super::memory_config_from(config, config.workspace_dir.clone());
let entries = tinycortex::memory::sync::read_audit_log(&memory_config).map_err(|error| {
tracing::warn!(%error, "[tinycortex:sync] audit read failed");
error
})?;
tracing::debug!(
entries = entries.len(),
"[tinycortex:sync] audit read completed"
);
Ok(entries)
}
/// Read persisted sync audit records for best-effort RPC and reporting surfaces.
pub fn read_audit_log(config: &Config) -> Vec<SyncAuditEntry> {
try_read_audit_log(config).unwrap_or_default()
}
/// Estimate sync inference cost using TinyCortex's canonical pricing model.
pub fn estimate_cost_usd(input_tokens: u64, output_tokens: u64) -> f64 {
tinycortex::memory::sync::estimate_cost_usd(input_tokens, output_tokens)
}
/// Measure coverage of a raw archive by its TinyCortex memory tree.
pub fn raw_coverage(
config: &Config,
tree_scope: &str,
archive_source_id: &str,
) -> anyhow::Result<RawCoverage> {
tracing::debug!("[tinycortex:sync] raw coverage scan starting");
let memory_config = super::memory_config_from(config, config.workspace_dir.clone());
let coverage =
tinycortex::memory::sync::raw_coverage(&memory_config, tree_scope, archive_source_id)
.map_err(|error| {
tracing::warn!(%error, "[tinycortex:sync] raw coverage scan failed");
error
})?;
tracing::debug!(
total = coverage.total,
covered = coverage.covered,
pending = coverage.pending.len(),
"[tinycortex:sync] raw coverage scan completed"
);
Ok(coverage)
}
/// Return whether a raw archive contains records absent from its memory tree.
pub fn needs_rebuild(config: &Config, tree_scope: &str, archive_source_id: &str) -> bool {
let memory_config = super::memory_config_from(config, config.workspace_dir.clone());
let required =
tinycortex::memory::sync::needs_rebuild(&memory_config, tree_scope, archive_source_id);
tracing::debug!(
required,
"[tinycortex:sync] raw rebuild requirement evaluated"
);
required
}
/// Rebuild a memory tree from its raw archive through the host summarizer.
pub async fn rebuild_tree_from_raw(
config: &Config,
tree_scope: &str,
archive_source_id: &str,
) -> anyhow::Result<RebuildOutcome> {
tracing::info!("[tinycortex:sync] raw rebuild starting");
let memory_config = super::memory_config_from(config, config.workspace_dir.clone());
let summariser = super::HostSummariser::new(config.clone());
let outcome = tinycortex::memory::sync::rebuild_tree_from_raw(
&memory_config,
tree_scope,
archive_source_id,
&summariser,
)
.await
.map_err(|error| {
tracing::warn!(%error, "[tinycortex:sync] raw rebuild failed");
error
})?;
tracing::info!(
files_read = outcome.files_read,
batches = outcome.batches,
"[tinycortex:sync] raw rebuild completed"
);
Ok(outcome)
}
/// Run a registered GitHub repository source through TinyCortex synchronization.
pub async fn run_github_sync(
source: &MemorySourceEntry,
config: &Config,
) -> anyhow::Result<SyncOutcome> {
tracing::info!("[tinycortex:sync] GitHub repository sync starting");
if crate::openhuman::memory::global::client_if_ready().is_none() {
tracing::debug!("[tinycortex:sync] GitHub sync initializing memory client");
crate::openhuman::memory::global::init(config.workspace_dir.clone())
.map_err(anyhow::Error::msg)
.map_err(|error| {
tracing::warn!(%error, "[tinycortex:sync] GitHub sync memory initialization failed");
error
})?;
}
let outcome = run_source_pipeline(source, config)
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))
.map_err(|error| {
tracing::warn!(%error, "[tinycortex:sync] GitHub repository sync failed");
error
})?;
tracing::info!(
records_ingested = outcome.records_ingested,
more_pending = outcome.more_pending,
actions_called = outcome.actions_called,
"[tinycortex:sync] GitHub repository sync completed"
);
Ok(outcome)
}
#[async_trait]
impl ExternalSourceReader for HostSyncAdapter {
async fn list_items(
@@ -467,7 +610,7 @@ impl LocalDocumentSink for HostSyncAdapter {
.config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("local document sink missing host config"))?;
let input = crate::openhuman::memory_sync::canonicalize::document::DocumentInput {
let input = tinycortex::memory::ingest::canonicalize::document::DocumentInput {
provider: "memory_sources:local".into(),
title: document.title,
body: document.body,
@@ -558,7 +701,10 @@ fn stage_name(stage: SyncStage) -> &'static str {
#[cfg(test)]
mod tests {
use super::{build_pipeline, is_composio_toolkit_syncable, syncable_composio_toolkits};
use super::{
build_pipeline, is_composio_toolkit_syncable, syncable_composio_toolkits,
try_read_audit_log,
};
use crate::openhuman::config::Config;
use crate::openhuman::memory_sources::MemorySourceEntry;
use crate::openhuman::memory_sync::composio::{
@@ -676,4 +822,20 @@ mod tests {
assert!(is_composio_toolkit_syncable("Gmail"));
assert!(is_composio_toolkit_syncable(" slack "));
}
#[test]
fn fallible_audit_read_distinguishes_io_failure_from_empty_log() {
let workspace = tempfile::tempdir().expect("workspace");
let audit_path = workspace.path().join("memory_tree/sync_audit.jsonl");
std::fs::create_dir_all(&audit_path).expect("create directory at audit file path");
let mut config = Config::default();
config.workspace_dir = workspace.path().to_path_buf();
let error = try_read_audit_log(&config).expect_err("directory read must fail");
assert!(
error.downcast_ref::<std::io::Error>().is_some(),
"expected the audit I/O error to remain distinguishable: {error:#}"
);
}
}
+2 -2
View File
@@ -23,13 +23,13 @@ use chrono::{TimeZone, Utc};
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::memory::ingest_pipeline::{ingest_chat, ingest_email};
use openhuman_core::openhuman::memory_queue::drain_until_idle;
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use openhuman_core::openhuman::memory_sync::canonicalize::email::{EmailMessage, EmailThread};
use openhuman_core::openhuman::tools::{
MemoryTreeFetchLeavesTool, MemoryTreeSearchEntitiesTool, Tool,
};
use serde_json::{json, Value};
use tempfile::TempDir;
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
use tinycortex::memory::ingest::canonicalize::email::{EmailMessage, EmailThread};
/// Build a Config rooted at `tmp/workspace`. The nested `workspace` dir
/// matches what `resolve_config_dir_for_workspace` would derive when
+1 -1
View File
@@ -7722,7 +7722,7 @@ async fn json_rpc_inference_prompt_requires_external_ollama_runtime_when_unreach
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
prompt_err_message.contains("routes inference through an external Ollama endpoint"),
prompt_err_message.contains("external Ollama endpoint is unavailable"),
"unexpected error: {prompt_err}"
);
+1 -1
View File
@@ -19,8 +19,8 @@ use openhuman_core::openhuman::memory_store::content::wiki_git::{
get_read_pointer_tag, set_read_pointer_tag,
};
use openhuman_core::openhuman::memory_store::content::{SummaryComposeInput, SummaryTreeKind};
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use openhuman_core::openhuman::memory_tree::ingest::{ingest_summary, SummaryIngestInput};
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
fn make_config(workspace_dir: &std::path::Path) -> Config {
let mut config = Config::default();
+1 -1
View File
@@ -23,8 +23,8 @@ use tempfile::TempDir;
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::memory::ingest_pipeline::ingest_chat;
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use openhuman_core::openhuman::memory_tree::retrieval::{fast_retrieve, FastRetrieveOptions};
use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage};
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
+152 -6
View File
@@ -24,11 +24,30 @@
//! - The crate chunk-DB init is additionally forced via
//! `tinycortex::memory::chunks::with_connection` so the substrate schema is
//! deterministic regardless of which subsystems the op flow happened to touch.
//! - `vectors` / `store_meta` / `kv_*` are created by other crate subsystems on
//! their own first touch (the chunk/embed pipeline) rather than by the minimal
//! doc-put + recall flow; they are reported in the run's schema dump and left
//! to a follow-up that widens the flow, so this harness stays green and useful
//! today without a fragile dependence on ingest internals.
//! - The crate KV tier (`kv_global` / `kv_namespace`, crate `store/kv.rs`)
//! attaches to the host `UnifiedMemory` connection via
//! `KvStore::from_shared_connection`. These two table *names* are pinned as
//! [`CRATE_KV_TABLES`], but a name-presence check alone is **not** a cutover
//! guard: the host `UnifiedMemory::new` (`namespace_store/init.rs`) also
//! `CREATE TABLE IF NOT EXISTS`es both names unconditionally on every workspace
//! open, so the names would survive even if the crate KV store cut over to
//! differently-named tables and stranded a user's persisted preferences. The
//! real guard is therefore **functional**: [`assert_crate_kv_interop`] drives
//! the production KV surface (`memory::ops::kv_{set,get}`, crate-backed via
//! `KvStore::from_shared_connection`) for the global and namespace scopes,
//! asserts the value round-trips, and asserts via read-only SQL that the write
//! physically landed in the pinned `kv_global` / `kv_namespace` tables. A
//! cutover that renamed or dropped the crate KV tables strands the write
//! outside the pinned names and fails here.
//! - The standalone `VectorStore` tables (`vectors` / `store_meta`, crate
//! `store/vectors/store.rs`) are deliberately **not** pinned: nothing on the
//! host's live path opens that store — `VectorStore::open` has no non-test
//! caller in either the crate or the host, and the live embedding path instead
//! uses `mem_tree_chunk_embeddings` (crate substrate) plus the host
//! `vector_chunks` tier — so those tables never appear in a real workspace.
//! Pinning them would assert schema the shipped product never creates. Add
//! them here only if the host ever wires the standalone vector store onto a
//! workspace.
//!
//! Run with: `cargo test --test memory_golden_parity_e2e`
@@ -40,7 +59,8 @@ use tempfile::tempdir;
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::memory::ops::{
doc_put, memory_recall_context, memory_recall_memories, PutDocParams,
doc_put, kv_get, kv_set, memory_recall_context, memory_recall_memories, KvGetDeleteParams,
KvSetParams, PutDocParams,
};
use openhuman_core::openhuman::memory::rpc_models::{RecallContextRequest, RecallMemoriesRequest};
use openhuman_core::openhuman::tinycortex::memory_config_from;
@@ -119,6 +139,15 @@ const HOST_UNIFIED_TABLES: &[&str] = &[
"user_profile",
];
/// The crate KV tier (`kv_global` + `kv_namespace`, crate `store/kv.rs`) that
/// rides the host `UnifiedMemory` connection via `KvStore::from_shared_connection`.
/// These names are the *targets* the production KV write path must land in;
/// [`assert_crate_kv_interop`] is what proves it does. The harness guards against
/// these tables being **renamed or dropped** by the crate KV store — not against
/// arbitrary in-place schema reshaping (a column/index change that preserves the
/// names and the `key` / `value_json` columns the API reads would still pass).
const CRATE_KV_TABLES: &[&str] = &["kv_global", "kv_namespace"];
// ── Schema scan helpers (path-agnostic, read-only) ───────────────────────────
fn collect_db_files(dir: &Path, out: &mut Vec<PathBuf>) {
@@ -163,6 +192,118 @@ fn tables_in_workspace(ws: &Path) -> BTreeSet<String> {
tables
}
/// Read-only: does any `*.db` under `ws` hold a row keyed `key` in `table`?
///
/// Used to prove that a production KV write physically landed in a *pinned* table
/// name rather than one the crate KV store may have cut over to. `table` and
/// `key` are harness constants (never external input), so the interpolated
/// `table` name carries no injection surface. A missing table or unreadable DB
/// counts as "not present" and the scan moves on.
fn kv_row_present(ws: &Path, table: &str, key: &str) -> bool {
let mut dbs = Vec::new();
collect_db_files(ws, &mut dbs);
for db in dbs {
let Ok(conn) =
rusqlite::Connection::open_with_flags(&db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
else {
continue;
};
let sql = format!("SELECT COUNT(*) FROM \"{table}\" WHERE key = ?1");
if let Ok(count) = conn.query_row(&sql, [key], |row| row.get::<_, i64>(0)) {
if count > 0 {
return true;
}
}
}
false
}
/// The crate KV tier's real cutover guard (see the module-level design note and
/// [`CRATE_KV_TABLES`]). A name-presence check alone cannot detect a crate KV
/// cutover because the host `UnifiedMemory::new` recreates `kv_global` /
/// `kv_namespace` unconditionally; this instead drives the production KV surface
/// (crate-backed via `KvStore::from_shared_connection`) for the global and
/// namespace scopes, asserts the value round-trips, and asserts via read-only SQL
/// that each write physically landed in the pinned tables. A cutover that renamed
/// or dropped the crate KV tables strands the write outside the pinned names and
/// trips one of these asserts.
async fn assert_crate_kv_interop(workspace: &Path, namespace: &str, tables: &BTreeSet<String>) {
eprintln!(
"[golden-parity][kv] validating crate KV interop over pinned tables {:?}",
CRATE_KV_TABLES
);
// Cheap coexistence pre-check: the pinned tables materialised at all.
let missing_kv: Vec<&str> = CRATE_KV_TABLES
.iter()
.copied()
.filter(|t| !tables.contains(*t))
.collect();
assert!(
missing_kv.is_empty(),
"crate KV-tier tables missing from the workspace: {missing_kv:?}; found: {tables:?}"
);
let key = "golden-parity-kv-canary";
let value = serde_json::json!({ "pref": "golden-parity", "v": 1 });
// ── Global scope: write via production kv_set, read back, prove it landed
// in the pinned `kv_global` table. ──
eprintln!("[golden-parity][kv] global-scope write via production kv_set");
kv_set(KvSetParams {
namespace: None,
key: key.to_string(),
value: value.clone(),
})
.await
.expect("crate KV global set");
let got_global = kv_get(KvGetDeleteParams {
namespace: None,
key: key.to_string(),
})
.await
.expect("crate KV global get");
assert_eq!(
got_global.value,
Some(value.clone()),
"crate KV global round-trip lost the value (adapter cannot read back its own write)"
);
assert!(
kv_row_present(workspace, "kv_global", key),
"crate KV global write did not land in the pinned `kv_global` table — the KV store cut over to a differently-named table and would strand existing preferences"
);
// ── Namespace scope: same, against the pinned `kv_namespace` table. ──
eprintln!("[golden-parity][kv] namespace-scope write via production kv_set");
kv_set(KvSetParams {
namespace: Some(namespace.to_string()),
key: key.to_string(),
value: value.clone(),
})
.await
.expect("crate KV namespace set");
let got_ns = kv_get(KvGetDeleteParams {
namespace: Some(namespace.to_string()),
key: key.to_string(),
})
.await
.expect("crate KV namespace get");
assert_eq!(
got_ns.value,
Some(value.clone()),
"crate KV namespace round-trip lost the value (adapter cannot read back its own write)"
);
assert!(
kv_row_present(workspace, "kv_namespace", key),
"crate KV namespace write did not land in the pinned `kv_namespace` table — the KV store cut over to a differently-named table and would strand existing preferences"
);
eprintln!(
"[golden-parity][kv] crate KV interop verified — round-trip ok and writes landed in {:?}",
CRATE_KV_TABLES
);
}
fn put_params(ns: &str) -> PutDocParams {
PutDocParams {
namespace: ns.to_string(),
@@ -256,6 +397,11 @@ async fn golden_workspace_composes_substrate_and_unified_tiers() {
"host UnifiedMemory tables missing from the workspace: {missing_unified:?}; found: {tables:?}"
);
// Crate KV tier: a functional interop guard, not a name-presence check.
// (Name presence alone is satisfied by the host `UnifiedMemory::new` init and
// cannot detect a crate KV cutover — see [`assert_crate_kv_interop`].)
assert_crate_kv_interop(&workspace, "golden-parity-e2e", &tables).await;
// Coexistence: both tiers are present in the same workspace (P12).
assert!(
tables.contains("mem_tree_chunks") && tables.contains("memory_docs"),
+3 -5
View File
@@ -51,12 +51,10 @@ use openhuman_core::openhuman::memory_store::content::raw::{
};
use openhuman_core::openhuman::memory_store::trees::store as tree_store;
use openhuman_core::openhuman::memory_store::trees::types::SUMMARY_FANOUT;
use openhuman_core::openhuman::memory_sync::sources::audit::read_audit_log;
use openhuman_core::openhuman::memory_sync::sources::github::run_github_sync;
use openhuman_core::openhuman::memory_sync::sources::rebuild::{
needs_rebuild, rebuild_tree_from_raw,
};
use openhuman_core::openhuman::memory_tree::ingest::{ingest_summary, SummaryIngestInput};
use openhuman_core::openhuman::tinycortex::read_audit_log;
use openhuman_core::openhuman::tinycortex::run_github_sync;
use openhuman_core::openhuman::tinycortex::{needs_rebuild, rebuild_tree_from_raw};
// ── Shared harness ────────────────────────────────────────────────────────
@@ -332,8 +332,9 @@ async fn local_services_cover_mocked_inference_assets_speech_and_whisper_install
.contains("downloaded payload too small"));
let seen = state.requests.lock().expect("requests").clone();
assert!(seen.iter().any(|(path, _)| path == "/api/generate"));
assert!(seen.iter().any(|(path, _)| path == "/api/chat"));
assert!(seen
.iter()
.any(|(path, _)| path == "/v1/chat/completions"));
assert!(seen.iter().any(|(path, _)| path.ends_with("ggml-tiny.bin")));
assert!(seen
.iter()
@@ -351,8 +352,7 @@ async fn serve_mock() -> (String, MockState) {
.route("/api/tags", get(ollama_tags))
.route("/api/show", post(ollama_show))
.route("/api/pull", post(ollama_pull))
.route("/api/generate", post(ollama_generate))
.route("/api/chat", post(ollama_chat))
.route("/v1/chat/completions", post(ollama_chat_completions))
.route("/asset/stt", get(asset_stt))
.route("/asset/tts", get(asset_tts))
.route("/asset/tts-json", get(asset_tts_json))
@@ -415,24 +415,40 @@ async fn ollama_pull(State(state): State<MockState>, Json(body): Json<Value>) ->
.expect("pull response")
}
async fn ollama_generate(
async fn ollama_chat_completions(
State(state): State<MockState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> impl IntoResponse {
remember(&state, "/api/generate", &headers, body.clone());
let prompt = body["prompt"].as_str().unwrap_or_default();
let system = body["system"].as_str().unwrap_or_default();
remember(&state, "/v1/chat/completions", &headers, body.clone());
let messages = body["messages"].as_array().cloned().unwrap_or_default();
let system = messages
.iter()
.find(|message| message["role"] == "system")
.and_then(|message| message["content"].as_str())
.unwrap_or_default();
let prompt = messages
.iter()
.rev()
.find(|message| message["role"] == "user")
.and_then(|message| message["content"].as_str())
.unwrap_or_default();
let response = if prompt.contains("single emoji character") {
"".to_string()
} else if system.contains("inline text completion") {
"adds tests".to_string()
} else if prompt == "chat please" {
"chat generated".to_string()
} else {
format!("generated: {}", prompt.trim())
};
Json(json!({
"response": response,
"done": true,
"id": "chatcmpl-round21",
"choices": [{
"message": { "role": "assistant", "content": response },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 2, "completion_tokens": 4, "total_tokens": 6 },
"prompt_eval_count": 2,
"prompt_eval_duration": 1_000_000,
"eval_count": 4,
@@ -440,22 +456,6 @@ async fn ollama_generate(
}))
}
async fn ollama_chat(
State(state): State<MockState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> impl IntoResponse {
remember(&state, "/api/chat", &headers, body);
Json(json!({
"message": { "role": "assistant", "content": "chat generated" },
"done": true,
"prompt_eval_count": 1,
"prompt_eval_duration": 1_000_000,
"eval_count": 1,
"eval_duration": 1_000_000
}))
}
async fn asset_stt() -> impl IntoResponse {
bytes_response(vec![b's'; 4096])
}
@@ -259,13 +259,13 @@ async fn local_service_public_inference_assets_and_shutdown_use_loopback_ollama(
.prompt(&config, "Say hi", Some(8), true)
.await
.expect("prompt");
assert_eq!(prompt, "generated final");
assert_eq!(prompt, "chat:gemma3:1b-it-qat");
let summarized = service
.summarize(&config, "one two three", Some(16))
.await
.expect("summarize");
assert_eq!(summarized, "generated final");
assert_eq!(summarized, "chat:gemma3:1b-it-qat");
let completion = service
.inline_complete_interactive(
@@ -278,7 +278,7 @@ async fn local_service_public_inference_assets_and_shutdown_use_loopback_ollama(
)
.await
.expect("inline");
assert_eq!(completion, "generated final");
assert_eq!(completion, "chat:gemma3:1b-it-qat");
let assets = service.assets_status(&config).await.expect("assets");
assert!(assets.ollama_available);
@@ -170,7 +170,7 @@ async fn local_service_covers_mocked_bootstrap_assets_diagnostics_and_embed() {
.await
.expect("mocked embed");
assert_eq!(embedded.model_id, "bge-m3");
assert_eq!(embedded.dimensions, 3);
assert_eq!(embedded.dimensions, 1024);
assert_eq!(embedded.vectors.len(), 2);
let seen = state.requests.lock().expect("requests");
@@ -330,7 +330,7 @@ async fn ollama_embed(
remember(&state, "/api/embed", &headers, body);
Json(json!({
"model": "bge-m3",
"embeddings": [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
"embeddings": [vec![0.1; 1024], vec![0.2; 1024]]
}))
}
@@ -19,19 +19,19 @@ use openhuman_core::openhuman::memory_store::chunks::store::upsert_chunks;
use openhuman_core::openhuman::memory_store::chunks::types::{
approx_token_count, chunk_id, Chunk, Metadata, SourceKind as ChunkSourceKind, SourceRef,
};
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{
use tinycortex::memory::ingest::canonicalize::chat::{
canonicalise as canonicalise_chat, ChatBatch, ChatMessage,
};
use openhuman_core::openhuman::memory_sync::canonicalize::document::{
use tinycortex::memory::ingest::canonicalize::document::{
canonicalise as canonicalise_document, DocumentInput,
};
use openhuman_core::openhuman::memory_sync::canonicalize::email::{
use tinycortex::memory::ingest::canonicalize::email::{
canonicalise as canonicalise_email, EmailMessage, EmailThread,
};
use openhuman_core::openhuman::memory_sync::composio::providers::{
classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope,
};
use openhuman_core::openhuman::memory_sync::{SyncOutcome, SyncPipelineKind};
use tinycortex::memory::sync::{SyncOutcome, SyncPipelineKind};
use openhuman_core::openhuman::memory_tree::summarise::{
fallback_summary, SummaryContext, SummaryInput,
};
@@ -395,6 +395,7 @@ fn memory_sources_validation_and_sync_classification_edges() {
records_ingested: 3,
more_pending: true,
note: Some("paged".into()),
..SyncOutcome::default()
};
let encoded = serde_json::to_value(&outcome).expect("sync outcome json");
assert_eq!(encoded["records_ingested"], 3);
@@ -79,16 +79,16 @@ use openhuman_core::openhuman::memory_store::trees::types::{
use openhuman_core::openhuman::memory_store::{
MemoryClient, NamespaceDocumentInput, UnifiedMemory,
};
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{
use tinycortex::memory::ingest::canonicalize::chat::{
canonicalise as canonicalise_chat, ChatBatch, ChatMessage,
};
use openhuman_core::openhuman::memory_sync::canonicalize::document::{
use tinycortex::memory::ingest::canonicalize::document::{
canonicalise as canonicalise_document, DocumentInput,
};
use openhuman_core::openhuman::memory_sync::canonicalize::email::{
use tinycortex::memory::ingest::canonicalize::email::{
canonicalise as canonicalise_email, EmailMessage, EmailThread,
};
use openhuman_core::openhuman::memory_sync::canonicalize::email_clean;
use tinycortex::memory::ingest::canonicalize::email_clean;
use openhuman_core::openhuman::memory_sync::composio;
use openhuman_core::openhuman::memory_sync::composio::providers::profile::{
canonicalize, delete_connected_identity_facets, is_self_identity, is_self_identity_any_toolkit,
@@ -117,9 +117,7 @@ use openhuman_core::openhuman::memory_sync::composio::providers::{
use openhuman_core::openhuman::memory_sync::sync_status::{
rpc as memory_sync_status_rpc, schemas as memory_sync_status_schemas,
};
use openhuman_core::openhuman::memory_sync::traits::{
SyncOutcome as PipelineSyncOutcome, SyncPipeline, SyncPipelineKind,
};
use tinycortex::memory::sync::{SyncOutcome as PipelineSyncOutcome, SyncPipelineKind};
use openhuman_core::openhuman::memory_tools::tools::{MemoryToolsListTool, MemoryToolsPutTool};
use openhuman_core::openhuman::memory_tools::{
render_tool_memory_rules, tool_memory_namespace, tool_memory_store, ToolMemoryPriority,
@@ -2172,43 +2170,12 @@ async fn memory_preferences_remember_redaction_and_pipeline_traits_cover_public_
"localhost:11434"
);
#[derive(Default)]
struct RawPipeline;
#[async_trait::async_trait]
impl SyncPipeline for RawPipeline {
fn id(&self) -> &str {
"workspace:raw-coverage"
}
fn kind(&self) -> SyncPipelineKind {
SyncPipelineKind::Workspace
}
async fn init(&self, _config: &Config) -> anyhow::Result<()> {
Ok(())
}
async fn tick(&self, _config: &Config) -> anyhow::Result<PipelineSyncOutcome> {
Ok(PipelineSyncOutcome {
records_ingested: 2,
more_pending: false,
note: Some("covered".into()),
})
}
}
let pipeline = RawPipeline;
assert_eq!(pipeline.id(), "workspace:raw-coverage");
assert_eq!(pipeline.kind().as_str(), "workspace");
pipeline
.init(&config_in(&tmp))
.await
.expect("pipeline init");
let outcome = pipeline
.tick(&config_in(&tmp))
.await
.expect("pipeline tick");
let outcome = PipelineSyncOutcome {
records_ingested: 2,
more_pending: false,
note: Some("covered".into()),
..PipelineSyncOutcome::default()
};
assert_eq!(outcome.records_ingested, 2);
assert_eq!(serde_json::to_value(outcome).unwrap()["note"], "covered");
assert_eq!(PipelineSyncOutcome::default().records_ingested, 0);

Some files were not shown because too many files have changed in this diff Show More