Compare commits

..

88 Commits

Author SHA1 Message Date
Slavi Pantaleev
ead70d71ff Prepare 1.21.1 release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:07:16 +03:00
Slavi Pantaleev
49bb52a091 Update anthropic-rs fork to drop vulnerable rustls-webpki 0.101
The anthropic git dependency now uses reqwest 0.12 / rustls 0.23, pulling
rustls-webpki 0.103.13 instead of the 0.101.7 that was dragged in via the
old reqwest 0.11. This clears three RUSTSEC/Dependabot advisories:

- GHSA-82j2-j2ch-gfr8 (high): DoS via panic on malformed CRL BIT STRING
- GHSA-xgp8-3hg3-c2mh (low): name constraints accepted for wildcard names
- GHSA-965h-392x-2mh5 (low): name constraints for URI names incorrectly accepted

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:06:27 +03:00
Slavi Pantaleev
1fc2f0f65a Prepare 1.21.0 release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:44:25 +03:00
Slavi Pantaleev
f7cb38b620 Fix clippy warnings in tests
- Avoid unwrap_or() on a statically-Some value by using the raw token count
- Use an array literal instead of vec! for the non-allocated test cases

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:41:00 +03:00
Slavi Pantaleev
04e7667d11 Default to gpt-image-2 for OpenAI image generation
Make gpt-image-2 the default image-generation model and add it to the
recognized model-id mapping, updating the sample provider configs to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:39:45 +03:00
Slavi Pantaleev
78cbfda481 Update Rust crate async-openai to 0.41.0
Adapt to upstream API changes:
- Handle the new ImageModel::GptImage2 variant in image-model match arms
- ImageSize dropped Copy (gained an Other(String) variant), so clone it

