mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
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:
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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+1
-50
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([]));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Executable
+60
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>(¶ms, "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");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(¤t));
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
parts.push(capitalise(¤t));
|
||||
}
|
||||
|
||||
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::*;
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user