Supersedes #170.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:37:46 +03:00
renovate[bot]
36fd6a4dda Update Rust crate quick_cache to v0.6.23 2026-06-05 09:33:16 +03:00
renovate[bot]
1308196419 Update docker.io/ollama/ollama Docker tag to v0.30.5 2026-06-05 09:33:07 +03:00
renovate[bot]
b3546ebaf1 Update ghcr.io/element-hq/synapse Docker tag to v1.154.0 2026-06-04 18:50:24 +03:00
renovate[bot]
2499e6baca Update Rust crate chrono to v0.4.45 2026-06-04 18:50:01 +03:00
renovate[bot]
871b9d2f3b Update dependency prek to v0.4.4 2026-06-04 14:48:21 +03:00
renovate[bot]
4a36cf9446 Update docker.io/ollama/ollama Docker tag to v0.30.4 2026-06-04 07:13:27 +03:00
renovate[bot]
a2180452c9 Update docker.io/ollama/ollama Docker tag to v0.30.2 2026-06-03 07:39:57 +03:00
Slavi Pantaleev
0cb0fc18ce Prepare 1.20.0 release
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:05:29 +03:00
renovate[bot]
78bddac716 Update Rust crate tiktoken-rs to 0.12.* (#162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 23:03:20 +03:00
Slavi Pantaleev
f7209221f6 Update matrix-sdk to v0.18.0 (and mxlink to v1.15.0)
matrix-sdk 0.18.0 introduces no breaking changes that affect baibot, but
it does require a matching mxlink release: mxlink 1.14.0 pins matrix-sdk
0.17.0, so without bumping mxlink the two matrix-sdk versions conflict.
mxlink 1.15.0 (released alongside this) tracks matrix-sdk 0.18.0, so we
bump the floor to >=1.15.0.

Verified locally: cargo build and the full test suite pass.

Supersedes Renovate PR #161.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:47:13 +03:00
renovate[bot]
5f2054e07f Update Rust crate async-openai to v0.40.3 2026-06-02 12:06:50 +03:00
renovate[bot]
8301c5a29f Update docker.io/ollama/ollama Docker tag to v0.30.0 2026-06-02 12:06:30 +03:00
Slavi Pantaleev
b325b6b1a5 Upgrade Rust (1.95.0 -> 1.96.0)
Bumps the base image in Dockerfile/Dockerfile.ci (via Renovate #158) and
keeps rust-toolchain.toml in sync, since rustup honors the toolchain file
inside the container build and would otherwise keep using 1.95.0.

Verified locally under 1.96.0: cargo check, clippy -D warnings, fmt check,
and cargo test --all-features all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:02:05 +03:00
renovate[bot]
0600721663 Update ghcr.io/element-hq/element-web Docker tag to v1.12.20 2026-05-27 20:50:26 +03:00
renovate[bot]
dc17cf7e96 Update ghcr.io/element-hq/element-web Docker tag to v1.12.19 2026-05-27 13:50:35 +03:00
Slavi Pantaleev
d6a8f6ba0b Prepare 1.19.3 release
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:09:11 +03:00
renovate[bot]
c4c3d71195 Update dependency prek to v0.4.3 2026-05-27 09:56:15 +03:00
renovate[bot]
84ae29d034 Update dependency prek to v0.4.2 2026-05-26 15:14:23 +03:00
renovate[bot]
447f43df7b Update Rust crate async-openai to v0.40.2 2026-05-22 23:11:28 +03:00
renovate[bot]
dabac790ad Update Rust crate async-openai to v0.40.1 2026-05-22 10:19:16 +03:00
renovate[bot]
fa01f012a1 Update Rust crate serde_json to v1.0.150 2026-05-22 10:19:05 +03:00
Slavi Pantaleev
20cb33bc66 Prepare 1.19.2 release
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:23:20 +03:00
Slavi Pantaleev
2791bdb08b Merge pull request #150 from etkecc/chore/update-dependencies
Update dependencies
2026-05-21 09:39:11 +03:00
Slavi Pantaleev
5dd505202a Update dependencies
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:33:28 +03:00
Slavi Pantaleev
d61078002c Merge pull request #149 from etkecc/renovate/async-openai-0.x
Update Rust crate async-openai to 0.40.0
2026-05-21 09:26:46 +03:00
renovate[bot]
cf5b346558 Update Rust crate async-openai to 0.40.0 2026-05-21 05:55:20 +00:00
renovate[bot]
ebbb6658e1 Update dependency prek to v0.4.1 2026-05-20 09:13:57 +03:00
renovate[bot]
369dc0c1ba Update ghcr.io/element-hq/synapse Docker tag to v1.153.0 2026-05-19 21:46:36 +03:00
renovate[bot]
3185f44a93 Update Rust crate quick_cache to v0.6.22 2026-05-18 07:32:20 +03:00
renovate[bot]
aa8ccde0ed Update docker.io/postgres Docker tag to v18.4 2026-05-15 12:48:23 +03:00
renovate[bot]
d5df0d7416 Update docker.io/ollama/ollama Docker tag to v0.24.0 2026-05-15 12:48:07 +03:00
renovate[bot]
140ca9ed68 Update dependency prek to v0.4.0 2026-05-14 16:40:26 +03:00
renovate[bot]
89d77d52b6 Update Rust crate async-openai to v0.38.2 2026-05-14 07:32:06 +03:00
renovate[bot]
5092700275 Update docker.io/ollama/ollama Docker tag to v0.23.4 2026-05-14 07:31:22 +03:00
renovate[bot]
10c365124e Update docker.io/ollama/ollama Docker tag to v0.23.3 2026-05-13 07:28:16 +03:00
renovate[bot]
08bdf4f7a2 Update ghcr.io/element-hq/element-web Docker tag to v1.12.18 2026-05-12 20:38:24 +03:00
Slavi Pantaleev
af557a7e45 Fix multi-arch manifest publish: use buildx imagetools
docker/build-push-action now wraps single-platform images in an OCI
image index (to carry provenance attestations), so the per-arch
`*-amd64`/`*-arm64` tags are manifest lists. `docker manifest create`
refuses manifest-list sources ("X is a manifest list"). Switch to
`docker buildx imagetools create`, which flattens index sources
correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:22:50 +03:00
renovate[bot]
fa7eb11b1d Update Rust crate async-openai to v0.38.1 2026-05-12 02:39:22 +03:00
Slavi Pantaleev
ff1e128f0e Fix versioned image publish under workflow_run trigger
e978d3c switched the publish trigger from `push` to `workflow_run` and
correctly migrated the `type=raw,value=latest` rule to read the upstream
head_branch from `github.event.workflow_run.*` — but left the
`type=semver,pattern={{raw}}` rule unchanged. That rule still reads
`github.ref`, which under workflow_run dispatch is always
`refs/heads/main` (the default branch where the workflow file lives),
not the triggering tag ref. As a result, no semver tag was extracted,
metadata-action produced no tags, and `buildx` failed with
"tag is needed when pushing to registry". The `latest` tag kept
publishing because its rule was migrated; versioned tags (v1.19.0,
v1.19.1) silently stopped publishing.

Pass the upstream head_branch to the semver rule explicitly via `value`,
gated by `enable` so it only fires for v* tags. Mirrors the migration
the raw rule already received.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:28:54 +03:00
Slavi Pantaleev
c9da927c66 Prepare 1.19.1 release
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:40:24 +03:00
Slavi Pantaleev
7b16c5c1c3 Update async-openai to v0.38.0
Closes #137. The 0.36 -> 0.38 bump (skipping 0.37) introduces Tower-based
middleware support and a fix to the ReasoningItem `type` field, but
neither affects baibot's call sites — no code adaptation was needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:40:10 +03:00
Slavi Pantaleev
f809bf8d7c Prepare 1.19.0 release
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:34:49 +03:00
Slavi Pantaleev
44aea427fc Update matrix-sdk to v0.17.0 and Rust toolchain to 1.95.0
matrix-sdk 0.17.0 brings a few breaking API changes that we adapt to:

- Drop the `native-tls` feature on matrix-sdk; it was removed upstream
  in 2026-04 (matrix-sdk now hardwires rustls). The previous workaround
  comment referencing rust-mxlink issue #1 is no longer relevant.
- `Relation::Reply` is now a tuple variant wrapping a `Reply` struct;
  pattern matches updated to bind through `reply.in_reply_to.event_id`.

Also bumps the Rust toolchain from 1.93.0 to 1.95.0 (closes #85, which
proposed the Dockerfile bump in isolation). rustc 1.94+ trips a
query-depth overflow when computing async layouts in the matrix-sdk
timeline future graph; matrix-rust-sdk PR #6489 raises the limit, but
\`recursion_limit\` is per-crate, so we repeat \`#![recursion_limit = "256"]\`
on this crate root.

Bumps the mxlink floor to 1.14.0, which carries the matching adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:29:41 +03:00
renovate[bot]
8f45a57ced Update docker.io/ollama/ollama Docker tag to v0.23.2 2026-05-09 16:06:25 +03:00
renovate[bot]
6a9100309c Update ghcr.io/element-hq/synapse Docker tag to v1.152.1 2026-05-09 16:06:17 +03:00
renovate[bot]
1953878e6b Update Rust crate tokio to v1.52.3 2026-05-09 08:12:03 +03:00
renovate[bot]
35761a5bf7 Update ghcr.io/element-hq/element-web Docker tag to v1.12.17 2026-05-09 08:11:38 +03:00
renovate[bot]
c7fa0cc0ee Update forgejo.ellis.link/continuwuation/continuwuity Docker tag to v0.5.9 2026-05-09 08:11:25 +03:00
renovate[bot]
87fcf9d019 Update dependency prek to v0.3.13 2026-05-09 08:11:04 +03:00
renovate[bot]
4b52bc906c Update forgejo.ellis.link/continuwuation/continuwuity Docker tag to v0.5.8 2026-04-25 22:16:54 +03:00
renovate[bot]
517a6c5e33 Update docker.io/ollama/ollama Docker tag to v0.21.2 2026-04-24 08:17:02 +03:00
renovate[bot]
636c8a35eb Update Rust crate async-openai to 0.36.0 2026-04-24 06:54:26 +03:00
renovate[bot]
a50de600da Update docker.io/ollama/ollama Docker tag to v0.21.1 2026-04-22 07:15:30 +03:00
Slavi Pantaleev
e978d3cb2f Publish only after successful CI
Run the publish workflow from the workflow_run event so Docker publishing happens only after the CI workflow completes successfully for push events on main or v* tags.

Check out the exact SHA validated by CI and derive Docker metadata from the upstream CI ref, so publishing follows the tested revision instead of the default branch tip.
2026-04-20 22:09:37 +03:00
Slavi Pantaleev
2b1bdbd3d2 Split CI and publish workflows
This supersedes 7d183b9 ("Run CI for pull requests"), which mixed validation and publishing in one workflow and regressed docker-manifest by dropping the package-write permission it needs to publish the manifest.

Split the workflows so CI handles pull requests, branch pushes, tags, and manual runs, while publishing stays focused on Docker delivery with the manifest permission fixed explicitly at the job level.
2026-04-20 22:08:07 +03:00
Slavi Pantaleev
7d183b91d1 Run CI for pull requests 2026-04-20 21:40:03 +03:00
renovate[bot]
420f380417 Update Rust crate async-openai to 0.35.0 (#125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 21:39:09 +03:00
renovate[bot]
6f541e2361 Update forgejo.ellis.link/continuwuation/continuwuity Docker tag to v0.5.7 2026-04-17 21:52:46 +03:00
renovate[bot]
0737c2761e Update docker.io/ollama/ollama Docker tag to v0.21.0 2026-04-17 10:41:04 +03:00
renovate[bot]
45852a0d53 Update Rust crate tokio to v1.52.1 2026-04-17 10:38:22 +03:00
renovate[bot]
0fcf8d8703 Update Rust crate tokio to 1.52.* 2026-04-15 09:43:04 +03:00
renovate[bot]
41905b006a Update docker.io/ollama/ollama Docker tag to v0.20.7 2026-04-14 07:33:22 +03:00
renovate[bot]
ee3d27701b Update docker.io/ollama/ollama Docker tag to v0.20.6 2026-04-13 08:58:29 +03:00
Slavi Pantaleev
1b5c2fbdc1 Prepare 1.18.0 release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:55:55 +03:00
Slavi Pantaleev
a865a26093 Update tiktoken-rs to 0.11, adding support for newer GPT models
Supersedes https://github.com/etkecc/baibot/pull/116

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:52:22 +03:00
Slavi Pantaleev
1563e83672 Update dependencies 2026-04-11 10:40:09 +03:00
renovate[bot]
cf114b37b3 Update docker.io/ollama/ollama Docker tag to v0.20.5 2026-04-10 09:21:42 +03:00
renovate[bot]
b8ef2b978b Update Rust crate tokio to v1.51.1 2026-04-08 19:25:58 +03:00
renovate[bot]
7e37aee1b9 Update ghcr.io/element-hq/synapse Docker tag to v1.151.0 2026-04-08 11:57:52 +03:00
renovate[bot]
53836e556a Update ghcr.io/element-hq/element-web Docker tag to v1.12.15 2026-04-08 11:57:29 +03:00
renovate[bot]
89052bbfd9 Update ghcr.io/element-hq/element-web Docker tag to v1.12.14 2026-04-07 19:53:11 +03:00
renovate[bot]
07eb12d406 Update docker.io/ollama/ollama Docker tag to v0.20.3 2026-04-07 08:45:11 +03:00
renovate[bot]
3272cd6fb2 Update Rust crate tokio to 1.51.* 2026-04-04 15:52:04 +03:00
renovate[bot]
7ab97a39ca Update docker.io/ollama/ollama Docker tag to v0.20.2 2026-04-04 10:35:45 +03:00
renovate[bot]
11c5a9942e Update docker.io/ollama/ollama Docker tag to v0.20.1 2026-04-04 09:17:02 +03:00
renovate[bot]
251dec454c Update docker.io/ollama/ollama Docker tag to v0.20.0 2026-04-03 07:40:18 +03:00
renovate[bot]
1031dbf672 Update docker.io/ollama/ollama Docker tag to v0.19.0 2026-03-30 08:31:02 +03:00
renovate[bot]
581f00b9fb Update docker.io/ollama/ollama Docker tag to v0.18.3 2026-03-26 08:18:33 +02:00
Slavi Pantaleev
e57778d2bd Prepare 1.17.0 release 2026-03-25 20:18:56 +02:00
kschwank
2d659964a7 Add sender context mode for text generation (#104)
Add a per-room/global `sender_context_mode` setting that optionally
prefixes conversation messages with sender metadata before sending them
to the model provider.

This helps models distinguish between participants in multi-user rooms.
                                                                                                                                                                                                                                                             
Three modes are supported:
- `disabled` (default, no change)
- `matrix_user_id` (prefixes with `[sender=@user:server]`)
- `matrix_user_id_and_timestamp` (adds `send_at`; Example: `[sender=@user:server sent_at=<ISO 8601>]`)
                                                                                                                                                                                                                                                             
Sender context is applied to user and assistant text messages only,
skipping system prompts and non-text content.

Mixed-sender merged turns (something we intentionally do for Anthropic)
have their `sender_id` cleared to avoid misattribution.
2026-03-25 20:14:34 +02:00
renovate[bot]
661e7263fb Update ghcr.io/element-hq/synapse Docker tag to v1.150.0 2026-03-24 17:08:21 +02:00
renovate[bot]
1705c16762 Update ghcr.io/element-hq/element-web Docker tag to v1.12.13 2026-03-24 14:28:10 +02:00
45 changed files with 1523 additions and 1051 deletions

26
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: CI
on:
workflow_dispatch:
pull_request:
branches: [ "main" ]
push:
branches:
- "**"
tags: [ "v*" ]
permissions:
contents: read
pull-requests: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test-and-clippy:
name: Unit testing and linting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.93.0
- name: Install SQLite3
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
- run: cargo test --all-features
- run: cargo clippy

View File

@@ -1,33 +1,31 @@
name: CI (main and tags)
name: Publish
on:
push:
branches: [ "main" ]
tags: [ "v*" ]
workflow_run:
workflows: [ "CI" ]
types: [ "completed" ]
permissions:
checks: write
contents: write
packages: write
pull-requests: read
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: publish-${{ github.event.workflow_run.id || github.ref }}
cancel-in-progress: false
jobs:
test-and-clippy:
name: Unit testing and linting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.93.0
- name: Install SQLite3
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
- run: cargo test --all-features
- run: cargo clippy
docker-clean-metadata:
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
(
github.event.workflow_run.head_branch == 'main' ||
startsWith(github.event.workflow_run.head_branch || '', 'v')
)
runs-on: ubuntu-latest
outputs:
json: ${{ steps.meta.outputs.json }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
@@ -35,10 +33,17 @@ jobs:
images: |
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
type=semver,pattern={{raw}}
type=raw,value=latest,enable=${{ github.event.workflow_run.head_branch == 'main' }}
type=semver,pattern={{raw}},value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch || '', 'v') }}
docker-build:
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
(
github.event.workflow_run.head_branch == 'main' ||
startsWith(github.event.workflow_run.head_branch || '', 'v')
)
permissions:
contents: read
packages: write
@@ -57,6 +62,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- name: Log in to the GitHub Container registry
uses: docker/login-action@v4
with:
@@ -68,8 +76,8 @@ jobs:
uses: docker/metadata-action@v6
with:
tags: |
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
type=semver,pattern={{raw}}
type=raw,value=latest,enable=${{ github.event.workflow_run.head_branch == 'main' }}
type=semver,pattern={{raw}},value=${{ github.event.workflow_run.head_branch }},enable=${{ startsWith(github.event.workflow_run.head_branch || '', 'v') }}
flavor: |
latest=auto
suffix=-${{ matrix.arch }},onlatest=true
@@ -84,6 +92,16 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
docker-manifest:
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
(
github.event.workflow_run.head_branch == 'main' ||
startsWith(github.event.workflow_run.head_branch || '', 'v')
)
permissions:
contents: read
packages: write
needs:
- docker-build
- docker-clean-metadata
@@ -103,5 +121,4 @@ jobs:
- name: Create and push manifest
run: |
docker manifest create ${{ matrix.image }} ${{ matrix.image }}-amd64 ${{ matrix.image }}-arm64
docker manifest push ${{ matrix.image }}
docker buildx imagetools create -t ${{ matrix.image }} ${{ matrix.image }}-amd64 ${{ matrix.image }}-arm64

View File

@@ -1,3 +1,72 @@
# (2026-06-05) Version 1.21.1
- (**Security**) Update the [anthropic](https://github.com/etkecc/anthropic-rs) dependency to use [reqwest](https://crates.io/crates/reqwest) 0.12 / [rustls](https://crates.io/crates/rustls) 0.23, replacing the vulnerable `rustls-webpki` 0.101 line with 0.103.13. This resolves [`GHSA-82j2-j2ch-gfr8`](https://github.com/advisories/GHSA-82j2-j2ch-gfr8) (high — denial of service via panic on a malformed CRL), [`GHSA-xgp8-3hg3-c2mh`](https://github.com/advisories/GHSA-xgp8-3hg3-c2mh) and [`GHSA-965h-392x-2mh5`](https://github.com/advisories/GHSA-965h-392x-2mh5) (name-constraint validation issues).
# (2026-06-05) Version 1.21.0
- (**Improvement**) Default to OpenAI's `gpt-image-2` model for image generation (in newly-created OpenAI agents and the sample provider configs).
- (**Internal Improvement**) Update [async-openai](https://crates.io/crates/async-openai) from 0.40 to 0.41, which [resynchronizes with the upstream OpenAI API spec](https://github.com/64bit/async-openai/issues/557) after it had drifted out of sync — a mismatch that was already causing some breakage (hopefully now resolved). Adapts to the newly-added `gpt-image-2` image model and an `ImageSize` type change.
- (**Internal Improvement**) Dependency updates.
# (2026-06-02) Version 1.20.0
- (**Internal Improvement**) Update [matrix-sdk](https://crates.io/crates/matrix-sdk) from 0.17 to 0.18 and [mxlink](https://crates.io/crates/mxlink) to 1.15.0.
- (**Internal Improvement**) Update [tiktoken-rs](https://crates.io/crates/tiktoken-rs) to 0.12, backporting OpenAI [tiktoken](https://github.com/openai/tiktoken) 0.13.0 for better alignment with upstream tokenization behavior.
- (**Internal Improvement**) Bump the pinned Rust toolchain from 1.95.0 to 1.96.0 (in `rust-toolchain.toml` and the Docker build images).
- (**Internal Improvement**) Dependency updates.
# (2026-05-27) Version 1.19.3
- (**Internal Improvement**) Update [async-openai](https://crates.io/crates/async-openai) to 0.40.2, pulling in several upstream fixes (streaming HTTP error surfacing, default `ResponseTextParam.format` deserialization, etc.).
- (**Internal Improvement**) Dependency updates.
# (2026-05-21) Version 1.19.2
- (**Internal Improvement**) Update [async-openai](https://crates.io/crates/async-openai) to 0.40.0.
- (**Internal Improvement**) Dependency updates.
# (2026-05-09) Version 1.19.1
- (**Internal Improvement**) Update [async-openai](https://crates.io/crates/async-openai) to 0.38.0.
- (**Internal Improvement**) Dependency updates.
# (2026-05-09) Version 1.19.0
- (**Internal Improvement**) Update [matrix-sdk](https://crates.io/crates/matrix-sdk) from 0.16 to 0.17 and [mxlink](https://crates.io/crates/mxlink) to 1.14.0. matrix-sdk 0.17 dropped its `native-tls` feature and now uses [rustls](https://github.com/rustls/rustls) exclusively as its TLS backend.
- (**Internal Improvement**) Bump the pinned Rust toolchain from 1.93.0 to 1.95.0 (in `rust-toolchain.toml` and the Docker build images).
- (**Internal Improvement**) Dependency updates.
# (2026-04-11) Version 1.18.0
- (**Bugfix**) Fix the bot not sending a welcome message when joining a room on homeservers (like [Continuwuity](https://continuwuity.org/)) that place the join membership event in the sync response's `state` block rather than the `timeline` block, via [mxlink](https://crates.io/crates/mxlink) 1.13.1
- (**Improvement**) Update [tiktoken-rs](https://crates.io/crates/tiktoken-rs) to 0.11, adding tokenization support for newer GPT models (gpt-5.x, codex, etc.) and fixing context sizes for o1-mini/chatgpt-4o/gpt-4.5
- (**Internal Improvement**) Dependency updates
# (2026-03-25) Version 1.17.0
- (**Feature**) Add `text-generation sender-context-mode` for attaching sender metadata to conversation messages. See the [💬 Text Generation](./docs/configuration/text-generation.md#-sender-context-mode) documentation for details. Thanks to [kschwank](https://github.com/kschwank) for the contribution in [#104](https://github.com/etkecc/baibot/pull/104)!
# (2026-03-24) Version 1.16.1
- (**Bugfix**) Fix compatibility with [async-openai](https://crates.io/crates/async-openai) 0.34.0 by populating the new `phase` field required for OpenAI Responses API message inputs. baibot does not currently distinguish between assistant `commentary` and `final_answer` turns, so using `None` preserves the previous behavior while remaining compatible with the updated crate.

1573
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ license = "AGPL-3.0-or-later"
readme = "README.md"
keywords = ["matrix", "chat", "bot", "AI", "LLM"]
include = ["/etc/assets/baibot-torso-768.png", "/src", "/README.md", "/CHANGELOG.md", "/LICENSE"]
version = "1.16.1"
version = "1.21.1"
edition = "2024"
[lib]
@@ -17,15 +17,14 @@ path = "src/lib.rs"
[dependencies]
anthropic = { git = "https://github.com/etkecc/anthropic-rs.git", branch = "fix-content-block-image" }
anyhow = "1.0.*"
async-openai = { version = "0.34.0", features = ["audio", "chat-completion", "image", "responses"] }
async-openai = { version = "0.41.0", features = ["audio", "chat-completion", "image", "responses"] }
base64 = "0.22.*"
chrono = { version = "0.4.*", default-features = false, features = ["std", "now"] }
# We'd rather not depend on this, but we cannot use the ruma-events EventContent macro without it.
# We add the `native-tls` feature, because of https://github.com/etkecc/rust-mxlink/issues/1
matrix-sdk = { version = "0.16.0", default-features = false, features = ["native-tls"] }
matrix-sdk = { version = "0.18.0", default-features = false }
mime_guess = "2.0.*"
mxidwc = "1.0.*"
mxlink = ">=1.13.0"
mxlink = ">=1.15.0"
etke_openai_api_rust = "0.1.*"
quick_cache = "0.6.*"
regex = "1.12.*"
@@ -33,8 +32,8 @@ serde = { version = "1.0.*", features = ["derive"], default-features = false }
serde_json = "1.0.*"
serde_yaml_ng = "0.10.*"
tempfile = "3.27.*"
tiktoken-rs = { version = "0.9.*", default-features = false }
tokio = { version = "1.50.*", features = ["rt", "rt-multi-thread", "macros"] }
tiktoken-rs = { version = "0.12.*", default-features = false }
tokio = { version = "1.52.*", features = ["rt", "rt-multi-thread", "macros"] }
tracing = "0.1.*"
tracing-subscriber = { version = "0.3.*", features = ["env-filter"] }
url = "2.5.*"

View File

@@ -4,7 +4,7 @@
# #
#######################################
FROM docker.io/rust:1.93.1-slim-trixie AS build
FROM docker.io/rust:1.96.0-slim-trixie AS build
RUN apt-get update && apt-get install -y build-essential pkg-config libssl-dev libsqlite3-dev

View File

@@ -4,7 +4,7 @@
# #
#######################################
FROM docker.io/rust:1.93.1-slim-trixie AS build
FROM docker.io/rust:1.96.0-slim-trixie AS build
RUN apt-get update && apt-get install -y build-essential pkg-config libssl-dev libsqlite3-dev

View File

@@ -57,6 +57,25 @@ This feature relies on [tokenization](https://en.wikipedia.org/wiki/Large_langua
This setting is **disabled by default**, but can be enabled via `!bai config room text-generation set-context-management-enabled true` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings)).
### 👤 Sender Context Mode
In multi-user rooms, it may be useful for the model to know which participant sent each message in the conversation context.
To support this, the bot has a `text-generation sender-context-mode` setting, which can be set to:
- (default) `disabled`: do not attach sender metadata to messages before sending them to the model
- `matrix_user_id`: prefix text messages with the sender's Matrix user ID, for example: `[sender=@alice:example.com] Hello bot`
- `matrix_user_id_and_timestamp`: prefix text messages with the sender's Matrix user ID and the message timestamp, for example: `[sender=@alice:example.com sent_at=2026-03-23T14:30:00Z] Hello bot`
This sender metadata is attached to conversation messages before they are sent to the model provider. It applies to user and assistant text messages, but not to system prompts or non-text content.
⚠️ Enabling this sends Matrix user IDs, and optionally timestamps, to the model provider.
Example: `!bai config room text-generation set-sender-context-mode matrix_user_id` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))
### ⌨️ Prompt Override
You can override the [system prompt](https://huggingface.co/docs/transformers/en/tasks/prompting) configured at the [🤖 agent](../agents.md) level.

View File

@@ -32,6 +32,8 @@ In multi-user (group) rooms, to avoid disturbing the normal conversation between
Normally, the bot only responds to allowed [👥 Users](./access.md#-users). In certain cases, it's useful for an allowed user to provoke the bot to respond even in foreign threads or reply chains. You can learn more about this feature in the [On-demand involvement](./features.md#on-demand-involvement) section below.
If needed, the bot can also attach sender metadata to conversation messages before sending them to the model, which can help the model distinguish between participants in multi-user rooms. See [🛠️ Configuration / 💬 Text Generation / 👤 Sender Context Mode](./configuration/text-generation.md#-sender-context-mode).
A few other features (like [🗣️ Text-to-Speech](#️-text-to-speech) and [🦻 Speech-to-Text](#-speech-to-text)) combine well with Text Generation, so you **don't necessarily need to communicate with the bot via text** (with [Seamless voice interaction](#seamless-voice-interaction), you can communicate only with voice).
You may also wish to see:

View File

@@ -21,7 +21,7 @@ text_to_speech:
speed: 1.0
response_format: opus
image_generation:
model_id: gpt-image-1.5
model_id: gpt-image-2
style: null
size: null
quality: null

View File

@@ -111,7 +111,7 @@ agents:
# speed: 1.0
# response_format: opus
# image_generation:
# model_id: gpt-image-1.5
# model_id: gpt-image-2
# style: null
# size: null
# quality: null

View File

@@ -1,6 +1,6 @@
services:
continuwuity:
image: forgejo.ellis.link/continuwuation/continuwuity:v0.5.6
image: forgejo.ellis.link/continuwuation/continuwuity:v0.5.9
user: "${UID}:${GID}"
restart: unless-stopped
cap_drop:

View File

@@ -1,6 +1,6 @@
services:
element-web:
image: ghcr.io/element-hq/element-web:v1.12.12
image: ghcr.io/element-hq/element-web:v1.12.20
user: "${UID}:${GID}"
restart: unless-stopped
environment:

View File

@@ -1,6 +1,6 @@
services:
ollama:
image: docker.io/ollama/ollama:0.18.2
image: docker.io/ollama/ollama:0.30.5
restart: unless-stopped
ports:
- "${SERVICE_OLLAMA_BIND_PORT_HTTP}:11434"

View File

@@ -1,6 +1,6 @@
services:
postgres:
image: docker.io/postgres:18.3-alpine
image: docker.io/postgres:18.4-alpine
user: ${UID}:${GID}
restart: unless-stopped
environment:
@@ -14,7 +14,7 @@ services:
- /etc/passwd:/etc/passwd:ro
synapse:
image: ghcr.io/element-hq/synapse:v1.149.1
image: ghcr.io/element-hq/synapse:v1.154.0
user: "${UID}:${GID}"
restart: unless-stopped
entrypoint: python

View File

@@ -1,5 +1,5 @@
[tools]
prek = "0.3.2"
prek = "0.4.4"
[settings]
# Disable automatic trust prompts - we trust this config

View File

@@ -1,4 +1,4 @@
[toolchain]
channel = "1.93.0"
channel = "1.96.0"
components = ["rustfmt", "clippy"]
profile = "default"

View File

@@ -71,6 +71,7 @@ impl ControllerTrait for Controller {
let messages = vec![LLMMessage {
author: LLMAuthor::User,
sender_id: None,
content: LLMMessageContent::Text("Hello!".to_string()),
timestamp: chrono::Utc::now(),
}];
@@ -108,6 +109,7 @@ impl ControllerTrait for Controller {
} else {
Some(LLMMessage {
author: LLMAuthor::Prompt,
sender_id: None,
content: LLMMessageContent::Text(prompt_text),
timestamp: chrono::Utc::now(),
})

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use super::OPENAI_IMAGE_MODEL_GPT_IMAGE_1_DOT_5;
use super::OPENAI_IMAGE_MODEL_GPT_IMAGE_2;
use crate::agent::{default_prompt, provider::ConfigTrait};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -175,7 +175,7 @@ pub struct ImageGenerationConfig {
impl Default for ImageGenerationConfig {
fn default() -> Self {
Self {
model_id: OPENAI_IMAGE_MODEL_GPT_IMAGE_1_DOT_5.to_owned(),
model_id: OPENAI_IMAGE_MODEL_GPT_IMAGE_2.to_owned(),
style: default_image_style(),
size: default_image_size(),
quality: default_image_quality(),
@@ -193,6 +193,7 @@ impl ImageGenerationConfig {
"gpt-image-1" => Ok(async_openai::types::images::ImageModel::GptImage1),
"gpt-image-1.5" => Ok(async_openai::types::images::ImageModel::GptImage1dot5),
"gpt-image-1-mini" => Ok(async_openai::types::images::ImageModel::GptImage1Mini),
"gpt-image-2" => Ok(async_openai::types::images::ImageModel::GptImage2),
other => Ok(async_openai::types::images::ImageModel::Other(
other.to_owned(),
)),

View File

@@ -67,6 +67,7 @@ impl ControllerTrait for Controller {
let messages = vec![LLMMessage {
author: LLMAuthor::User,
sender_id: None,
content: LLMMessageContent::Text("Hello!".to_string()),
timestamp: chrono::Utc::now(),
}];
@@ -104,6 +105,7 @@ impl ControllerTrait for Controller {
} else {
Some(LLMMessage {
author: LLMAuthor::Prompt,
sender_id: None,
content: LLMMessageContent::Text(prompt_text),
timestamp: chrono::Utc::now(),
})
@@ -269,6 +271,7 @@ impl ControllerTrait for Controller {
ImageModel::GptImage1 => ImageModel::GptImage1Mini,
ImageModel::GptImage1dot5 => ImageModel::GptImage1Mini,
ImageModel::GptImage1Mini => ImageModel::GptImage1Mini,
ImageModel::GptImage2 => ImageModel::GptImage1Mini,
ImageModel::Other(_) => ImageModel::DallE2,
}
} else {
@@ -308,7 +311,7 @@ impl ControllerTrait for Controller {
let size = if params.smallest_size_possible {
Some(get_sticker_size(&model))
} else {
image_generation_config.size
image_generation_config.size.clone()
};
let response_format = match model.clone() {
@@ -319,6 +322,7 @@ impl ControllerTrait for Controller {
ImageModel::GptImage1 => None,
ImageModel::GptImage1Mini => None,
ImageModel::GptImage1dot5 => None,
ImageModel::GptImage2 => None,
ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),
};
@@ -428,6 +432,7 @@ impl ControllerTrait for Controller {
ImageModel::GptImage1 => None,
ImageModel::GptImage1Mini => None,
ImageModel::GptImage1dot5 => None,
ImageModel::GptImage2 => None,
ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),
};
@@ -645,6 +650,7 @@ fn get_sticker_size(model: &ImageModel) -> async_openai::types::images::ImageSiz
ImageModel::GptImage1 => ImageSize::S1024x1024,
ImageModel::GptImage1Mini => ImageSize::S1024x1024,
ImageModel::GptImage1dot5 => ImageSize::S1024x1024,
ImageModel::GptImage2 => ImageSize::S1024x1024,
ImageModel::Other(_) => ImageSize::S1024x1024,
}
}

View File

@@ -16,7 +16,7 @@ use super::super::AgentInstantiationResult;
use super::ConfigTrait;
use super::controller::ControllerType;
pub const OPENAI_IMAGE_MODEL_GPT_IMAGE_1_DOT_5: &str = "gpt-image-1.5";
pub const OPENAI_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
pub fn create_controller_from_yaml_value_config(
agent_id: &str,

View File

@@ -64,6 +64,7 @@ impl ControllerTrait for Controller {
let messages = vec![LLMMessage {
author: LLMAuthor::User,
sender_id: None,
content: LLMMessageContent::Text("Hello!".to_string()),
timestamp: chrono::Utc::now(),
}];
@@ -101,6 +102,7 @@ impl ControllerTrait for Controller {
} else {
Some(LLMMessage {
author: LLMAuthor::Prompt,
sender_id: None,
content: LLMMessageContent::Text(prompt_text),
timestamp: chrono::Utc::now(),
})

View File

@@ -9,7 +9,7 @@ fn agent_config_parsing_works() {
let sample_config = crate::agent::default_config_for_provider(&provider);
let sample_config_pretty_yaml = serde_yaml_ng::to_string(&sample_config).unwrap();
let test_cases = vec![
let test_cases = [
// Invalid input
TestCase {
input: r#"Hello"#.to_owned(),

View File

@@ -3,7 +3,8 @@ use crate::{
entity::roomconfig::{
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,
TextToSpeechUserMessagesFlowType,
},
};
@@ -48,6 +49,9 @@ pub enum ConfigTextGenerationSettingRelatedControllerType {
GetTemperatureOverride,
SetTemperatureOverride(Option<f32>),
GetSenderContextMode,
SetSenderContextMode(Option<TextGenerationSenderContextMode>),
}
#[derive(Debug, PartialEq)]

View File

@@ -163,6 +163,26 @@ fn determine_controller() {
),
)),
},
TestCase {
name: "per-room text-generation/sender-context-mode getter",
input: "room text-generation sender-context-mode",
expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(
controller_type::SettingsStorageSource::Room,
controller_type::ConfigSettingRelatedControllerType::TextGeneration(
controller_type::ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode,
),
)),
},
TestCase {
name: "global text-generation/sender-context-mode getter",
input: "global text-generation sender-context-mode",
expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(
controller_type::SettingsStorageSource::Global,
controller_type::ConfigSettingRelatedControllerType::TextGeneration(
controller_type::ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode,
),
)),
},
TestCase {
name: "per-room text-to-speech/speed-override getter",
input: "room text-to-speech speed-override",

View File

@@ -3,7 +3,10 @@ mod tests;
use crate::{
controller::ControllerType,
entity::roomconfig::{TextGenerationAutoUsage, TextGenerationPrefixRequirementType},
entity::roomconfig::{
TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
TextGenerationSenderContextMode,
},
strings,
};
@@ -197,5 +200,43 @@ pub(super) fn determine(
);
}
if let Some(remaining_text) = text.strip_prefix("sender-context-mode") {
let remaining_text = remaining_text.trim();
if !remaining_text.is_empty() {
return Err(ControllerType::Error(
strings::cfg::configuration_getter_used_with_extra_text(
"sender-context-mode",
remaining_text,
)
.to_owned(),
));
}
return Ok(ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode);
}
if let Some(value_string) = text.strip_prefix("set-sender-context-mode") {
let value_string = value_string.trim().to_owned();
let value_choice = if value_string.is_empty() {
None
} else {
let value_choice =
TextGenerationSenderContextMode::from_str(&value_string.to_lowercase());
if value_choice.is_none() {
return Err(ControllerType::Error(
strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),
));
}
value_choice
};
return Ok(
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(value_choice),
);
}
Err(ControllerType::Unknown)
}

View File

@@ -90,6 +90,74 @@ fn determine_controller_context_management() {
}
}
#[test]
fn determine_controller_sender_context() {
use super::ConfigTextGenerationSettingRelatedControllerType;
use super::ControllerType;
use crate::entity::roomconfig::TextGenerationSenderContextMode;
struct TestCase {
name: &'static str,
input: &'static str,
expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,
}
let test_cases = vec![
TestCase {
name: "sender-context-mode getter ok",
input: "sender-context-mode",
expected: Ok(ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode),
},
TestCase {
name: "sender-context-mode getter extra args",
input: "sender-context-mode some values here",
expected: Err(ControllerType::Error(
crate::strings::cfg::configuration_getter_used_with_extra_text(
"sender-context-mode",
"some values here",
),
)),
},
TestCase {
name: "sender-context-mode setter matrix_user_id",
input: "set-sender-context-mode matrix_user_id",
expected: Ok(
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(Some(
TextGenerationSenderContextMode::MatrixUserId,
)),
),
},
TestCase {
name: "sender-context-mode setter uppercase",
input: "set-sender-context-mode MATRIX_USER_ID_AND_TIMESTAMP",
expected: Ok(
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(Some(
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
)),
),
},
TestCase {
name: "sender-context-mode setter invalid",
input: "set-sender-context-mode non-Enum-Value",
expected: Err(ControllerType::Error(
crate::strings::cfg::configuration_value_unrecognized("non-Enum-Value"),
)),
},
TestCase {
name: "sender-context-mode unsetter",
input: "set-sender-context-mode",
expected: Ok(
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(None),
),
},
];
for test_case in test_cases {
let result = super::determine(test_case.input);
assert_eq!(result, test_case.expected, "Test case: {}", test_case.name);
}
}
#[test]
fn determine_controller_prefix_requirement_type() {
use super::ConfigTextGenerationSettingRelatedControllerType;

View File

@@ -1,5 +1,6 @@
use crate::entity::roomconfig::{
RoomSettings, TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
TextGenerationSenderContextMode,
};
use crate::{Bot, entity::MessageContext};
@@ -151,5 +152,38 @@ pub(super) async fn dispatch(
}
}
}
ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode => {
let value = &room_settings.text_generation.sender_context_mode;
setting_get::<TextGenerationSenderContextMode>(bot, message_context, value).await
}
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(value) => {
let value = value.to_owned();
let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {
room_settings.text_generation.sender_context_mode = value;
});
match config_type {
SettingsStorageSource::Room => {
room_setting_set::<TextGenerationSenderContextMode>(
bot,
message_context,
&value,
setter_callback,
)
.await
}
SettingsStorageSource::Global => {
global_setting_set::<TextGenerationSenderContextMode>(
bot,
message_context,
&value,
setter_callback,
)
.await
}
}
}
}
}

View File

@@ -7,7 +7,8 @@ use crate::{
roomconfig::{
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,
TextToSpeechUserMessagesFlowType,
},
},
strings,
@@ -233,6 +234,46 @@ fn build_section_text_generation(command_prefix: &str, bot_username: &str) -> St
));
message.push_str("\n\n");
// Sender Context
message.push_str(&format!(
"#### {}",
strings::help::cfg::text_generation_sender_context_heading()
));
message.push_str("\n\n");
message.push_str(&strings::help::cfg::text_generation_sender_context_intro());
message.push('\n');
message.push_str(
&strings::help::cfg::the_following_configuration_values_are_recognized(
TextGenerationSenderContextMode::choices(),
),
);
message.push_str("\n\n");
message.push_str(&format!(
"- {}",
&strings::help::cfg::current_setting_show(
command_prefix,
"text-generation sender-context-mode"
)
));
message.push('\n');
message.push_str(&format!(
"- {}",
&strings::help::cfg::current_setting_set(
command_prefix,
"text-generation set-sender-context-mode VALUE"
)
));
message.push('\n');
message.push_str(&format!(
"- {}",
&strings::help::cfg::current_setting_unset(
command_prefix,
"text-generation set-sender-context-mode"
)
));
message.push_str("\n\n");
// Prompt override
message.push_str(&format!(

View File

@@ -359,6 +359,33 @@ async fn generate_text_generation_section(
),
);
// Sender Context
let effective_sender_context = room_config_context.text_generation_sender_context_mode();
let room_config_sender_context = room_config_context
.room_config
.settings
.text_generation
.sender_context_mode;
let global_config_sender_context = room_config_context
.global_config
.fallback_room_settings
.text_generation
.sender_context_mode;
let sender_context_set_where = if room_config_sender_context.is_some() {
strings::cfg::status_badge_set_in_room_config()
} else if global_config_sender_context.is_some() {
strings::cfg::status_badge_set_in_global_config()
} else {
strings::cfg::status_badge_using_hardcoded_default()
};
message.push_str(&strings::cfg::status_text_generation_entry_sender_context(
effective_sender_context,
sender_context_set_where,
));
// Prompt override
let text_agent_prompt = if let Some(text_generation_agent) = &text_generation_agent {

View File

@@ -15,7 +15,8 @@ use crate::conversation::matrix::MatrixMessageProcessingParams;
use crate::entity::MessagePayload;
use crate::entity::roomconfig::{
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,
TextToSpeechUserMessagesFlowType,
};
use crate::strings;
use crate::utils::text_to_speech::create_transcribed_message_text;
@@ -23,6 +24,7 @@ use crate::{
Bot,
conversation::{
create_llm_conversation_for_matrix_reply_chain, create_llm_conversation_for_matrix_thread,
llm::{Author, Conversation, MessageContent},
matrix::create_list_of_bot_user_prefixes_to_strip,
},
entity::MessageContext,
@@ -486,6 +488,13 @@ async fn handle_stage_text_generation(
}
};
let conversation = inject_sender_context(
conversation,
message_context
.room_config_context()
.text_generation_sender_context_mode(),
);
tracing::debug!(
agent_id = agent.identifier().as_string(),
provider = format!("{}", agent.definition().provider.clone()),
@@ -761,3 +770,238 @@ async fn generate_and_send_tts_for_message(
)
.await
}
fn inject_sender_context(
conversation: Conversation,
sender_context_mode: TextGenerationSenderContextMode,
) -> Conversation {
if sender_context_mode == TextGenerationSenderContextMode::Disabled {
return conversation;
}
let include_timestamp =
sender_context_mode == TextGenerationSenderContextMode::MatrixUserIdAndTimestamp;
let messages = conversation
.messages
.into_iter()
.map(|mut message| {
if message.author == Author::Prompt {
return message;
}
let Some(sender_id) = &message.sender_id else {
return message;
};
if let MessageContent::Text(ref mut text) = message.content {
*text = if include_timestamp {
let timestamp = message.timestamp.format("%Y-%m-%dT%H:%M:%SZ");
format!("[sender={} sent_at={}] {}", sender_id, timestamp, text)
} else {
format!("[sender={}] {}", sender_id, text)
};
}
message
})
.collect();
Conversation { messages }
}
#[cfg(test)]
mod sender_context_tests {
use super::inject_sender_context;
use crate::conversation::llm::{Author, Conversation, ImageDetails, Message, MessageContent};
use crate::entity::roomconfig::TextGenerationSenderContextMode;
use chrono::{TimeZone, Utc};
use mxlink::matrix_sdk::ruma::events::room::message::ImageMessageEventContent;
use mxlink::matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};
use mxlink::mime;
#[test]
fn test_inject_sender_context_prefixes_text_messages() {
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
let user_id = OwnedUserId::try_from("@alice:example.com").unwrap();
let conversation = Conversation {
messages: vec![Message {
author: Author::User,
sender_id: Some(user_id),
timestamp,
content: MessageContent::Text("Hello bot".to_string()),
}],
};
let result = inject_sender_context(
conversation,
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
);
assert_eq!(result.messages.len(), 1);
assert_eq!(
result.messages[0].content,
MessageContent::Text(
"[sender=@alice:example.com sent_at=2026-03-23T14:30:00Z] Hello bot".to_string()
)
);
}
#[test]
fn test_inject_sender_context_can_prefix_without_timestamp() {
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
let user_id = OwnedUserId::try_from("@alice:example.com").unwrap();
let conversation = Conversation {
messages: vec![Message {
author: Author::User,
sender_id: Some(user_id),
timestamp,
content: MessageContent::Text("Hello bot".to_string()),
}],
};
let result =
inject_sender_context(conversation, TextGenerationSenderContextMode::MatrixUserId);
assert_eq!(result.messages.len(), 1);
assert_eq!(
result.messages[0].content,
MessageContent::Text("[sender=@alice:example.com] Hello bot".to_string())
);
}
#[test]
fn test_inject_sender_context_prefixes_assistant_messages() {
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
let user_id = OwnedUserId::try_from("@baibot:example.com").unwrap();
let conversation = Conversation {
messages: vec![Message {
author: Author::Assistant,
sender_id: Some(user_id),
timestamp,
content: MessageContent::Text("Hello human".to_string()),
}],
};
let result = inject_sender_context(
conversation,
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
);
assert_eq!(result.messages.len(), 1);
assert_eq!(
result.messages[0].content,
MessageContent::Text(
"[sender=@baibot:example.com sent_at=2026-03-23T14:30:00Z] Hello human".to_string()
)
);
}
#[test]
fn test_inject_sender_context_skips_prompt_messages() {
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
let conversation = Conversation {
messages: vec![Message {
author: Author::Prompt,
sender_id: None,
timestamp,
content: MessageContent::Text("You are a bot".to_string()),
}],
};
let result =
inject_sender_context(conversation, TextGenerationSenderContextMode::MatrixUserId);
assert_eq!(
result.messages[0].content,
MessageContent::Text("You are a bot".to_string())
);
}
#[test]
fn test_inject_sender_context_skips_messages_without_sender_id() {
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
let conversation = Conversation {
messages: vec![Message {
author: Author::User,
sender_id: None,
timestamp,
content: MessageContent::Text("Transcribed text".to_string()),
}],
};
let result = inject_sender_context(
conversation,
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
);
assert_eq!(
result.messages[0].content,
MessageContent::Text("Transcribed text".to_string())
);
}
#[test]
fn test_inject_sender_context_leaves_non_text_content_unchanged() {
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
let user_id = OwnedUserId::try_from("@alice:example.com").unwrap();
let image_event_content = ImageMessageEventContent::plain(
"image.png".to_string(),
OwnedMxcUri::from("mxc://example.com/1234567890"),
);
let conversation = Conversation {
messages: vec![Message {
author: Author::User,
sender_id: Some(user_id),
timestamp,
content: MessageContent::Image(ImageDetails::new(
image_event_content.clone(),
mime::IMAGE_PNG,
vec![],
)),
}],
};
let result = inject_sender_context(
conversation,
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
);
assert_eq!(
result.messages[0].content,
MessageContent::Image(ImageDetails::new(
image_event_content,
mime::IMAGE_PNG,
vec![]
))
);
}
#[test]
fn test_inject_sender_context_none_leaves_text_unchanged() {
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
let user_id = OwnedUserId::try_from("@alice:example.com").unwrap();
let conversation = Conversation {
messages: vec![Message {
author: Author::User,
sender_id: Some(user_id),
timestamp,
content: MessageContent::Text("Hello bot".to_string()),
}],
};
let result = inject_sender_context(conversation, TextGenerationSenderContextMode::Disabled);
assert_eq!(
result.messages[0].content,
MessageContent::Text("Hello bot".to_string())
);
}
}

View File

@@ -64,6 +64,7 @@ mod tests {
original_prompt: "Generate a picture of a dog",
messages: vec![Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("Must be blue".to_owned()),
timestamp,
}],
@@ -75,16 +76,19 @@ mod tests {
messages: vec![
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("Must be blue".to_owned()),
timestamp,
},
Message {
author: Author::Assistant,
sender_id: None,
content: MessageContent::Text("Whatever".to_owned()),
timestamp,
},
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text(
"Must be 3-legged.\nMust be flying.".to_owned(),
),
@@ -99,21 +103,25 @@ mod tests {
messages: vec![
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("Must be blue".to_owned()),
timestamp,
},
Message {
author: Author::Assistant,
sender_id: None,
content: MessageContent::Text("Whatever".to_owned()),
timestamp,
},
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("Again".to_owned()),
timestamp,
},
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("again".to_owned()),
timestamp,
},

View File

@@ -1,4 +1,5 @@
use chrono::{DateTime, Utc};
use mxlink::matrix_sdk::ruma::OwnedUserId;
use mxlink::matrix_sdk::ruma::events::room::message::{
FileMessageEventContent, ImageMessageEventContent,
};
@@ -16,6 +17,7 @@ pub enum Author {
#[derive(Debug, Clone)]
pub struct Message {
pub author: Author,
pub sender_id: Option<OwnedUserId>,
pub timestamp: DateTime<Utc>,
pub content: MessageContent,
}
@@ -104,6 +106,11 @@ impl Conversation {
///
/// Certain models (like Anthropic) cannot tolerate consecutive messages by the same author,
/// so combining them helps avoid issues.
///
/// When multiple text messages by the same author are merged, the resulting message keeps a
/// `sender_id` only if all merged messages came from the same sender. Mixed-sender merges are
/// possible for user turns in multi-user rooms, so `sender_id` is cleared in that case to
/// avoid incorrectly attributing the whole merged turn to the first sender.
/// See: https://github.com/etkecc/baibot/issues/13
pub fn combine_consecutive_messages(&self) -> Conversation {
// We'll likely get fewer messages, but let's reserve the maximum we expect.
@@ -134,6 +141,10 @@ impl Conversation {
text.push('\n');
text.push_str(message_text_content);
}
if last_message.sender_id != message.sender_id {
last_message.sender_id = None;
}
}
Conversation {
@@ -150,7 +161,7 @@ impl Conversation {
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use mxlink::matrix_sdk::ruma::OwnedMxcUri;
use mxlink::matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};
use mxlink::mime;
#[test]
@@ -173,21 +184,25 @@ mod tests {
// User's turn
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("Hello".to_string()),
timestamp: timestamp_1,
},
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("How are you?".to_string()),
timestamp: timestamp_2,
},
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("I'm OK, btw.".to_string()),
timestamp: timestamp_3,
},
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Image(ImageDetails::new(
image_event_content.clone(),
mime::IMAGE_PNG,
@@ -197,28 +212,33 @@ mod tests {
},
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("Above is an image.".to_string()),
timestamp: timestamp_4,
},
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("Would you take a look at it?".to_string()),
timestamp: timestamp_4,
},
// Assistant's turn
Message {
author: Author::Assistant,
sender_id: None,
content: MessageContent::Text("Hi there!".to_string()),
timestamp: timestamp_2,
},
Message {
author: Author::Assistant,
sender_id: None,
content: MessageContent::Text("I'm doing well, thank you.".to_string()),
timestamp: timestamp_3,
},
// User's turn
Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text("That's great!".to_string()),
timestamp: timestamp_3,
},
@@ -267,4 +287,39 @@ mod tests {
);
assert_eq!(conversation.messages[4].timestamp, timestamp_3);
}
#[test]
fn combine_consecutive_messages_clears_sender_id_for_mixed_sender_turns() {
let timestamp_1 = Utc.with_ymd_and_hms(2024, 9, 20, 18, 34, 15).unwrap();
let timestamp_2 = Utc.with_ymd_and_hms(2024, 9, 20, 18, 34, 16).unwrap();
let sender_1 = OwnedUserId::try_from("@alice:example.com").unwrap();
let sender_2 = OwnedUserId::try_from("@bob:example.com").unwrap();
let conversation = Conversation {
messages: vec![
Message {
author: Author::User,
sender_id: Some(sender_1),
content: MessageContent::Text("Hello".to_string()),
timestamp: timestamp_1,
},
Message {
author: Author::User,
sender_id: Some(sender_2),
content: MessageContent::Text("Hi there".to_string()),
timestamp: timestamp_2,
},
],
};
let conversation = conversation.combine_consecutive_messages();
assert_eq!(conversation.messages.len(), 1);
assert_eq!(conversation.messages[0].sender_id, None);
assert_eq!(
conversation.messages[0].content,
MessageContent::Text("Hello\nHi there".to_string())
);
assert_eq!(conversation.messages[0].timestamp, timestamp_1);
}
}

View File

@@ -20,6 +20,7 @@ fn test_messages_by_the_bot_are_identified_correctly() {
let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id).unwrap();
assert_eq!(llm_message.author, Author::Assistant);
assert_eq!(llm_message.sender_id, Some(bot_user_id.clone()));
assert_eq!(
llm_message.content,
MessageContent::Text("Hello!".to_string())
@@ -47,6 +48,7 @@ fn test_notice_messages_by_bot_with_speech_to_text_prefix_are_cleaned_up_and_con
let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id).unwrap();
assert_eq!(llm_message.author, Author::User);
assert_eq!(llm_message.sender_id, None);
assert_eq!(
llm_message.content,
MessageContent::Text(source_message_text.to_string())
@@ -75,6 +77,30 @@ fn test_notice_error_messages_by_bot_are_ignored() {
assert!(llm_message.is_none());
}
#[test]
fn test_user_messages_preserve_sender_id() {
let bot_user_id =
OwnedUserId::try_from("@bot:example.com").expect("Failed to parse bot user ID");
let user_id = OwnedUserId::try_from("@alice:example.com").expect("Failed to parse user ID");
let matrix_message = super::super::matrix::MatrixMessage {
sender_id: user_id.clone(),
content: super::super::matrix::MatrixMessageContent::Text("Hello!".to_owned()),
mentioned_users: vec![],
timestamp: chrono::Utc::now(),
};
let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id).unwrap();
assert_eq!(llm_message.author, Author::User);
assert_eq!(llm_message.sender_id, Some(user_id));
assert_eq!(
llm_message.content,
MessageContent::Text("Hello!".to_string())
);
}
#[test]
fn test_other_notice_messages_by_the_bot_are_ignored() {
// Also see `test_notice_error_messages_by_bot_are_ignored()`.

View File

@@ -1,15 +1,15 @@
use tiktoken_rs::CoreBPE;
use tiktoken_rs::get_bpe_from_tokenizer;
use tiktoken_rs::bpe_for_tokenizer;
use tiktoken_rs::tokenizer;
use super::{Author, Message, MessageContent};
fn get_bpe_for_model(model: &str) -> CoreBPE {
fn get_bpe_for_model(model: &str) -> &'static CoreBPE {
let tokenizer = tokenizer::get_tokenizer(model)
.or_else(|| tokenizer::get_tokenizer("gpt-4"))
.unwrap();
get_bpe_from_tokenizer(tokenizer).unwrap()
bpe_for_tokenizer(tokenizer).unwrap()
}
pub fn shorten_messages_list_to_context_size(
@@ -26,7 +26,7 @@ pub fn shorten_messages_list_to_context_size(
// We want to retain the prompt in all cases, so we always count it first.
// We also always reserve enough tokens for the maximum response we expect.
let mut current_context_length: u32 = if let Some(prompt_message) = prompt_message {
calculate_token_size_for_message(&bpe, model, prompt_message)
calculate_token_size_for_message(bpe, model, prompt_message)
+ max_response_tokens.unwrap_or(0)
} else {
0
@@ -37,7 +37,7 @@ pub fn shorten_messages_list_to_context_size(
let mut messages_to_keep: Vec<Message> = Vec::new();
for message in messages {
let tokens_for_message = calculate_token_size_for_message(&bpe, model, &message);
let tokens_for_message = calculate_token_size_for_message(bpe, model, &message);
if current_context_length + tokens_for_message > max_context_tokens {
break;
@@ -89,11 +89,12 @@ pub mod test {
let message = super::Message {
author: super::Author::User,
sender_id: None,
content: super::MessageContent::Text("Hello there!".to_string()),
timestamp: chrono::Utc::now(),
};
let tokens = super::calculate_token_size_for_message(&bpe, model, &message);
let tokens = super::calculate_token_size_for_message(bpe, model, &message);
assert_eq!(8, tokens);
}
@@ -104,10 +105,12 @@ pub mod test {
let bpe = super::get_bpe_for_model(model);
let max_response_tokens: Option<u32> = Some(5);
let max_response_tokens_value: u32 = 5;
let max_response_tokens: Option<u32> = Some(max_response_tokens_value);
let prompt = super::Message {
author: super::Author::Prompt,
sender_id: None,
content: super::MessageContent::Text("You are a bot!".to_string()),
timestamp: chrono::Utc::now(),
};
@@ -115,13 +118,14 @@ pub mod test {
assert_eq!(
prompt_length,
super::calculate_token_size_for_message(&bpe, model, &prompt)
super::calculate_token_size_for_message(bpe, model, &prompt)
);
let mut conversation_messages = Vec::new();
let first = super::Message {
author: super::Author::User,
sender_id: None,
content: super::MessageContent::Text("Hello there!".to_string()),
timestamp: chrono::Utc::now(),
};
@@ -129,13 +133,14 @@ pub mod test {
assert_eq!(
first_length,
super::calculate_token_size_for_message(&bpe, model, &first)
super::calculate_token_size_for_message(bpe, model, &first)
);
conversation_messages.push(first);
let second = super::Message {
author: super::Author::Assistant,
sender_id: None,
content: super::MessageContent::Text("Hello!".to_string()),
timestamp: chrono::Utc::now(),
};
@@ -143,13 +148,14 @@ pub mod test {
assert_eq!(
second_length,
super::calculate_token_size_for_message(&bpe, model, &second)
super::calculate_token_size_for_message(bpe, model, &second)
);
conversation_messages.push(second);
let third = super::Message {
author: super::Author::User,
sender_id: None,
content: super::MessageContent::Text(
"This is the 3rd message in this conversation. It shall be preserved.".to_owned(),
),
@@ -159,13 +165,14 @@ pub mod test {
assert_eq!(
third_length,
super::calculate_token_size_for_message(&bpe, model, &third)
super::calculate_token_size_for_message(bpe, model, &third)
);
conversation_messages.push(third.clone());
let forth = super::Message {
author: super::Author::Assistant,
sender_id: None,
content: super::MessageContent::Text(
"This is yet another message that shall be preserved.".to_owned(),
),
@@ -175,7 +182,7 @@ pub mod test {
assert_eq!(
forth_length,
super::calculate_token_size_for_message(&bpe, model, &forth)
super::calculate_token_size_for_message(bpe, model, &forth)
);
conversation_messages.push(forth.clone());
@@ -187,7 +194,7 @@ pub mod test {
&Some(prompt),
conversation_messages,
max_response_tokens,
prompt_length + max_response_tokens.unwrap_or(0) + forth_length + third_length,
prompt_length + max_response_tokens_value + forth_length + third_length,
);
assert_eq!(2, new_conversation_messages.len());
@@ -209,10 +216,12 @@ pub mod test {
let bpe = super::get_bpe_for_model(model);
let max_response_tokens: Option<u32> = Some(5);
let max_response_tokens_value: u32 = 5;
let max_response_tokens: Option<u32> = Some(max_response_tokens_value);
let prompt = super::Message {
author: super::Author::User,
sender_id: None,
content: super::MessageContent::Text("あなたはボットです。".to_string()),
timestamp: chrono::Utc::now(),
};
@@ -220,13 +229,14 @@ pub mod test {
assert_eq!(
prompt_length,
super::calculate_token_size_for_message(&bpe, model, &prompt)
super::calculate_token_size_for_message(bpe, model, &prompt)
);
let mut conversation_messages = Vec::new();
let first = super::Message {
author: super::Author::User,
sender_id: None,
content: super::MessageContent::Text("こんにちは!".to_string()),
timestamp: chrono::Utc::now(),
};
@@ -234,13 +244,14 @@ pub mod test {
assert_eq!(
first_length,
super::calculate_token_size_for_message(&bpe, model, &first)
super::calculate_token_size_for_message(bpe, model, &first)
);
conversation_messages.push(first);
let second = super::Message {
author: super::Author::Assistant,
sender_id: None,
content: super::MessageContent::Text("こんにちは。今日は元気ですか。".to_string()),
timestamp: chrono::Utc::now(),
};
@@ -248,13 +259,14 @@ pub mod test {
assert_eq!(
second_length,
super::calculate_token_size_for_message(&bpe, model, &second)
super::calculate_token_size_for_message(bpe, model, &second)
);
conversation_messages.push(second);
let third = super::Message {
author: super::Author::User,
sender_id: None,
content: super::MessageContent::Text(
"これは第3のメッセージなので、保存されます。".to_string(),
),
@@ -264,13 +276,14 @@ pub mod test {
assert_eq!(
third_length,
super::calculate_token_size_for_message(&bpe, model, &third)
super::calculate_token_size_for_message(bpe, model, &third)
);
conversation_messages.push(third.clone());
let forth = super::Message {
author: super::Author::Assistant,
sender_id: None,
content: super::MessageContent::Text(
"これはもう一つの保存されますメッセージです。".to_string(),
),
@@ -280,7 +293,7 @@ pub mod test {
assert_eq!(
forth_length,
super::calculate_token_size_for_message(&bpe, model, &forth)
super::calculate_token_size_for_message(bpe, model, &forth)
);
conversation_messages.push(forth.clone());
@@ -292,7 +305,7 @@ pub mod test {
&Some(prompt),
conversation_messages,
max_response_tokens,
prompt_length + max_response_tokens.unwrap_or(0) + forth_length + third_length,
prompt_length + max_response_tokens_value + forth_length + third_length,
);
assert_eq!(2, new_conversation_messages.len());

View File

@@ -17,14 +17,17 @@ pub fn convert_matrix_message_to_llm_message(
fn convert_bot_message(matrix_message: &MatrixMessage) -> Option<Message> {
match &matrix_message.content {
MatrixMessageContent::Text(text) => {
convert_bot_text_message(text, &matrix_message.timestamp)
}
MatrixMessageContent::Text(text) => convert_bot_text_message(
text,
&matrix_message.timestamp,
matrix_message.sender_id.clone(),
),
MatrixMessageContent::Notice(text) => {
convert_bot_notice_message(text, &matrix_message.timestamp)
}
MatrixMessageContent::Image(image_content, mime_type, media_bytes) => Some(Message {
author: Author::Assistant,
sender_id: Some(matrix_message.sender_id.clone()),
content: MessageContent::Image(ImageDetails::new(
image_content.clone(),
mime_type.clone(),
@@ -34,6 +37,7 @@ fn convert_bot_message(matrix_message: &MatrixMessage) -> Option<Message> {
}),
MatrixMessageContent::File(file_content, mime_type, media_bytes) => Some(Message {
author: Author::Assistant,
sender_id: Some(matrix_message.sender_id.clone()),
content: MessageContent::File(FileDetails::new(
file_content.clone(),
mime_type.clone(),
@@ -47,9 +51,11 @@ fn convert_bot_message(matrix_message: &MatrixMessage) -> Option<Message> {
fn convert_bot_text_message(
text: &str,
timestamp: &chrono::DateTime<chrono::Utc>,
sender_id: OwnedUserId,
) -> Option<Message> {
Some(Message {
author: Author::Assistant,
sender_id: Some(sender_id),
content: MessageContent::Text(text.to_owned()),
timestamp: timestamp.to_owned(),
})
@@ -68,8 +74,10 @@ fn convert_bot_notice_message(
if let Some(text) = text_to_speech_utils::parse_transcribed_message_text(text) {
// This is a transcription message. We remove the prefix and consider it as a message sent by the user.
// sender_id is None because the original speaker is unknown.
return Some(Message {
author: Author::User,
sender_id: None,
content: MessageContent::Text(text.to_owned()),
timestamp: timestamp.to_owned(),
});
@@ -82,16 +90,19 @@ fn convert_user_message(matrix_message: &MatrixMessage) -> Option<Message> {
match &matrix_message.content {
MatrixMessageContent::Text(text) => Some(Message {
author: Author::User,
sender_id: Some(matrix_message.sender_id.clone()),
content: MessageContent::Text(text.clone()),
timestamp: matrix_message.timestamp.to_owned(),
}),
MatrixMessageContent::Notice(text) => Some(Message {
author: Author::User,
sender_id: Some(matrix_message.sender_id.clone()),
content: MessageContent::Text(text.clone()),
timestamp: matrix_message.timestamp.to_owned(),
}),
MatrixMessageContent::Image(image_content, mime_type, media_bytes) => Some(Message {
author: Author::User,
sender_id: Some(matrix_message.sender_id.clone()),
content: MessageContent::Image(ImageDetails::new(
image_content.clone(),
mime_type.clone(),
@@ -101,6 +112,7 @@ fn convert_user_message(matrix_message: &MatrixMessage) -> Option<Message> {
}),
MatrixMessageContent::File(file_content, mime_type, media_bytes) => Some(Message {
author: Author::User,
sender_id: Some(matrix_message.sender_id.clone()),
content: MessageContent::File(FileDetails::new(
file_content.clone(),
mime_type.clone(),

View File

@@ -119,7 +119,7 @@ async fn get_matrix_messages_in_reply_chain_native(
AnySyncMessageLikeEvent::RoomMessage(room_message) => {
if let SyncMessageLikeEvent::Original(room_message_original) = room_message {
match room_message_original.content.relates_to {
Some(Relation::Reply { in_reply_to }) => Some(in_reply_to.event_id.clone()),
Some(Relation::Reply(reply)) => Some(reply.in_reply_to.event_id.clone()),
_ => None,
}
} else {
@@ -420,11 +420,11 @@ pub async fn determine_interaction_context_for_room_event(
)
.await
}
Relation::Reply { in_reply_to } => {
Relation::Reply(reply) => {
determine_interaction_context_for_room_event_related_to_reply(
current_event,
current_event_is_mentioning_bot,
in_reply_to.event_id.clone(),
reply.in_reply_to.event_id.clone(),
)
.await
}

View File

@@ -5,8 +5,9 @@ use super::roomconfig::RoomConfig;
use crate::entity::roomconfig::{
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextToSpeechBotMessagesFlowType,
TextToSpeechUserMessagesFlowType, defaults as roomconfig_defaults,
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
defaults as roomconfig_defaults,
};
#[derive(Debug)]
@@ -135,6 +136,20 @@ impl RoomConfigContext {
.unwrap_or(false)
}
pub fn text_generation_sender_context_mode(&self) -> TextGenerationSenderContextMode {
self.room_config
.settings
.text_generation
.sender_context_mode
.or({
self.global_config
.fallback_room_settings
.text_generation
.sender_context_mode
})
.unwrap_or(roomconfig_defaults::TEXT_GENERATION_SENDER_CONTEXT_MODE)
}
pub fn text_generation_prefix_requirement_type(&self) -> TextGenerationPrefixRequirementType {
self.room_config
.settings

View File

@@ -1,5 +1,7 @@
use super::{SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages};
use super::{TextGenerationAutoUsage, TextGenerationPrefixRequirementType};
use super::{
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,
};
use super::{TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType};
pub const TEXT_GENERATION_PREFIX_REQUIREMENT_TYPE: TextGenerationPrefixRequirementType =
@@ -7,6 +9,9 @@ pub const TEXT_GENERATION_PREFIX_REQUIREMENT_TYPE: TextGenerationPrefixRequireme
pub const TEXT_GENERATION_AUTO_USAGE: TextGenerationAutoUsage = TextGenerationAutoUsage::Always;
pub const TEXT_GENERATION_SENDER_CONTEXT_MODE: TextGenerationSenderContextMode =
TextGenerationSenderContextMode::Disabled;
pub const TEXT_TO_SPEECH_BOT_MESSAGES_FLOW_TYPE: TextToSpeechBotMessagesFlowType =
TextToSpeechBotMessagesFlowType::OnDemandForVoice;

View File

@@ -16,7 +16,9 @@ pub use handler::RoomSettingsHandler;
pub use speech_to_text::{
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
};
pub use text_generation::{TextGenerationAutoUsage, TextGenerationPrefixRequirementType};
pub use text_generation::{
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,
};
pub use text_to_speech::{TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType};
#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]

View File

@@ -14,6 +14,9 @@ pub struct RoomSettingsTextGeneration {
/// When enabled, the bot will automatically tokenize messages and try to shorten the message context intelligently.
pub context_management_enabled: Option<bool>,
/// Controls how each message in the conversation context is annotated with sender metadata.
pub sender_context_mode: Option<TextGenerationSenderContextMode>,
/// Allows customizing the system prompt that the agent would use
pub prompt_override: Option<String>,
@@ -111,3 +114,46 @@ impl std::fmt::Display for TextGenerationAutoUsage {
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
pub enum TextGenerationSenderContextMode {
#[serde(rename = "disabled")]
Disabled,
#[serde(rename = "matrix_user_id")]
MatrixUserId,
#[serde(rename = "matrix_user_id_and_timestamp")]
MatrixUserIdAndTimestamp,
}
impl TextGenerationSenderContextMode {
pub fn choices() -> Vec<Self> {
vec![
Self::Disabled,
Self::MatrixUserId,
Self::MatrixUserIdAndTimestamp,
]
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"disabled" => Some(Self::Disabled),
"matrix_user_id" => Some(Self::MatrixUserId),
"matrix_user_id_and_timestamp" => Some(Self::MatrixUserIdAndTimestamp),
_ => None,
}
}
}
impl std::fmt::Display for TextGenerationSenderContextMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TextGenerationSenderContextMode::Disabled => write!(f, "disabled"),
TextGenerationSenderContextMode::MatrixUserId => write!(f, "matrix_user_id"),
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp => {
write!(f, "matrix_user_id_and_timestamp")
}
}
}
}

View File

@@ -6,8 +6,8 @@ use mxlink::helpers::account_data_config::RoomConfigManager as AccountDataRoomCo
pub use entity::{RoomConfig, RoomConfigCarrierContent, RoomSettings, RoomSettingsHandler};
pub use entity::{
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextToSpeechBotMessagesFlowType,
TextToSpeechUserMessagesFlowType,
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
};
pub type RoomConfigurationManager =

View File

@@ -1,3 +1,9 @@
// rustc 1.94+ trips a query-depth overflow when computing async layouts in
// the matrix-sdk timeline future graph. matrix-rust-sdk PR #6489 raises the
// limit, but `recursion_limit` is per-crate and applies to the crate currently
// being compiled — so the consumer has to repeat it.
#![recursion_limit = "256"]
mod agent;
mod bot;
mod controller;

View File

@@ -249,6 +249,13 @@ pub fn status_text_generation_entry_context_management(value: bool, set_where: &
format!("- ♻️ Context management: `{}` ({})\n", value, set_where)
}
pub fn status_text_generation_entry_sender_context(
value: impl std::fmt::Display,
set_where: &str,
) -> String {
format!("- 👤 Sender context mode: `{}` ({})\n", value, set_where)
}
pub fn status_text_generation_entry_prompt(value: &str, set_where: &str) -> String {
let value = value.trim();

View File

@@ -132,6 +132,18 @@ pub fn text_generation_context_management_intro() -> String {
)
}
pub fn text_generation_sender_context_heading() -> &'static str {
"👤 Sender Context Mode"
}
pub fn text_generation_sender_context_intro() -> String {
format!(
"{}\n{}",
"Controls whether the bot attaches sender information to conversation messages before sending them to the model.",
"`disabled` leaves messages unchanged, `matrix_user_id` adds `[sender=@alice:example.com]`, and `matrix_user_id_and_timestamp` adds `[sender=@alice:example.com sent_at=2026-03-23T14:30:00Z]`. Enabling this sends Matrix user IDs, and optionally timestamps, to the model provider.",
)
}
pub fn text_generation_prompt_override_heading() -> &'static str {
"⌨️ Prompt Override"
}