mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 04:55:51 +00:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67d314b089 | |||
| 22eddbc474 | |||
| e7f1f73e27 | |||
| cd93856561 | |||
| 3c907ff0fd | |||
| 85d94f2f74 | |||
| 69dedc4eeb | |||
| c10be159c3 | |||
| 9face81ef3 | |||
| 10b3cbda3e | |||
| b6ad1ed4be | |||
| 5c5a719186 | |||
| 3d594e77f5 | |||
| ac8f324a3b | |||
| 53e82d2e13 | |||
| bbbe1a96d7 | |||
| a94b3a4ce2 | |||
| 30a62ab478 | |||
| d3fbc19473 | |||
| 11f0083074 | |||
| 89b74fd7eb | |||
| 68aaa2f6f2 | |||
| 906e385e8a | |||
| 91d8025421 | |||
| 3e261ca2c9 | |||
| 2746a4b454 | |||
| a15ef2e19d | |||
| 7bceba5c19 | |||
| 984815cfc6 | |||
| b873f26a8c | |||
| 294400383d | |||
| 9b93c47415 | |||
| f341507fa9 | |||
| eb31b7d8b9 | |||
| 97df98b269 | |||
| cdb0280cd6 | |||
| 45dfc4cf87 | |||
| 6461f4053c | |||
| fb5566cdbc | |||
| e444a886ff | |||
| aa7bc81fbc | |||
| ced1d5dec5 | |||
| 4b72bcfe99 | |||
| 6f07089ea7 | |||
| 5770ba67a8 | |||
| b684305667 | |||
| 8ad6c2180d | |||
| b8ed49ce5b | |||
| 2df87284cb | |||
| 3f7f50edef | |||
| dcff321290 | |||
| b28e3672f6 | |||
| 866af8d2f0 | |||
| f362dcb5db | |||
| 3c43d55c69 | |||
| fa17f00d56 | |||
| c9c57bb7ba | |||
| d6ca168199 | |||
| ae88d7535f | |||
| 66370675ab | |||
| 457b4638c1 | |||
| edf058e325 | |||
| c740b13021 | |||
| f9e7ca5b68 | |||
| 542197d8ab | |||
| 507f251ac5 | |||
| d3cc667c97 | |||
| 346d5be27c | |||
| 43c91caf6a | |||
| 602e768419 | |||
| 87966afec8 | |||
| 8b59a71f29 | |||
| 097987a262 | |||
| 455c25ed1b | |||
| 6c8bcf0c8a | |||
| 32cf754ae3 | |||
| 8ee5f1c806 | |||
| 536290973b | |||
| 46b379f446 | |||
| 97708c3fbb | |||
| 5872468c17 | |||
| bc9a7cfab8 | |||
| d62843b90b | |||
| 9f1ab92242 | |||
| 73b58d5bba | |||
| 729393ca1b | |||
| 3335072bdb | |||
| 01278efdde |
@@ -111,7 +111,7 @@ First check the repo root for `.env`:
|
||||
Do not start the standalone e2e server as the product under test.
|
||||
|
||||
Use `scripts/init-dev-env.sh`. It follows the e2e setup pattern — Postgres,
|
||||
migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
|
||||
Redis, migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
|
||||
skill and starts the repo's dev server (`pnpm run dev:next` / `bun run dev`),
|
||||
not `e2e/scripts/setup.ts --start`. The script hard-blocks when root `.env`
|
||||
exists, so it cannot accidentally override a user's local config. When `.env`
|
||||
@@ -132,19 +132,19 @@ fi
|
||||
Bootstrap flow when no `.env` exists:
|
||||
|
||||
```bash
|
||||
# From repo root. Managed DB flow requires Docker Desktop.
|
||||
# From repo root. Managed Postgres/Redis flow requires Docker Desktop.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
```
|
||||
|
||||
If using an existing Postgres instead of the managed Docker DB, set
|
||||
`DATABASE_URL` and skip `setup-db`:
|
||||
`DATABASE_URL` and `REDIS_URL`, then skip `setup-db`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
|
||||
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
|
||||
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
```
|
||||
|
||||
For backend-only checks, `dev-next` is available, but Web smoke needs the
|
||||
@@ -170,6 +170,9 @@ Default script env:
|
||||
- `APP_URL=http://localhost:3010`
|
||||
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
|
||||
- `DATABASE_DRIVER=node`
|
||||
- `AGENT_RUNTIME_MODE=queue` so backend-only agent runtime checks use the
|
||||
same queued execution path as production
|
||||
- `REDIS_URL=redis://localhost:6380` for queue-mode agent runtime state
|
||||
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
|
||||
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
|
||||
run `init-dev-env.sh qstash` in a separate terminal when the path under test
|
||||
@@ -177,6 +180,7 @@ Default script env:
|
||||
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
|
||||
- S3 mock vars
|
||||
- Managed DB container: `lobehub-agent-testing-postgres`
|
||||
- Managed Redis container: `lobehub-agent-testing-redis`
|
||||
|
||||
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
|
||||
onboarding already completed, plus a local API key in
|
||||
|
||||
@@ -48,14 +48,15 @@ curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
|
||||
```bash
|
||||
# Start backend only.
|
||||
# With root .env: use the existing local config.
|
||||
pnpm run dev:next
|
||||
# Agent runtime queue mode is required to mirror production async execution.
|
||||
AGENT_RUNTIME_MODE=queue pnpm run dev:next
|
||||
|
||||
# Without root .env: use the self-contained agent-testing env.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
|
||||
|
||||
# Full-stack SPA + backend. Required for Web smoke.
|
||||
# With root .env:
|
||||
bun run dev
|
||||
AGENT_RUNTIME_MODE=queue bun run dev
|
||||
|
||||
# Without root .env:
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
@@ -91,6 +92,8 @@ in doubt.
|
||||
| `ECONNREFUSED` | Server not running — start it |
|
||||
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
|
||||
| Stale data / old behavior | Server needs a restart to pick up code changes |
|
||||
| Agent call runs inline | Set `AGENT_RUNTIME_MODE=queue`, make sure `REDIS_URL` is configured, then restart the server |
|
||||
| Queue mode needs Redis | Run `init-dev-env.sh setup-db`, or provide `REDIS_URL=redis://...` for an existing Redis |
|
||||
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
|
||||
|
||||
Marketplace/community endpoints are not part of the local agent-testing auth
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
# Usage:
|
||||
# init-dev-env.sh env # print shell exports
|
||||
# init-dev-env.sh write [file] # write a source-able env file
|
||||
# init-dev-env.sh setup-db # start local Postgres and run migrations
|
||||
# init-dev-env.sh setup-db # start local Postgres/Redis and run migrations
|
||||
# init-dev-env.sh migrate # run DB migrations against the configured DB
|
||||
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
|
||||
# init-dev-env.sh qstash # run local Upstash QStash dev server
|
||||
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
|
||||
# init-dev-env.sh dev # exec `bun run dev` with this env
|
||||
# init-dev-env.sh clean-db # remove the managed Postgres container
|
||||
# init-dev-env.sh clean-db # remove the managed Postgres/Redis containers
|
||||
#
|
||||
# Overrides:
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres REDIS_PORT=6380 REDIS_CONTAINER=lobehub-agent-testing-redis QSTASH_DEV_PORT=8080
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -32,6 +32,9 @@ SERVER_PORT="${SERVER_PORT:-3010}"
|
||||
DB_PORT="${DB_PORT:-5433}"
|
||||
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
|
||||
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
|
||||
REDIS_PORT="${REDIS_PORT:-6380}"
|
||||
REDIS_CONTAINER="${REDIS_CONTAINER:-lobehub-agent-testing-redis}"
|
||||
REDIS_URL="${REDIS_URL:-redis://localhost:${REDIS_PORT}}"
|
||||
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
|
||||
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
|
||||
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
|
||||
@@ -54,6 +57,7 @@ guard_no_root_env() {
|
||||
}
|
||||
|
||||
apply_env() {
|
||||
export AGENT_RUNTIME_MODE="${AGENT_RUNTIME_MODE:-queue}"
|
||||
export APP_URL="${APP_URL:-http://localhost:${SERVER_PORT}}"
|
||||
export AUTH_EMAIL_VERIFICATION="${AUTH_EMAIL_VERIFICATION:-0}"
|
||||
export AUTH_SECRET="${AUTH_SECRET:-agent-testing-local-auth-secret-32chars}"
|
||||
@@ -69,6 +73,7 @@ apply_env() {
|
||||
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
|
||||
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
|
||||
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
|
||||
export REDIS_URL
|
||||
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
|
||||
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
|
||||
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
|
||||
@@ -78,6 +83,7 @@ apply_env() {
|
||||
env_keys() {
|
||||
printf '%s\n' \
|
||||
APP_URL \
|
||||
AGENT_RUNTIME_MODE \
|
||||
AUTH_EMAIL_VERIFICATION \
|
||||
AUTH_SECRET \
|
||||
DATABASE_DRIVER \
|
||||
@@ -92,6 +98,7 @@ env_keys() {
|
||||
QSTASH_NEXT_SIGNING_KEY \
|
||||
QSTASH_TOKEN \
|
||||
QSTASH_URL \
|
||||
REDIS_URL \
|
||||
S3_ACCESS_KEY_ID \
|
||||
S3_BUCKET \
|
||||
S3_ENDPOINT \
|
||||
@@ -137,6 +144,15 @@ wait_for_db() {
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
wait_for_redis() {
|
||||
printf ' waiting for Redis'
|
||||
until docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; do
|
||||
printf '.'
|
||||
sleep 1
|
||||
done
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
start_db() {
|
||||
require_docker
|
||||
|
||||
@@ -157,6 +173,25 @@ start_db() {
|
||||
wait_for_db
|
||||
}
|
||||
|
||||
start_redis() {
|
||||
require_docker
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
ok "Redis container already running: $REDIS_CONTAINER"
|
||||
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
docker start "$REDIS_CONTAINER" > /dev/null
|
||||
ok "started existing Redis container: $REDIS_CONTAINER"
|
||||
else
|
||||
docker run -d \
|
||||
--name "$REDIS_CONTAINER" \
|
||||
-p "${REDIS_PORT}:6379" \
|
||||
redis:7-alpine > /dev/null
|
||||
ok "created Redis container: $REDIS_CONTAINER"
|
||||
fi
|
||||
|
||||
wait_for_redis
|
||||
}
|
||||
|
||||
migrate_db() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
@@ -327,9 +362,11 @@ cmd_status() {
|
||||
apply_env
|
||||
echo "agent-testing local dev env:"
|
||||
note "APP_URL=$APP_URL"
|
||||
note "AGENT_RUNTIME_MODE=$AGENT_RUNTIME_MODE"
|
||||
note "DATABASE_URL=$DATABASE_URL"
|
||||
note "PORT=$PORT"
|
||||
note "QSTASH_URL=$QSTASH_URL"
|
||||
note "REDIS_URL=$REDIS_URL"
|
||||
if command -v docker > /dev/null 2>&1; then
|
||||
ok "docker CLI available"
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
|
||||
@@ -337,6 +374,11 @@ cmd_status() {
|
||||
else
|
||||
note "managed Postgres is not running: $DB_CONTAINER"
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
ok "managed Redis running: $REDIS_CONTAINER"
|
||||
else
|
||||
note "managed Redis is not running: $REDIS_CONTAINER"
|
||||
fi
|
||||
else
|
||||
bad "docker CLI is not available"
|
||||
fi
|
||||
@@ -373,6 +415,15 @@ cmd_clean_db() {
|
||||
else
|
||||
note "Postgres container not found: $DB_CONTAINER"
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
docker stop "$REDIS_CONTAINER" > /dev/null
|
||||
fi
|
||||
if docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
docker rm "$REDIS_CONTAINER" > /dev/null
|
||||
ok "removed Redis container: $REDIS_CONTAINER"
|
||||
else
|
||||
note "Redis container not found: $REDIS_CONTAINER"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
@@ -391,6 +442,7 @@ case "$COMMAND" in
|
||||
write) shift; write_env "${1:-}" ;;
|
||||
setup-db)
|
||||
start_db
|
||||
start_redis
|
||||
migrate_db
|
||||
;;
|
||||
migrate) migrate_db ;;
|
||||
|
||||
@@ -53,6 +53,12 @@ For Modal specifically, see the dedicated **modal** skill — use the imperative
|
||||
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
|
||||
| Navigation | Burger, Menu, SideNav, Tabs |
|
||||
|
||||
## Loading indicators
|
||||
|
||||
**Do NOT use antd `Spin` / `<Spin />`.** Use a project loader
|
||||
(`NeuralNetworkLoading`, `DotsLoading`, …) — see the **ux** skill ("Loading
|
||||
visuals") for the component table and when to use each.
|
||||
|
||||
## State
|
||||
|
||||
When a feature component manages more than 3 pieces of state (`useState`/`useReducer`/derived state), extract the logic into a custom hook (e.g. `useXxx`). Keep the component focused on rendering — the hook holds state and handlers, so logic can be unit-tested without rendering the component.
|
||||
@@ -112,6 +118,7 @@ errorElement: <ErrorBoundary />;
|
||||
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
|
||||
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
|
||||
| antd `Spin` / `<Spin />` for loading | Use `NeuralNetworkLoading` / project loaders (see the **ux** skill) |
|
||||
| `import { Select } from '@lobehub/ui'` | `import { Select } from '@lobehub/ui/base-ui'` |
|
||||
| `import { Modal } from '@lobehub/ui'` + `<Modal open>` declarative | `createModal` / `confirmModal` from `@lobehub/ui/base-ui` (see modal skill) |
|
||||
| `import { DropdownMenu/Popover/Switch } from '@lobehub/ui'` | Import same name from `@lobehub/ui/base-ui` instead |
|
||||
|
||||
@@ -43,6 +43,9 @@ cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only'
|
||||
2. **Tests must pass type check** - Run `bun run type-check` after writing tests
|
||||
3. **After 1-2 failed fix attempts, stop and ask for help**
|
||||
4. **Test behavior, not implementation details**
|
||||
5. **Regression tests for bug fixes** - After fixing a bug, add a regression test that fails before the fix and passes after, to prevent recurrence
|
||||
6. **No new component tests** - Only update existing React component tests. Complex logic should be extracted into hooks and tested there instead
|
||||
7. **All source changes before any test changes** - Complete all source file edits first, then update tests in a separate pass. Interleaving disrupts reasoning about the source changes, especially across many files
|
||||
|
||||
## Basic Test Structure
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
---
|
||||
name: ux
|
||||
description: 'LobeHub product design values / principles / checklists. Load this skill whenever the work touches user-interface features or implementation — designing or building any user-facing flow — to get better UX results.'
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# UX — Design Values & Execution Checklists
|
||||
|
||||
How LobeHub products should feel, and concrete rules to get there. Use this when
|
||||
**building or reviewing** any user-facing flow. For component/styling choices see
|
||||
**react**, for wording see **microcopy**, for imperative modal wiring see **modal**.
|
||||
|
||||
## Design values
|
||||
|
||||
LobeHub follows four product design values — **Natural・Meaningful・Certainty・
|
||||
Growth**. Read them before designing:
|
||||
**[references/design-values.md](references/design-values.md)** (definitions +
|
||||
conflict priority).
|
||||
|
||||
> The checklists below are the execution layer. Each item is tagged with the
|
||||
> value(s) it serves; for what those values mean, see the file above.
|
||||
|
||||
## How this is organized
|
||||
|
||||
The checklists are grouped by **interaction type** — the kind of thing the user
|
||||
is doing. Jump to the module that matches the surface you're building (reading a
|
||||
list, editing content, running an action, …); each module collects the rules
|
||||
specific to that interaction. The same surface often spans several modules (an
|
||||
editable list is Read + Edit + Act) — walk each that applies.
|
||||
|
||||
---
|
||||
|
||||
## 1. Read — viewing data & lists
|
||||
|
||||
Any surface that **displays** records, lists, or detail. Covers the states a data
|
||||
view can be in, behavior at scale, and keeping the user's place visible.
|
||||
|
||||
### 1.1 Data states: empty / loading / error・Meaningful・Certainty
|
||||
|
||||
Every data surface has **four** states — design all of them, not just "has data".
|
||||
|
||||
- [ ] **Empty state is a purpose-built page, not a blank screen.** It explains what
|
||||
this is, why it's empty, and gives a clear next action (CTA + value props).
|
||||
✅ Devices: an empty "Connect your first device" page with primary/secondary
|
||||
connect paths and "what you can do once connected" cards — ❌ not a bare title
|
||||
over skeleton rows or a blank body. _(Meaningful)_
|
||||
- [ ] **Distinguish the empty variants** — "no data yet" (onboarding CTA) vs
|
||||
"no match for filters" (clear-filters affordance) are different screens. _(Certainty)_
|
||||
- [ ] **Loading state** designed (skeleton / NeuralNetworkLoading), not a flash of
|
||||
blank or layout shift. _(Natural)_
|
||||
- [ ] **Error state** designed — surface the reason and a retry/back path. _(Meaningful)_
|
||||
|
||||
### 1.2 Lists at scale・Certainty・Natural
|
||||
|
||||
A list/data page must be designed for its **whole range of sizes**, not just the
|
||||
demo data.
|
||||
|
||||
- [ ] **Walk the scale: 1 / 2 / 5 / 20 / 100 / 1k–10k rows.** Pick the right
|
||||
mechanism per range — plain render → load-more / pagination → virtual scroll;
|
||||
add batch-select / bulk actions once counts get large. _(Certainty)_
|
||||
- [ ] **Co-design empty / loading / error with the data state** (see §1.1). A list
|
||||
isn't done until all four render well. _(Natural)_
|
||||
|
||||
### 1.3 Selection visibility in scrolled lists・Certainty・Natural
|
||||
|
||||
A capped / scrollable / virtualized list mounts at `scrollTop = 0`. If the
|
||||
active item sits below the fold, the user lands on a valid selection that is
|
||||
**off-screen** — and reads it as "nothing is selected" or a broken page. Any
|
||||
list that can open with a pre-selected item must **scroll that item into view**.
|
||||
This is an easy case to miss: it only shows up once the list is long enough and
|
||||
the selection is restored rather than freshly clicked.
|
||||
|
||||
- [ ] **Scroll the active item into view on mount / restore.** When the selection
|
||||
is restored from a URL query, deep link, or persisted state (not a fresh
|
||||
click), bring it into view — the container starts at the top otherwise. ✅
|
||||
The nested thread list is capped to \~9 rows; a thread restored from
|
||||
`?thread=` below the fold is scrolled into view on mount. _(Certainty)_
|
||||
- [ ] **Hardest when the selection has no other anchor.** If the parent/container
|
||||
row isn't highlighted while a child is active (no breadcrumb, no header
|
||||
echo), an off-screen active row means **zero** visible feedback — design
|
||||
for exactly this case. _(Meaningful)_
|
||||
- [ ] **Use `block: 'nearest'` (or equivalent).** Only scroll when the row is
|
||||
actually off-screen; an already-visible selection must not jump. _(Natural)_
|
||||
- [ ] **Re-run once async rows mount.** The active id is usually known before the
|
||||
list finishes loading; key the scroll off a list-ready signal (e.g. row
|
||||
count), not only off the id, so a restored selection still lands when the
|
||||
data arrives. _(Certainty)_
|
||||
- [ ] **Mirror it across duplicated list variants** so the behavior can't regress
|
||||
in just one (e.g. parallel agent / group lists). _(Certainty)_
|
||||
|
||||
### 1.4 Option visibility in pickers・Certainty・Meaningful
|
||||
|
||||
- [ ] **Pickers list every valid target.** Watch for options dropped by backend
|
||||
list queries (pagination, `virtual` flags, scope filters) and add them back.
|
||||
✅ The default "LobeAI" (inbox) agent is `virtual` and excluded from the
|
||||
sidebar list, so the move picker re-adds it. An empty picker must mean
|
||||
"genuinely none", never "we filtered out the only option". _(Meaningful)_
|
||||
|
||||
---
|
||||
|
||||
## 2. Edit — entering & changing content
|
||||
|
||||
Any surface where the user **types or edits**. Input is expensive effort; the
|
||||
overriding rule is **never lose it**.
|
||||
|
||||
### 2.1 Protect in-progress edits・Certainty・Meaningful
|
||||
|
||||
Typed / edited content is real user effort; losing it is one of the most
|
||||
infuriating outcomes a product can produce. Whenever an editor holds unsaved
|
||||
input, assume the exit can be **accidental** — a misclick, a refresh, a crash, a
|
||||
navigation, a failed save — and build a safety net: back the draft up locally and
|
||||
recover it.
|
||||
|
||||
- [ ] **Back up the draft locally as the user types.** Persist to
|
||||
localStorage / IndexedDB / store so a refresh, crash, accidental close, or
|
||||
navigation doesn't vaporize the content. _(Certainty)_
|
||||
- [ ] **Restore on return.** Coming back to the same editing context auto-restores
|
||||
(or offers to restore) the unsaved draft, rather than showing a blank field. _(Meaningful)_
|
||||
- [ ] **Guard destructive exits.** Closing / navigating / switching items away
|
||||
from a dirty editor warns or auto-saves — never silently discards. _(Certainty)_
|
||||
- [ ] **Survive a failed save.** If the save errors, keep the user's content in
|
||||
the field / draft and let them retry; never clear the input on failure. _(Meaningful)_
|
||||
- [ ] **Scope the draft to its target** (per topic / message / item id) so drafts
|
||||
don't bleed across entities or resurrect on the wrong item. _(Certainty)_
|
||||
|
||||
---
|
||||
|
||||
## 3. Act — operations, flows & buttons
|
||||
|
||||
Any surface where the user **performs an action** — a single op, a bulk op, or a
|
||||
multi-step flow. Covers momentum, focus, and full entity lifecycle.
|
||||
|
||||
### 3.1 Flow & momentum・Natural・Meaningful
|
||||
|
||||
Every action chain must **push the user forward**, never dead-end or block the flow.
|
||||
|
||||
- [ ] **Forward momentum** — after any operation, lead the user to the next step,
|
||||
don't just stop. _(Meaningful)_
|
||||
- [ ] **Success state = primary "go to result", secondary "dismiss"** — the strong
|
||||
button is the forward action (take me to the result); "Done" is the weak/
|
||||
secondary button. ✅ After moving topics: primary = "Go to «target»", secondary
|
||||
\= "Done". _(Meaningful・Natural)_
|
||||
- [ ] **Bulk ⇄ single-item parity** — an action on a multi-select toolbar must also
|
||||
be reachable on a single item (its context menu), and vice versa. _(Certainty)_
|
||||
- [ ] **Confirm → in-progress → done, in one surface** — bulk/irreversible/async
|
||||
ops use a modal state machine: a confirm step stating exactly what happens →
|
||||
an in-progress view with **dismissal locked** → a done (or error) view in the
|
||||
same modal. Never fire-and-forget with only a toast; never leave a dead
|
||||
spinner. _(Certainty・Meaningful)_
|
||||
|
||||
### 3.2 One primary button per surface・Certainty
|
||||
|
||||
- [ ] **One primary button per surface.** The single primary CTA tells the user the
|
||||
core action; everything else is secondary/tertiary. Never a pile of primary
|
||||
buttons competing for attention. _(Certainty)_
|
||||
|
||||
### 3.3 Entity lifecycle completeness・Meaningful・Certainty
|
||||
|
||||
The recurring trap: a feature ships only the **display** of a list, but edit /
|
||||
delete / management are never built — so the user can add something and then be
|
||||
stuck with it. For every entity a user can see, design its **full lifecycle**:
|
||||
create / read / update / delete, plus state transitions (enable/disable,
|
||||
connect/disconnect, install/uninstall). A read-only list the user can't manage
|
||||
breaks the flow.
|
||||
|
||||
**The allowed operation set depends on the entity's source / ownership** — decide
|
||||
it explicitly _before_ building. Worked example, the tools/connectors list:
|
||||
|
||||
| Entity class | Add | Edit | Remove |
|
||||
| ----------------------------------- | ------- | --------- | ------------------ |
|
||||
| Official / built-in (skills, tools) | — | — | ✗ not removable |
|
||||
| Community (installed MCP) | install | configure | uninstall / remove |
|
||||
| User-custom (custom connector) | create | edit | delete |
|
||||
|
||||
- [ ] **No display-only features.** For every listed entity, enumerate CRUD +
|
||||
lifecycle ops and build the ones that apply. _(Meaningful)_
|
||||
- [ ] **Operation set per source/ownership class** — built-in may be read-only;
|
||||
anything the user _installed_ must be removable; anything the user _created_
|
||||
must be editable **and** deletable. _(Certainty)_
|
||||
- [ ] **Each item exposes its allowed ops** (hover action / context menu / detail
|
||||
page), and there's a clear entry point to add/create where applicable. _(Natural)_
|
||||
- [ ] **An intentionally-absent op is a documented decision, not an oversight**
|
||||
(e.g. official tools can't be deleted — by design). _(Certainty)_
|
||||
|
||||
---
|
||||
|
||||
## 4. Feedback — loading & system response
|
||||
|
||||
How the product **answers back** while and after the user acts — loading visuals
|
||||
and proactive guardrails.
|
||||
|
||||
### 4.1 Loading visuals・Natural
|
||||
|
||||
**Never use antd `Spin`** — it doesn't match the product's loading visual. Use a
|
||||
project loader:
|
||||
|
||||
| Need | Component |
|
||||
| --------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Default loading (in-flight) | `NeuralNetworkLoading` from `@/components/NeuralNetworkLoading` (`size` prop) |
|
||||
| Inline dots | `DotsLoading` / `BubblesLoading` from `@/components` |
|
||||
| Branded full-page | `Loading` from `@/components/Loading/BrandTextLoading` |
|
||||
| List / card placeholder | a skeleton (e.g. `SkeletonList`) |
|
||||
|
||||
When in doubt, reach for `NeuralNetworkLoading` — it's the default in-flight
|
||||
indicator (e.g. modal "in progress" states).
|
||||
|
||||
### 4.2 Capability-gated features・Certainty・Meaningful
|
||||
|
||||
A feature can be fully built and still produce a broken result when the selected
|
||||
model — or its still-loading config — **can't deliver the capability the feature
|
||||
depends on** (for example, an agentic run on a model without tool calling). This
|
||||
is usually the user's configuration choice, not a defect; but if the product stays
|
||||
silent the user reads it as the product being broken. When a feature's success
|
||||
depends on a capability the current config may lack, the product owes a
|
||||
**proactive, non-blocking reminder** — a guardrail, not a gate.
|
||||
|
||||
- [ ] **Surface the mismatch, don't fail silently.** When a feature needs a model
|
||||
capability (tool calling, vision, reasoning, long context) the current model
|
||||
lacks, show a soft inline warning at the point of action — never a hard block
|
||||
or a modal that stops the user. _(Meaningful)_
|
||||
- [ ] **Stay reactive.** The reminder clears the moment the user switches to a
|
||||
capable model — derive it from live state, not a one-shot check. _(Natural)_
|
||||
- [ ] **Don't warn while config is loading.** A capability that hasn't resolved yet
|
||||
looks "unsupported"; warning then is a false alarm — exactly the glitch users
|
||||
mistake for a product bug. Warn only on a _resolved_ unsupported state. _(Certainty)_
|
||||
- [ ] **Scope to the mode that needs it.** Show only when the capability-dependent
|
||||
mode is on; one reminder per root cause, never a pile of overlapping notices. _(Natural・Certainty)_
|
||||
- [ ] **State the problem and the remedy.** The copy says what's wrong _and_ what
|
||||
the user should do about it. _(Meaningful)_
|
||||
|
||||
---
|
||||
|
||||
## 5. Grow — discoverability & progressive disclosure
|
||||
|
||||
How the product **deepens** as the user's needs deepen.
|
||||
|
||||
### 5.1 Progressive disclosure・Growth
|
||||
|
||||
The product should grow with the user — deeper power shows up as needs deepen.
|
||||
|
||||
- [ ] **Progressive disclosure** — keep the novice path clean; reveal advanced
|
||||
capabilities as the user gets there, don't dump everything at once. _(Growth・Natural)_
|
||||
- [ ] **Surface related actions at the moment of need** — make the next capability
|
||||
discoverable in context (e.g. after the first item exists, offer what to do
|
||||
with it), not buried in a far-off menu. _(Growth・Meaningful)_
|
||||
|
||||
---
|
||||
|
||||
## Quick review checklist
|
||||
|
||||
**Read — viewing data & lists**
|
||||
|
||||
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA.
|
||||
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
|
||||
- [ ] Capped/scrollable/virtualized list scrolls the restored active item into view on mount (`block: 'nearest'`, re-run after async rows mount).
|
||||
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
|
||||
|
||||
**Edit — entering & changing content**
|
||||
|
||||
- [ ] Editors back up in-progress input locally and recover it after refresh/crash/failed-save; destructive exits warn, never silently discard.
|
||||
|
||||
**Act — operations, flows & buttons**
|
||||
|
||||
- [ ] Action leads the user forward; success offers a primary "go to result".
|
||||
- [ ] Bulk action has a single-item entry (and vice versa).
|
||||
- [ ] Async/bulk/irreversible action: confirm → in-progress (locked) → done/error.
|
||||
- [ ] Exactly one primary button per surface.
|
||||
- [ ] Listed entities have their full lifecycle (not display-only); ops match source (built-in / installed / custom).
|
||||
|
||||
**Feedback — loading & system response**
|
||||
|
||||
- [ ] No antd `Spin`; use `NeuralNetworkLoading` / project loaders.
|
||||
- [ ] Capability-gated feature warns (soft, reactive, load-gated) when the model can't deliver it; copy gives the remedy.
|
||||
|
||||
**Grow — discoverability & progressive disclosure**
|
||||
|
||||
- [ ] Advanced capability is progressively disclosed / discoverable at the moment of need.
|
||||
|
||||
## Related skills
|
||||
|
||||
- **modal** — imperative `createModal` state-machine wiring for confirm/progress/done.
|
||||
- **microcopy** — wording for confirm / done / empty / error states.
|
||||
- **react** — component priority, `Button` usage, styling.
|
||||
@@ -0,0 +1,51 @@
|
||||
# LobeHub Design Values (设计价值观)
|
||||
|
||||
The philosophy behind every LobeHub interface. Read this before designing or
|
||||
reviewing a flow; the per-aspect execution rules live in the parent
|
||||
[SKILL.md](../SKILL.md) and each checklist item is tagged with the value(s) it serves.
|
||||
|
||||
Adapted from Ant Design's design values
|
||||
(<https://ant.design/docs/spec/values-cn>, <https://zhuanlan.zhihu.com/p/44809866>).
|
||||
LobeHub adopts all four.
|
||||
|
||||
## 自然 (Natural)
|
||||
|
||||
Minimise cognitive load. Digital products keep getting more complex while human
|
||||
attention stays scarce — so the interface should feel as effortless as the
|
||||
physical world. The next step should be obvious without thinking; the product
|
||||
proactively carries the user forward (sensible defaults, AI-assisted decisions,
|
||||
smooth transitions) rather than making them stop and figure things out.
|
||||
|
||||
## 意义感 (Meaningful)
|
||||
|
||||
Every screen is rooted in the user's real goal, not an isolated feature. Make the
|
||||
objective clear, give immediate feedback on the result of each action, and always
|
||||
point at the next meaningful step. Calibrate difficulty — neither a patronising
|
||||
over-simplification nor an overwhelming wall — so the user keeps a sense of
|
||||
progress and accomplishment.
|
||||
|
||||
## 确定性 (Certainty)
|
||||
|
||||
Low-entropy, predictable interactions. Reuse the same patterns, components, and
|
||||
wording so behaviour is never surprising. Keep a single clear focus per surface,
|
||||
and design **every** state (empty / loading / error / success) so nothing is left
|
||||
undefined. Restraint over cleverness: fewer, consistent rules beat many bespoke
|
||||
ones.
|
||||
|
||||
## 生长性 (Growth)
|
||||
|
||||
The product grows together with the user. As needs deepen and roles evolve,
|
||||
surface advanced capabilities progressively and make related features
|
||||
discoverable at the moment they become relevant — without crowding the novice
|
||||
path. Bridge product value to the user's changing scenarios and aim for
|
||||
human–machine symbiosis (人机共生): the user and the agent co-evolve, each making
|
||||
the other more capable over time.
|
||||
|
||||
## Priority when values conflict
|
||||
|
||||
For moment-to-moment interaction decisions: **意义感 ≳ 自然 > 确定性** — never
|
||||
sacrifice the user's goal or forward momentum just to keep things uniform.
|
||||
|
||||
**生长性 (Growth)** is a longer-horizon lens: weigh it when shaping how a feature
|
||||
is discovered and how it scales with the user, not when resolving a single-screen
|
||||
layout trade-off.
|
||||
+14
-5
@@ -425,14 +425,14 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# MCP_TOOL_TIMEOUT=60000
|
||||
|
||||
# #######################################
|
||||
# ######### Klavis Service ##############
|
||||
# ######### Composio Service ############
|
||||
# #######################################
|
||||
|
||||
# Klavis API Key for accessing Strata hosted MCP servers
|
||||
# Get your API key from: https://klavis.io
|
||||
# Composio API Key for accessing hosted integrations (Gmail, Slack, etc.)
|
||||
# Get your API key from: https://composio.dev
|
||||
# IMPORTANT: This key is stored server-side only and NEVER exposed to the client
|
||||
# When this key is set, Klavis integration will be automatically enabled
|
||||
# KLAVIS_API_KEY=your_klavis_api_key_here
|
||||
# When this key is set, Composio integration will be automatically enabled
|
||||
# COMPOSIO_API_KEY=your_composio_api_key_here
|
||||
|
||||
# #######################################
|
||||
# #### Message Gateway (IM Integration) ##
|
||||
@@ -445,6 +445,15 @@ OPENAI_API_KEY=sk-xxxxxxxxx
|
||||
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
|
||||
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
|
||||
|
||||
# #######################################
|
||||
# ######### Agent Gateway Mode ##########
|
||||
# #######################################
|
||||
|
||||
# Enable Gateway Mode for self-hosted deployments. Requires AGENT_GATEWAY_URL.
|
||||
# ENABLE_AGENT_GATEWAY=1
|
||||
# AGENT_GATEWAY_URL=https://agent-gateway.example.com
|
||||
# AGENT_GATEWAY_SERVICE_TOKEN=your_service_token_here
|
||||
|
||||
# #######################################
|
||||
# ########### Messenger Bot #############
|
||||
# #######################################
|
||||
|
||||
@@ -136,3 +136,5 @@ bun run type-check
|
||||
### Code Review
|
||||
|
||||
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
|
||||
|
||||
When designing or reviewing user-facing flows (empty/loading/error states, confirmations, async feedback, button hierarchy, lists at scale, pickers), follow the **ux** skill (`.agents/skills/ux/SKILL.md`) — LobeHub's design values (自然 / 意义感 / 确定性) plus per-aspect execution checklists.
|
||||
|
||||
@@ -649,6 +649,53 @@ describe('hetero exec command', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('finishes with result "error" when a terminal error event is pushed despite a clean exit', async () => {
|
||||
// CC relays an API/rate-limit error as an in-stream `error` event but still
|
||||
// exits 0. The finish result must NOT be derived from the exit code alone,
|
||||
// otherwise the topic/task is wrongly marked completed.
|
||||
mockSpawnAgent.mockReturnValue(
|
||||
createFakeHandle({
|
||||
events: [
|
||||
{
|
||||
data: {
|
||||
error: 'API Error: Server is temporarily limiting requests · Rate limited',
|
||||
message: 'API Error: Server is temporarily limiting requests · Rate limited',
|
||||
},
|
||||
operationId: 'op-err',
|
||||
stepIndex: 0,
|
||||
timestamp: 1,
|
||||
type: 'error',
|
||||
},
|
||||
],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await runCmd([
|
||||
'hetero',
|
||||
'exec',
|
||||
'--type',
|
||||
'claude-code',
|
||||
'--prompt',
|
||||
'hi',
|
||||
'--topic',
|
||||
'topic-1',
|
||||
'--operation-id',
|
||||
'op-err',
|
||||
'--render',
|
||||
'none',
|
||||
]);
|
||||
|
||||
expect(mockHeteroFinishMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeteroFinishMutate.mock.calls[0][0]).toMatchObject({
|
||||
error: {
|
||||
message: 'API Error: Server is temporarily limiting requests · Rate limited',
|
||||
type: 'AgentRuntimeError',
|
||||
},
|
||||
result: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
it('resets the per-message text accumulator at message boundaries (no cross-message duplication)', async () => {
|
||||
// The `replace` snapshot accumulator must not span
|
||||
// message boundaries. Two assistant messages separated by a
|
||||
|
||||
@@ -467,6 +467,11 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
* sessionId — CC session id from `system.init` (undefined on resume failure)
|
||||
* ingestError — true when a batch could not be flushed after retries
|
||||
* resumeNotFound — true when a resume-not-found error was intercepted
|
||||
* sawTerminalError — true when a terminal `error` event was pushed to the
|
||||
* ingester (CC can relay an API/rate-limit error this way
|
||||
* and still exit 0, so the exit code alone is not enough)
|
||||
* terminalErrorMessage — the message from that terminal `error` event, used
|
||||
* as the task-level error detail in the finish payload
|
||||
* stderrContent — accumulated stderr (only when interceptResumeErrors=true)
|
||||
*/
|
||||
const runOneAgent = async (
|
||||
@@ -477,9 +482,11 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
code: number | null;
|
||||
ingestError: boolean;
|
||||
resumeNotFound: boolean;
|
||||
sawTerminalError: boolean;
|
||||
sessionId: string | undefined;
|
||||
signal: NodeJS.Signals | null;
|
||||
stderrContent: string;
|
||||
terminalErrorMessage: string | undefined;
|
||||
}> => {
|
||||
// One raw-dump file pair per spawn attempt (the resume retry is a second
|
||||
// attempt). The stdout tee runs inside `spawnAgent` before the adapter.
|
||||
@@ -549,6 +556,8 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
// into the ingester. When intercepting resume errors, a matching
|
||||
// `error` event is withheld from the ingester and flags a retry instead.
|
||||
let resumeNotFound = false;
|
||||
let sawTerminalError = false;
|
||||
let terminalErrorMessage: string | undefined;
|
||||
const ingestError = false;
|
||||
try {
|
||||
for await (const event of handle.events) {
|
||||
@@ -563,6 +572,16 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// A terminal `error` event (e.g. an API/rate-limit error relayed by CC)
|
||||
// must mark the run as failed even when the child exits 0 — track it so
|
||||
// the finish result is not derived from the exit code alone. Capture the
|
||||
// message too, so the finish payload can surface it as the task-level
|
||||
// error detail (CC relays these on stdout, not stderr).
|
||||
if (event.type === 'error') {
|
||||
sawTerminalError = true;
|
||||
const data = event.data as Record<string, unknown> | undefined;
|
||||
terminalErrorMessage = String(data?.message ?? data?.error ?? '') || undefined;
|
||||
}
|
||||
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
|
||||
serverIngester?.push(event);
|
||||
}
|
||||
@@ -608,9 +627,11 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
code,
|
||||
ingestError,
|
||||
resumeNotFound,
|
||||
sawTerminalError,
|
||||
sessionId: handle.sessionId,
|
||||
signal,
|
||||
stderrContent,
|
||||
terminalErrorMessage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -675,16 +696,23 @@ const exec = async (options: ExecOptions): Promise<void> => {
|
||||
result = { ...result, ingestError: true };
|
||||
}
|
||||
|
||||
const exitedClean = !result.ingestError && (code === 0 || signal === 'SIGTERM');
|
||||
// CC relays API/rate-limit errors as an in-stream terminal `error` event but
|
||||
// still exits 0, so the exit code alone would report `success`. Treat any
|
||||
// pushed terminal error as a failed run so the topic/task is marked failed.
|
||||
const exitedClean =
|
||||
!result.ingestError && !result.sawTerminalError && (code === 0 || signal === 'SIGTERM');
|
||||
|
||||
// When the run failed, pass stderr as the error detail so the server can
|
||||
// surface a useful message instead of the generic "Agent execution failed"
|
||||
// fallback. Trim to the last 1 KB — the tail is most informative and
|
||||
// keeps the tRPC payload small.
|
||||
// When the run failed, pass an error detail so the server surfaces a useful
|
||||
// message instead of the generic "Agent execution failed" fallback. Prefer
|
||||
// the in-stream terminal error (CC relays API/rate-limit errors here while
|
||||
// exiting 0, so stderr is empty); otherwise fall back to the stderr tail.
|
||||
// Trim to the last 1 KB — the tail is most informative and keeps the tRPC
|
||||
// payload small.
|
||||
const stderrTail = result.stderrContent.trim();
|
||||
const errorDetail = result.terminalErrorMessage || stderrTail;
|
||||
const finishError =
|
||||
!exitedClean && stderrTail
|
||||
? { message: stderrTail.slice(-1024), type: 'AgentRuntimeError' }
|
||||
!exitedClean && errorDetail
|
||||
? { message: errorDetail.slice(-1024), type: 'AgentRuntimeError' }
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
GitWorkingTreeFiles,
|
||||
GitWorkingTreePatches,
|
||||
GitWorkingTreeStatus,
|
||||
GitWorktreeListItem,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
checkoutGitBranch as runCheckoutGitBranch,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
gitInfo as computeGitInfo,
|
||||
listGitBranches as computeListGitBranches,
|
||||
listGitRemoteBranches as computeListGitRemoteBranches,
|
||||
listGitWorktrees as computeListGitWorktrees,
|
||||
pullGitBranch as runPullGitBranch,
|
||||
pushGitBranch as runPushGitBranch,
|
||||
renameGitBranch as runRenameGitBranch,
|
||||
@@ -83,6 +85,11 @@ export default class GitController extends ControllerModule {
|
||||
return computeListGitRemoteBranches(dirPath);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async listGitWorktrees(dirPath: string): Promise<GitWorktreeListItem[]> {
|
||||
return computeListGitWorktrees(dirPath);
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getGitWorkingTreeStatus(dirPath: string): Promise<GitWorkingTreeStatus> {
|
||||
return computeGitWorkingTreeStatus(dirPath);
|
||||
|
||||
@@ -438,12 +438,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
@IpcMethod()
|
||||
async getLocalFilePreviewUrl({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
|
||||
try {
|
||||
const url = await this.app.localFileProtocolManager.createPreviewUrl({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
@@ -462,12 +464,14 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
@IpcMethod()
|
||||
async getLocalFilePreview({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
|
||||
try {
|
||||
const preview = await this.app.localFileProtocolManager.readPreviewFile({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
|
||||
@@ -226,6 +226,7 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -262,6 +263,7 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -270,6 +272,29 @@ describe('LocalFileCtr', () => {
|
||||
url: 'localfile://file/workspace/image.png?token=abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward user-approved external preview URL access', async () => {
|
||||
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
|
||||
'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
|
||||
);
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreviewUrl({
|
||||
allowExternalFile: true,
|
||||
path: '/tmp/worktree-switcher-demo.html',
|
||||
workingDirectory: '/tmp',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
allowExternalFile: true,
|
||||
accept: undefined,
|
||||
filePath: '/tmp/worktree-switcher-demo.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
url: 'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalFilePreview', () => {
|
||||
@@ -287,6 +312,7 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -329,6 +355,7 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -341,6 +368,35 @@ describe('LocalFileCtr', () => {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward user-approved external preview reads', async () => {
|
||||
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
|
||||
buffer: Buffer.from('<h1>Demo</h1>'),
|
||||
contentType: 'text/html',
|
||||
realPath: '/tmp/worktree-switcher-demo.html',
|
||||
});
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreview({
|
||||
allowExternalFile: true,
|
||||
path: '/tmp/worktree-switcher-demo.html',
|
||||
workingDirectory: '/tmp',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
allowExternalFile: true,
|
||||
accept: undefined,
|
||||
filePath: '/tmp/worktree-switcher-demo.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
preview: {
|
||||
content: '<h1>Demo</h1>',
|
||||
contentType: 'text/html',
|
||||
type: 'text',
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWriteFile', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
|
||||
const logger = createLogger('core:LocalFileProtocolManager');
|
||||
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
const EXTERNAL_PREVIEW_APPROVAL_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const normalizeAbsolutePath = (filePath: string): string | null => {
|
||||
const normalized = path.normalize(filePath);
|
||||
@@ -59,10 +60,7 @@ type PreviewFileAccept = 'image';
|
||||
const normalizeContentType = (contentType: string): string =>
|
||||
contentType.split(';')[0].trim().toLowerCase();
|
||||
|
||||
const isAcceptedPreviewContentType = (
|
||||
contentType: string,
|
||||
accept?: PreviewFileAccept,
|
||||
): boolean => {
|
||||
const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileAccept): boolean => {
|
||||
if (!accept) return true;
|
||||
|
||||
const normalizedContentType = normalizeContentType(contentType);
|
||||
@@ -84,6 +82,8 @@ const isAcceptedPreviewContentType = (
|
||||
export class LocalFileProtocolManager {
|
||||
private readonly approvedWorkspaceRoots = new Set<string>();
|
||||
|
||||
private readonly externalPreviewApprovals = new Map<string, number>();
|
||||
|
||||
private readonly indexedProjectRoots = new Set<string>();
|
||||
|
||||
private handlerRegistered = false;
|
||||
@@ -229,10 +229,12 @@ export class LocalFileProtocolManager {
|
||||
|
||||
async createPreviewUrl({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
accept?: PreviewFileAccept;
|
||||
allowExternalFile?: boolean;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<string | null> {
|
||||
@@ -243,11 +245,12 @@ export class LocalFileProtocolManager {
|
||||
? (
|
||||
await this.readPreviewFile({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
})
|
||||
)?.realPath
|
||||
: await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
: await this.resolveApprovedPreviewPath({ allowExternalFile, filePath, workspaceRoot });
|
||||
if (!realFilePath) return null;
|
||||
|
||||
this.cleanupExpiredTokens();
|
||||
@@ -263,14 +266,21 @@ export class LocalFileProtocolManager {
|
||||
|
||||
async readPreviewFile({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
accept?: PreviewFileAccept;
|
||||
allowExternalFile?: boolean;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<PreviewFileReadResult | null> {
|
||||
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
const realFilePath = await this.resolveApprovedPreviewPath({
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
persistExternalApproval: false,
|
||||
workspaceRoot,
|
||||
});
|
||||
if (!realFilePath) return null;
|
||||
|
||||
const fileStat = await stat(realFilePath);
|
||||
@@ -280,6 +290,10 @@ export class LocalFileProtocolManager {
|
||||
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
|
||||
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
|
||||
|
||||
if (allowExternalFile) {
|
||||
this.grantExternalPreviewApproval(realFilePath);
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentType,
|
||||
@@ -327,10 +341,14 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
|
||||
private async resolveApprovedPreviewPath({
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
persistExternalApproval = true,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
allowExternalFile?: boolean;
|
||||
filePath: string;
|
||||
persistExternalApproval?: boolean;
|
||||
workspaceRoot: string;
|
||||
}): Promise<string | null> {
|
||||
const normalizedFilePath = normalizeAbsolutePath(filePath);
|
||||
@@ -345,15 +363,44 @@ export class LocalFileProtocolManager {
|
||||
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
|
||||
|
||||
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
|
||||
const workspaceRootApproved =
|
||||
this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) ||
|
||||
this.indexedProjectRoots.has(normalizedRealWorkspaceRoot);
|
||||
if (
|
||||
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
|
||||
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
|
||||
workspaceRootApproved &&
|
||||
isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)
|
||||
) {
|
||||
return null;
|
||||
return normalizedRealFilePath;
|
||||
}
|
||||
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
|
||||
|
||||
return normalizedRealFilePath;
|
||||
if (this.hasExternalPreviewApproval(normalizedRealFilePath)) return normalizedRealFilePath;
|
||||
|
||||
if (allowExternalFile) {
|
||||
return this.approveExternalPreviewFile(normalizedRealFilePath, {
|
||||
persist: persistExternalApproval,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async approveExternalPreviewFile(
|
||||
realFilePath: string,
|
||||
{ persist = true }: { persist?: boolean } = {},
|
||||
): Promise<string | null> {
|
||||
const fileStat = await stat(realFilePath);
|
||||
if (!fileStat.isFile()) return null;
|
||||
|
||||
if (persist) {
|
||||
this.grantExternalPreviewApproval(realFilePath);
|
||||
}
|
||||
|
||||
return realFilePath;
|
||||
}
|
||||
|
||||
private grantExternalPreviewApproval(realFilePath: string) {
|
||||
this.cleanupExpiredExternalPreviewApprovals();
|
||||
this.externalPreviewApprovals.set(realFilePath, Date.now() + EXTERNAL_PREVIEW_APPROVAL_TTL_MS);
|
||||
}
|
||||
|
||||
private cleanupExpiredTokens() {
|
||||
@@ -365,6 +412,15 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupExpiredExternalPreviewApprovals() {
|
||||
const now = Date.now();
|
||||
for (const [realPath, expiresAt] of this.externalPreviewApprovals) {
|
||||
if (expiresAt <= now) {
|
||||
this.externalPreviewApprovals.delete(realPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private hasPreviewToken(token: string): boolean {
|
||||
const record = this.previewTokens.get(token);
|
||||
if (!record) return false;
|
||||
@@ -383,4 +439,16 @@ export class LocalFileProtocolManager {
|
||||
|
||||
return record.realPath === realResolvedPath;
|
||||
}
|
||||
|
||||
private hasExternalPreviewApproval(realFilePath: string): boolean {
|
||||
const expiresAt = this.externalPreviewApprovals.get(realFilePath);
|
||||
if (!expiresAt) return false;
|
||||
|
||||
if (expiresAt <= Date.now()) {
|
||||
this.externalPreviewApprovals.delete(realFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +263,31 @@ describe('LocalFileProtocolManager', () => {
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('mints preview URLs for user-approved external files only', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
allowExternalFile: true,
|
||||
filePath: '/tmp/worktree-switcher-demo.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
if (!url) throw new Error('Expected external local file preview URL');
|
||||
|
||||
expect(url).toContain('token=');
|
||||
|
||||
const repeatedUrl = await manager.createPreviewUrl({
|
||||
filePath: '/tmp/worktree-switcher-demo.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(repeatedUrl).toContain('token=');
|
||||
|
||||
const neighborUrl = await manager.createPreviewUrl({
|
||||
filePath: '/tmp/other.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(neighborUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('can approve a project root derived from an already approved nested scope', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
|
||||
@@ -326,6 +351,26 @@ describe('LocalFileProtocolManager', () => {
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/.env');
|
||||
});
|
||||
|
||||
it('does not keep external approval when an image-only external preview rejects text', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
mockReadFile.mockResolvedValue(Buffer.from('SECRET=value'));
|
||||
|
||||
const result = await manager.readPreviewFile({
|
||||
accept: 'image',
|
||||
allowExternalFile: true,
|
||||
filePath: '/tmp/secret.txt',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
|
||||
const repeatedUrl = await manager.createPreviewUrl({
|
||||
filePath: '/tmp/secret.txt',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(repeatedUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('does not read preview payloads outside the approved workspace root', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveIndexedProjectRoot('/Users/alice/project');
|
||||
|
||||
@@ -16,6 +16,12 @@ import type { App } from '../App';
|
||||
// Create logger
|
||||
const logger = createLogger('core:Tray');
|
||||
|
||||
// Debounce window for distinguishing a single-click from the leading edge of
|
||||
// a double-click. Electron delivers two `click` events before `double-click`,
|
||||
// so we defer the single-click action until this window passes — the
|
||||
// `double-click` handler clears it if it arrives in time.
|
||||
const CLICK_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface TrayOptions {
|
||||
/**
|
||||
* Tray icon path (relative to resource directory)
|
||||
@@ -54,6 +60,12 @@ export class Tray {
|
||||
*/
|
||||
private _contextMenu?: ElectronMenu;
|
||||
|
||||
/**
|
||||
* Pending single-click timer. Cleared by the double-click handler so a
|
||||
* double-click never accidentally fires startSession before showMainWindow.
|
||||
*/
|
||||
private _clickTimer?: NodeJS.Timeout;
|
||||
|
||||
/**
|
||||
* Identifier
|
||||
*/
|
||||
@@ -118,10 +130,25 @@ export class Tray {
|
||||
// Set default context menu
|
||||
this.setContextMenu();
|
||||
|
||||
// Left-click: open Quick Composer.
|
||||
// Left-click: deferred so a follow-up `double-click` can pre-empt it.
|
||||
this._tray.on('click', () => {
|
||||
logger.debug(`[${this.identifier}] Tray clicked`);
|
||||
this.onClick();
|
||||
if (this._clickTimer) clearTimeout(this._clickTimer);
|
||||
this._clickTimer = setTimeout(() => {
|
||||
this._clickTimer = undefined;
|
||||
this.onClick();
|
||||
}, CLICK_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
// Double-click (macOS / Windows): cancel the pending single-click and
|
||||
// surface the main window instead.
|
||||
this._tray.on('double-click', () => {
|
||||
logger.debug(`[${this.identifier}] Tray double-clicked`);
|
||||
if (this._clickTimer) {
|
||||
clearTimeout(this._clickTimer);
|
||||
this._clickTimer = undefined;
|
||||
}
|
||||
this.onDoubleClick();
|
||||
});
|
||||
|
||||
// Right-click: pop the stored context menu manually so left-click stays
|
||||
@@ -189,6 +216,18 @@ export class Tray {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tray double-click event — surfaces the main window.
|
||||
*/
|
||||
onDoubleClick() {
|
||||
logger.debug(`[${this.identifier}] Tray double-click → showMainWindow`);
|
||||
try {
|
||||
this.app.browserManager.showMainWindow();
|
||||
} catch (error) {
|
||||
logger.error(`[${this.identifier}] Failed to show main window:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the tray context menu with a pre-built Electron Menu instance.
|
||||
* Stored in-house and popped up manually on right-click to preserve
|
||||
@@ -259,6 +298,10 @@ export class Tray {
|
||||
*/
|
||||
destroy() {
|
||||
logger.debug(`Destroying tray instance: ${this.identifier}`);
|
||||
if (this._clickTimer) {
|
||||
clearTimeout(this._clickTimer);
|
||||
this._clickTimer = undefined;
|
||||
}
|
||||
if (this._tray) {
|
||||
this._tray.destroy();
|
||||
this._tray = undefined;
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('Tray', () => {
|
||||
expect(mockElectronTray.setContextMenu).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register both click and right-click listeners', () => {
|
||||
it('should register click, double-click and right-click listeners', () => {
|
||||
tray = new Tray(
|
||||
{
|
||||
iconPath: 'tray.png',
|
||||
@@ -200,6 +200,7 @@ describe('Tray', () => {
|
||||
|
||||
const events = mockElectronTray.on.mock.calls.map((c: any[]) => c[0]);
|
||||
expect(events).toContain('click');
|
||||
expect(events).toContain('double-click');
|
||||
expect(events).toContain('right-click');
|
||||
});
|
||||
|
||||
@@ -346,6 +347,96 @@ describe('Tray', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onDoubleClick', () => {
|
||||
beforeEach(() => {
|
||||
tray = new Tray(
|
||||
{
|
||||
iconPath: 'tray.png',
|
||||
identifier: 'test-tray',
|
||||
},
|
||||
mockApp,
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the main window', () => {
|
||||
tray.onDoubleClick();
|
||||
|
||||
expect(mockApp.browserManager.showMainWindow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not start the capture session', () => {
|
||||
tray.onDoubleClick();
|
||||
|
||||
expect(mockApp.screenCaptureManager.startSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw when showMainWindow throws', () => {
|
||||
vi.mocked(mockApp.browserManager.showMainWindow).mockImplementationOnce(() => {
|
||||
throw new Error('window failed');
|
||||
});
|
||||
|
||||
expect(() => tray.onDoubleClick()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('click vs double-click handling', () => {
|
||||
let clickHandler: (() => void) | undefined;
|
||||
let doubleClickHandler: (() => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
tray = new Tray(
|
||||
{
|
||||
iconPath: 'tray.png',
|
||||
identifier: 'test-tray',
|
||||
},
|
||||
mockApp,
|
||||
);
|
||||
|
||||
clickHandler = mockElectronTray.on.mock.calls.find((c: any[]) => c[0] === 'click')?.[1];
|
||||
doubleClickHandler = mockElectronTray.on.mock.calls.find(
|
||||
(c: any[]) => c[0] === 'double-click',
|
||||
)?.[1];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should debounce single click before calling startSession', () => {
|
||||
expect(clickHandler).toBeDefined();
|
||||
|
||||
clickHandler?.();
|
||||
expect(mockApp.screenCaptureManager.startSession).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should cancel the pending single click when double-click fires', () => {
|
||||
expect(clickHandler).toBeDefined();
|
||||
expect(doubleClickHandler).toBeDefined();
|
||||
|
||||
clickHandler?.();
|
||||
clickHandler?.();
|
||||
doubleClickHandler?.();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(mockApp.screenCaptureManager.startSession).not.toHaveBeenCalled();
|
||||
expect(mockApp.browserManager.showMainWindow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should only fire startSession once per single-click burst', () => {
|
||||
clickHandler?.();
|
||||
clickHandler?.();
|
||||
|
||||
vi.advanceTimersByTime(250);
|
||||
|
||||
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateIcon', () => {
|
||||
beforeEach(() => {
|
||||
tray = new Tray(
|
||||
|
||||
@@ -15,13 +15,21 @@ const mocks = vi.hoisted(() => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const mockGlobalConfigDependencies = (enableBusinessFeatures: boolean) => {
|
||||
interface MockGlobalConfigOptions {
|
||||
agentGatewayUrl?: string;
|
||||
enableAgentGateway?: boolean;
|
||||
}
|
||||
|
||||
const mockGlobalConfigDependencies = (
|
||||
enableBusinessFeatures: boolean,
|
||||
options: MockGlobalConfigOptions = {},
|
||||
) => {
|
||||
vi.doMock('@lobechat/business-const', () => ({
|
||||
ENABLE_BUSINESS_FEATURES: enableBusinessFeatures,
|
||||
}));
|
||||
|
||||
vi.doMock('@/config/klavis', () => ({
|
||||
klavisEnv: {},
|
||||
vi.doMock('@/config/composio', () => ({
|
||||
composioEnv: {},
|
||||
}));
|
||||
|
||||
vi.doMock('@/const/version', () => ({
|
||||
@@ -29,7 +37,12 @@ const mockGlobalConfigDependencies = (enableBusinessFeatures: boolean) => {
|
||||
}));
|
||||
|
||||
vi.doMock('@/envs/app', () => ({
|
||||
appEnv: {},
|
||||
appEnv: {
|
||||
...(options.agentGatewayUrl ? { AGENT_GATEWAY_URL: options.agentGatewayUrl } : {}),
|
||||
...(options.enableAgentGateway === undefined
|
||||
? {}
|
||||
: { ENABLE_AGENT_GATEWAY: options.enableAgentGateway }),
|
||||
},
|
||||
getAppConfig: vi.fn(() => ({
|
||||
DEFAULT_AGENT_CONFIG: '',
|
||||
})),
|
||||
@@ -113,6 +126,18 @@ const loadCapturedProviderConfig = async (enableBusinessFeatures: boolean) => {
|
||||
>;
|
||||
};
|
||||
|
||||
const loadServerConfig = async (
|
||||
enableBusinessFeatures: boolean,
|
||||
options?: MockGlobalConfigOptions,
|
||||
) => {
|
||||
vi.resetModules();
|
||||
mocks.genServerAiProvidersConfig.mockClear();
|
||||
mockGlobalConfigDependencies(enableBusinessFeatures, options);
|
||||
|
||||
const { getServerGlobalConfig } = await import('./index');
|
||||
return getServerGlobalConfig();
|
||||
};
|
||||
|
||||
describe('getServerGlobalConfig', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -139,4 +164,36 @@ describe('getServerGlobalConfig', () => {
|
||||
expect(providerConfig[ModelProvider.OpenAI]).toBeUndefined();
|
||||
expect(providerConfig[ModelProvider.DeepSeek].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable gateway mode for business builds', async () => {
|
||||
await expect(loadServerConfig(true)).resolves.toMatchObject({
|
||||
enableGatewayMode: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable gateway mode for self-hosted builds only when explicitly enabled with a gateway url', async () => {
|
||||
await expect(
|
||||
loadServerConfig(false, {
|
||||
agentGatewayUrl: 'https://gateway.test.com',
|
||||
enableAgentGateway: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
agentGatewayUrl: 'https://gateway.test.com',
|
||||
enableGatewayMode: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadServerConfig(false, {
|
||||
agentGatewayUrl: 'https://gateway.test.com',
|
||||
enableAgentGateway: false,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
agentGatewayUrl: 'https://gateway.test.com',
|
||||
enableGatewayMode: false,
|
||||
});
|
||||
|
||||
await expect(loadServerConfig(false, { enableAgentGateway: true })).resolves.toMatchObject({
|
||||
enableGatewayMode: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
|
||||
import { ModelProvider } from 'model-bank';
|
||||
|
||||
import { klavisEnv } from '@/config/klavis';
|
||||
import { composioEnv } from '@/config/composio';
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { appEnv, getAppConfig } from '@/envs/app';
|
||||
import { authEnv } from '@/envs/auth';
|
||||
@@ -104,7 +104,9 @@ export const getServerGlobalConfig = async () => {
|
||||
disableEmailPassword: authEnv.AUTH_DISABLE_EMAIL_PASSWORD,
|
||||
enableBusinessFeatures: ENABLE_BUSINESS_FEATURES,
|
||||
enableEmailVerification: authEnv.AUTH_EMAIL_VERIFICATION,
|
||||
enableKlavis: !!klavisEnv.KLAVIS_API_KEY,
|
||||
enableComposio: !!composioEnv.COMPOSIO_API_KEY,
|
||||
enableGatewayMode:
|
||||
ENABLE_BUSINESS_FEATURES || (!!appEnv.ENABLE_AGENT_GATEWAY && !!appEnv.AGENT_GATEWAY_URL),
|
||||
enableLobehubSkill: !!(appEnv.MARKET_TRUSTED_CLIENT_SECRET && appEnv.MARKET_TRUSTED_CLIENT_ID),
|
||||
enableMagicLink: authEnv.AUTH_ENABLE_MAGIC_LINK,
|
||||
enableMarketTrustedClient: !!(
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
} from '@lobechat/agent-runtime';
|
||||
import { LobeActivatorIdentifier } from '@lobechat/builtin-tool-activator';
|
||||
import {
|
||||
type ComposioServiceSummary,
|
||||
type CredSummary,
|
||||
generateComposioServicesList,
|
||||
generateCredsList,
|
||||
generateKlavisServicesList,
|
||||
type KlavisServiceSummary,
|
||||
} from '@lobechat/builtin-tool-creds';
|
||||
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
|
||||
import { BRANDING_PROVIDER } from '@lobechat/business-const';
|
||||
import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
|
||||
import { COMPOSIO_APP_TYPES } from '@lobechat/const';
|
||||
import {
|
||||
type AgentContextDocument,
|
||||
type AgentGroupConfig,
|
||||
@@ -38,7 +38,12 @@ import {
|
||||
ToolResolver,
|
||||
} from '@lobechat/context-engine';
|
||||
import { parse } from '@lobechat/conversation-flow';
|
||||
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
|
||||
import {
|
||||
applyModelExtendParams,
|
||||
type ChatStreamPayload,
|
||||
consumeStreamUntilDone,
|
||||
type ModelExtendParams,
|
||||
} from '@lobechat/model-runtime';
|
||||
import {
|
||||
context as otelContext,
|
||||
SpanKind,
|
||||
@@ -67,8 +72,9 @@ import {
|
||||
} from '@lobechat/types';
|
||||
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
|
||||
import debug from 'debug';
|
||||
import type { ExtendParamsType } from 'model-bank';
|
||||
|
||||
import { klavisEnv } from '@/config/klavis';
|
||||
import { composioEnv } from '@/config/composio';
|
||||
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
@@ -80,6 +86,10 @@ import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/type
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
import type { HookDispatcher } from '@/server/services/agentRuntime/hooks/HookDispatcher';
|
||||
import type {
|
||||
ExecGroupMemberParams,
|
||||
ExecGroupMemberResult,
|
||||
} from '@/server/services/agentRuntime/types';
|
||||
import {
|
||||
type DeviceAccessReason,
|
||||
isDeviceToolIdentifier,
|
||||
@@ -89,6 +99,7 @@ import { FileService } from '@/server/services/file';
|
||||
import { MessageService } from '@/server/services/message';
|
||||
import { OnboardingService } from '@/server/services/onboarding';
|
||||
import {
|
||||
type ServerAgentMemberRunner,
|
||||
type ServerSubAgentRunner,
|
||||
type ToolExecutionResultResponse,
|
||||
type ToolExecutionService,
|
||||
@@ -405,6 +416,147 @@ const buildServerVirtualSubAgentRunner = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the per-tool "call agent member" runner for the group orchestration
|
||||
* server tool (`lobe-group-management`). Mirrors {@link buildServerVirtualSubAgentRunner}
|
||||
* but for group members: it owns the group tool message (the parked tool call)
|
||||
* and the per-member anchors that drive the K=N member barrier.
|
||||
*
|
||||
* For each `agentMember.run(...)` it:
|
||||
* 1. creates the group tool placeholder (`tool_call_id` = the group-management
|
||||
* call id) stamped with the barrier target + finish disposition;
|
||||
* 2. for a single member uses that placeholder as the member anchor; for
|
||||
* multiple members creates one child anchor per member under it;
|
||||
* 3. forks each member via `ctx.execGroupMember` (in-group or isolated);
|
||||
* 4. backfills anchors for members that failed to start so the barrier can
|
||||
* still complete, and tears everything down when none started.
|
||||
*
|
||||
* Returns `undefined` when group-member execution is unavailable (no
|
||||
* `execGroupMember` callback, or missing agent/topic/group context).
|
||||
*/
|
||||
const buildServerAgentMemberRunner = (
|
||||
ctx: RuntimeExecutorContext,
|
||||
state: AgentState,
|
||||
chatToolPayload: ChatToolPayload,
|
||||
parentMessageId: string,
|
||||
): ServerAgentMemberRunner | undefined => {
|
||||
const execGroupMember = ctx.execGroupMember;
|
||||
if (!execGroupMember) return undefined;
|
||||
|
||||
const agentId = state.metadata?.agentId;
|
||||
const topicId = ctx.topicId ?? state.metadata?.topicId;
|
||||
const groupId = state.metadata?.groupId ?? undefined;
|
||||
if (!agentId || !topicId || !groupId) return undefined;
|
||||
|
||||
return {
|
||||
run: async ({ members, mode, onComplete, disableTools, timeout }) => {
|
||||
const expectedMembers = members.length;
|
||||
if (expectedMembers === 0) return { started: false, startedCount: 0 };
|
||||
|
||||
// 1. Group tool placeholder — the parked tool call the supervisor op waits
|
||||
// on. Stamped with the barrier target + finish disposition so the resume
|
||||
// path (and verify watchdog) resolve resume-vs-finish on their own.
|
||||
const groupTool = await ctx.messageModel.create({
|
||||
agentId,
|
||||
content: '',
|
||||
parentId: parentMessageId,
|
||||
plugin: chatToolPayload as any,
|
||||
pluginState: { expectedMembers, onComplete, status: 'pending' },
|
||||
role: 'tool',
|
||||
threadId: state.metadata?.threadId,
|
||||
tool_call_id: chatToolPayload.id,
|
||||
topicId,
|
||||
});
|
||||
|
||||
// 2. Per-member anchors. A single member collapses onto the group tool
|
||||
// message; multiple members each get a child anchor under it.
|
||||
const anchorIds: string[] = [];
|
||||
if (expectedMembers === 1) {
|
||||
anchorIds.push(groupTool.id);
|
||||
} else {
|
||||
for (let i = 0; i < expectedMembers; i += 1) {
|
||||
const memberToolCallId = `${chatToolPayload.id}::m${i}`;
|
||||
const anchor = await ctx.messageModel.create({
|
||||
agentId,
|
||||
content: '',
|
||||
parentId: groupTool.id,
|
||||
plugin: { ...(chatToolPayload as any), id: memberToolCallId },
|
||||
pluginState: { status: 'pending' },
|
||||
role: 'tool',
|
||||
threadId: state.metadata?.threadId,
|
||||
tool_call_id: memberToolCallId,
|
||||
topicId,
|
||||
});
|
||||
anchorIds.push(anchor.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fork members.
|
||||
let startedCount = 0;
|
||||
await Promise.all(
|
||||
members.map(async (member, i) => {
|
||||
const anchorMessageId = anchorIds[i];
|
||||
try {
|
||||
const result = await execGroupMember({
|
||||
agentId: member.agentId,
|
||||
anchorMessageId,
|
||||
disableTools,
|
||||
expectedMembers,
|
||||
groupId,
|
||||
groupToolMessageId: groupTool.id,
|
||||
instruction: member.instruction,
|
||||
mode,
|
||||
onComplete,
|
||||
parentOperationId: ctx.operationId,
|
||||
timeout,
|
||||
topicId,
|
||||
});
|
||||
if (result?.started) {
|
||||
startedCount += 1;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
log(
|
||||
'buildServerAgentMemberRunner: member %s failed to start: %O',
|
||||
member.agentId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
// Member failed to start — its completion bridge will never fire, so
|
||||
// backfill the anchor as errored to keep the K=N barrier reachable.
|
||||
try {
|
||||
await ctx.messageModel.updateToolMessage(anchorMessageId, {
|
||||
content: `Agent member "${member.agentId}" failed to start.`,
|
||||
pluginState: { status: 'error' },
|
||||
});
|
||||
} catch (error) {
|
||||
log(
|
||||
'buildServerAgentMemberRunner: failed to mark anchor %s as errored: %O',
|
||||
anchorMessageId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// None started — no bridge will ever fire, so tear down the placeholders
|
||||
// and let the caller surface an inline tool error instead of parking.
|
||||
if (startedCount === 0) {
|
||||
for (const id of new Set([...anchorIds, groupTool.id])) {
|
||||
try {
|
||||
await ctx.messageModel.deleteMessage(id);
|
||||
} catch (error) {
|
||||
log('buildServerAgentMemberRunner: cleanup failed for %s: %O', id, error);
|
||||
}
|
||||
}
|
||||
return { started: false, startedCount: 0 };
|
||||
}
|
||||
|
||||
return { started: true, startedCount };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
|
||||
kind === 'retry' && attempt <= maxRetries;
|
||||
|
||||
@@ -522,6 +674,12 @@ export interface RuntimeExecutorContext {
|
||||
botPlatformContext?: BotPlatformContext;
|
||||
discordContext?: any;
|
||||
evalContext?: EvalContext;
|
||||
/**
|
||||
* Callback to fork a group member ("call agent member") under a
|
||||
* `lobe-group-management` tool call. Injected by AiAgentService; powers the
|
||||
* per-tool `agentMember` runner (in-group + isolated members, K=N barrier).
|
||||
*/
|
||||
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
|
||||
/**
|
||||
* Callback to run a legacy agent invocation server-side.
|
||||
* Injected by AiAgentService so exec_sub_agent / exec_sub_agents executors
|
||||
@@ -721,6 +879,7 @@ export const createRuntimeExecutors = (
|
||||
type ContentPart = { text: string; type: 'text' } | { image: string; type: 'image' };
|
||||
let shouldReplayAssistantReasoning = false;
|
||||
let preserveThinkingForPayload: boolean | undefined;
|
||||
let resolvedExtendParams: ModelExtendParams | undefined;
|
||||
|
||||
// Process messages through serverMessagesEngine to inject system role, knowledge, etc.
|
||||
// Rebuild params from agentConfig at execution time (capabilities built dynamically)
|
||||
@@ -736,19 +895,36 @@ export const createRuntimeExecutors = (
|
||||
: undefined;
|
||||
const preserveThinkingRequested = preserveThinkingConfigured === true;
|
||||
|
||||
const readExtendParams = (
|
||||
card: (typeof builtinModels)[number] | undefined,
|
||||
): string[] | undefined =>
|
||||
card &&
|
||||
'settings' in card &&
|
||||
card.settings &&
|
||||
typeof card.settings === 'object' &&
|
||||
'extendParams' in card.settings
|
||||
? (card.settings as { extendParams?: string[] }).extendParams
|
||||
: undefined;
|
||||
|
||||
const modelCard = builtinModels.find(
|
||||
(item) =>
|
||||
item.providerId === provider &&
|
||||
(item.id === model || item.config?.deploymentName === model),
|
||||
);
|
||||
const modelExtendParams =
|
||||
modelCard &&
|
||||
'settings' in modelCard &&
|
||||
modelCard.settings &&
|
||||
typeof modelCard.settings === 'object' &&
|
||||
'extendParams' in modelCard.settings
|
||||
? (modelCard.settings as { extendParams?: string[] }).extendParams
|
||||
: undefined;
|
||||
|
||||
let modelExtendParams = readExtendParams(modelCard);
|
||||
|
||||
// Aggregation providers (e.g. `lobehub`) may serve a model without copying
|
||||
// its origin `settings.extendParams`. Fall back to the canonical model card
|
||||
// (matched by id across any provider) so reasoning/thinking params like
|
||||
// `thinkingLevel` still reach the model. Mirrors the client-side
|
||||
// `transformToAiModelList` re-namespacing behavior.
|
||||
if (!modelExtendParams || modelExtendParams.length === 0) {
|
||||
const canonicalCard = builtinModels.find(
|
||||
(item) => item.id === model || item.config?.deploymentName === model,
|
||||
);
|
||||
modelExtendParams = readExtendParams(canonicalCard);
|
||||
}
|
||||
|
||||
const modelSupportsPreserveThinkingFromCard =
|
||||
Array.isArray(modelExtendParams) && modelExtendParams.includes('preserveThinking');
|
||||
@@ -763,6 +939,19 @@ export const createRuntimeExecutors = (
|
||||
modelSupportsPreserveThinking && typeof preserveThinkingConfigured === 'boolean'
|
||||
? preserveThinkingConfigured
|
||||
: undefined;
|
||||
|
||||
// Resolve model extend params (thinkingLevel, reasoning effort, urlContext, …)
|
||||
// from the agent chat config so the server-side agent runtime forwards the same
|
||||
// runtime params the client chat service does. Without this, e.g. Gemini 3 Pro's
|
||||
// `thinkingLevel` never reaches the request and thought summaries come back empty.
|
||||
if (agentConfig.chatConfig) {
|
||||
resolvedExtendParams = applyModelExtendParams({
|
||||
chatConfig: agentConfig.chatConfig,
|
||||
extendParams: modelExtendParams as ExtendParamsType[] | undefined,
|
||||
model,
|
||||
});
|
||||
}
|
||||
|
||||
const messagesForContext = shouldReplayAssistantReasoning
|
||||
? (llmPayload.messages as UIChatMessage[])
|
||||
: stripAssistantReasoningForReplay(llmPayload.messages as UIChatMessage[]);
|
||||
@@ -999,39 +1188,38 @@ export const createRuntimeExecutors = (
|
||||
}
|
||||
}
|
||||
|
||||
// {{KLAVIS_SERVICES_LIST}} — used by lobe-creds system role (Klavis integrations section).
|
||||
// Mirrors client-side: klavisStoreSelectors.getServers() filtered by connection status.
|
||||
let klavisServicesListStr = '';
|
||||
if (ctx.serverDB && ctx.userId && !!klavisEnv.KLAVIS_API_KEY) {
|
||||
// {{COMPOSIO_SERVICES_LIST}} — used by lobe-creds system role (Composio integrations section).
|
||||
let composioServicesListStr = '';
|
||||
if (ctx.serverDB && ctx.userId && !!composioEnv.COMPOSIO_API_KEY) {
|
||||
try {
|
||||
const { PluginModel } = await import('@/database/models/plugin');
|
||||
const pluginModel = new PluginModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
|
||||
const allPlugins = await pluginModel.query();
|
||||
const validKlavisIds = new Set(KLAVIS_SERVER_TYPES.map((t) => t.identifier));
|
||||
const validComposioIds = new Set(COMPOSIO_APP_TYPES.map((t) => t.identifier));
|
||||
const connectedIds = new Set(
|
||||
allPlugins
|
||||
.filter(
|
||||
(p) =>
|
||||
validKlavisIds.has(p.identifier) &&
|
||||
(p.customParams as any)?.klavis?.isAuthenticated === true,
|
||||
validComposioIds.has(p.identifier) &&
|
||||
(p.customParams as any)?.composio?.status === 'ACTIVE',
|
||||
)
|
||||
.map((p) => p.identifier),
|
||||
);
|
||||
const connected: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter((t) =>
|
||||
const connected: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter((t) =>
|
||||
connectedIds.has(t.identifier),
|
||||
).map((t) => ({ identifier: t.identifier, name: t.label }));
|
||||
const available: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter(
|
||||
const available: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter(
|
||||
(t) => !connectedIds.has(t.identifier),
|
||||
).map((t) => ({ identifier: t.identifier, name: t.label }));
|
||||
klavisServicesListStr = generateKlavisServicesList(connected, available);
|
||||
composioServicesListStr = generateComposioServicesList(connected, available);
|
||||
log(
|
||||
'Fetched Klavis services for {{KLAVIS_SERVICES_LIST}}: connected=%d, available=%d',
|
||||
'Fetched Composio services for {{COMPOSIO_SERVICES_LIST}}: connected=%d, available=%d',
|
||||
connected.length,
|
||||
available.length,
|
||||
);
|
||||
} catch (error) {
|
||||
log(
|
||||
'Failed to fetch Klavis services for {{KLAVIS_SERVICES_LIST}} substitution: %O',
|
||||
'Failed to fetch Composio services for {{COMPOSIO_SERVICES_LIST}} substitution: %O',
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -1055,7 +1243,7 @@ export const createRuntimeExecutors = (
|
||||
sandbox_enabled: sandboxEnabled,
|
||||
sandbox_uploaded_files: sandboxUploadedFiles,
|
||||
CREDS_LIST: credsListStr,
|
||||
KLAVIS_SERVICES_LIST: klavisServicesListStr,
|
||||
COMPOSIO_SERVICES_LIST: composioServicesListStr,
|
||||
// Memory tool variables
|
||||
memory_effort: memoryEffort,
|
||||
},
|
||||
@@ -1205,6 +1393,9 @@ export const createRuntimeExecutors = (
|
||||
model,
|
||||
stream,
|
||||
tools,
|
||||
// ModelExtendParams keeps provider-specific effort/thinking values as loose
|
||||
// strings (e.g. hy3's 'no_think'); the runtime payload narrows them, so cast.
|
||||
...(resolvedExtendParams as Partial<ChatStreamPayload>),
|
||||
...(typeof preserveThinkingForPayload === 'boolean' && {
|
||||
preserveThinking: preserveThinkingForPayload,
|
||||
}),
|
||||
@@ -2446,7 +2637,7 @@ export const createRuntimeExecutors = (
|
||||
execution = { attempts: 1, result: dispatchResult };
|
||||
} else {
|
||||
// Inject source from sourceMap so BuiltinToolsExecutor can route
|
||||
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
|
||||
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
|
||||
if (toolSource && !chatToolPayload.source) {
|
||||
chatToolPayload.source = toolSource;
|
||||
}
|
||||
@@ -2463,7 +2654,14 @@ export const createRuntimeExecutors = (
|
||||
toolExecutionService.executeTool(chatToolPayload, {
|
||||
activeDeviceId: state.metadata?.activeDeviceId,
|
||||
agentId: state.metadata?.agentId,
|
||||
agentMember: buildServerAgentMemberRunner(
|
||||
ctx,
|
||||
state,
|
||||
chatToolPayload,
|
||||
payload.parentMessageId,
|
||||
),
|
||||
documentId: state.metadata?.documentId,
|
||||
editingAgentId: state.metadata?.editingAgentId,
|
||||
execSubAgent: ctx.execSubAgent,
|
||||
executionTimeoutMs: timeoutMs,
|
||||
groupId: state.metadata?.groupId,
|
||||
@@ -3026,7 +3224,7 @@ export const createRuntimeExecutors = (
|
||||
execution = { attempts: 1, result: dispatchResult };
|
||||
} else {
|
||||
// Inject source from sourceMap so BuiltinToolsExecutor can route
|
||||
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
|
||||
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
|
||||
const batchToolSource =
|
||||
state.operationToolSet?.sourceMap?.[chatToolPayload.identifier] ??
|
||||
state.toolSourceMap?.[chatToolPayload.identifier];
|
||||
@@ -3045,6 +3243,12 @@ export const createRuntimeExecutors = (
|
||||
toolExecutionService.executeTool(chatToolPayload, {
|
||||
activeDeviceId: state.metadata?.activeDeviceId,
|
||||
agentId: state.metadata?.agentId,
|
||||
agentMember: buildServerAgentMemberRunner(
|
||||
ctx,
|
||||
state,
|
||||
chatToolPayload,
|
||||
payload.parentMessageId,
|
||||
),
|
||||
documentId: state.metadata?.documentId,
|
||||
execSubAgent: ctx.execSubAgent,
|
||||
executionTimeoutMs: timeoutMs,
|
||||
|
||||
@@ -58,6 +58,9 @@ vi.mock('@/server/services/message', () => ({
|
||||
// @lobechat/model-runtime resolves to @cloud/business-model-runtime which has
|
||||
// cloud-specific dependencies that are unavailable in the test environment
|
||||
vi.mock('@lobechat/model-runtime', () => ({
|
||||
// The executor resolves extend params via this helper; an empty result keeps
|
||||
// the runtime payload unchanged, matching this suite's pre-existing behavior.
|
||||
applyModelExtendParams: vi.fn(() => ({})),
|
||||
consumeStreamUntilDone: vi.fn().mockResolvedValue(undefined),
|
||||
// `llmErrorClassification.ts` reads these at module-load time; an empty
|
||||
// spec map is fine here because this suite never exercises the runtime
|
||||
@@ -76,11 +79,11 @@ vi.mock('model-bank', () => ({
|
||||
LOBE_DEFAULT_MODEL_LIST: mockBuiltinModels,
|
||||
}));
|
||||
|
||||
// klavisEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
|
||||
vi.mock('@/config/klavis', () => ({
|
||||
getKlavisConfig: vi.fn(),
|
||||
getServerKlavisApiKey: vi.fn().mockReturnValue(undefined),
|
||||
klavisEnv: { KLAVIS_API_KEY: undefined },
|
||||
// composioEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
|
||||
vi.mock('@/config/composio', () => ({
|
||||
getComposioConfig: vi.fn(),
|
||||
getServerComposioApiKey: vi.fn().mockReturnValue(undefined),
|
||||
composioEnv: { COMPOSIO_API_KEY: undefined },
|
||||
}));
|
||||
|
||||
// fileEnv uses @t3-oss/env-core; stub the only field the runtime reads so the
|
||||
@@ -125,6 +128,7 @@ describe('RuntimeExecutors', () => {
|
||||
|
||||
mockMessageModel = {
|
||||
create: vi.fn().mockResolvedValue({ id: 'msg-123' }),
|
||||
deleteMessage: vi.fn().mockResolvedValue({ success: true }),
|
||||
// call_llm does a parent existence preflight; return a truthy row by
|
||||
// default so existing tests don't have to stub it.
|
||||
findById: vi.fn().mockResolvedValue({ id: 'msg-existing' }),
|
||||
@@ -4850,10 +4854,9 @@ describe('RuntimeExecutors', () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('call_tool sets stop:true in tool_result payload when tool returns execSubAgent state', async () => {
|
||||
// Simulate agentManagement.callAgent returning execSubAgent state
|
||||
it('call_tool preserves stop:true for legacy execSubAgent state', async () => {
|
||||
mockToolExecutionService.executeTool.mockResolvedValue({
|
||||
content: '🚀 Triggered async task to call agent "target-agent"',
|
||||
content: 'Legacy async task result',
|
||||
executionTime: 10,
|
||||
state: {
|
||||
parentMessageId: 'tool-msg-id',
|
||||
@@ -4894,13 +4897,112 @@ describe('RuntimeExecutors', () => {
|
||||
expect((result.nextContext?.payload as any).stop).toBe(true);
|
||||
});
|
||||
|
||||
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
|
||||
const mockExecSubAgentTask = vi
|
||||
it('call_tool lets server callAgent run as a deferred tool via the subAgent runner', async () => {
|
||||
const mockExecVirtualSubAgent = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
|
||||
const ctxWithCallback = {
|
||||
...ctx,
|
||||
execSubAgent: mockExecSubAgentTask,
|
||||
execVirtualSubAgent: mockExecVirtualSubAgent,
|
||||
topicId: 'topic-123',
|
||||
};
|
||||
|
||||
mockMessageModel.create.mockResolvedValueOnce({ id: 'tool-msg-id' });
|
||||
mockToolExecutionService.executeTool.mockImplementation(
|
||||
async (_payload: any, context: any) => {
|
||||
const subAgent = await context.subAgent.run({
|
||||
agentId: 'target-agent-id',
|
||||
description: 'Call agent target-agent',
|
||||
instruction: 'Do something useful',
|
||||
timeout: 1_800_000,
|
||||
});
|
||||
|
||||
return {
|
||||
content: '',
|
||||
deferred: true,
|
||||
executionTime: 10,
|
||||
state: {
|
||||
status: 'pending',
|
||||
subOperationId: subAgent.subOperationId,
|
||||
targetAgentId: 'target-agent-id',
|
||||
threadId: subAgent.threadId,
|
||||
},
|
||||
success: subAgent.started,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const executors = createRuntimeExecutors(ctxWithCallback);
|
||||
const state = createMockState();
|
||||
const instruction = {
|
||||
payload: {
|
||||
parentMessageId: 'assistant-msg-id',
|
||||
toolCalling: {
|
||||
apiName: 'callAgent',
|
||||
arguments: JSON.stringify({
|
||||
agentId: 'target-agent-id',
|
||||
instruction: 'Do something useful',
|
||||
runAsTask: true,
|
||||
}),
|
||||
id: 'tool-call-1',
|
||||
identifier: 'lobe-agent-management',
|
||||
type: 'default' as const,
|
||||
},
|
||||
},
|
||||
type: 'call_tool' as const,
|
||||
};
|
||||
|
||||
const result = await executors.call_tool!(instruction, state);
|
||||
|
||||
expect(mockMessageModel.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'parent-agent-id',
|
||||
plugin: expect.objectContaining({
|
||||
apiName: 'callAgent',
|
||||
identifier: 'lobe-agent-management',
|
||||
}),
|
||||
pluginState: { status: 'pending' },
|
||||
parentId: 'assistant-msg-id',
|
||||
role: 'tool',
|
||||
tool_call_id: 'tool-call-1',
|
||||
topicId: 'topic-123',
|
||||
}),
|
||||
);
|
||||
expect(mockExecVirtualSubAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'target-agent-id',
|
||||
instruction: 'Do something useful',
|
||||
parentMessageId: 'tool-msg-id',
|
||||
parentOperationId: 'op-123',
|
||||
title: 'Call agent target-agent',
|
||||
topicId: 'topic-123',
|
||||
}),
|
||||
);
|
||||
expect(result.newState.status).toBe('waiting_for_async_tool');
|
||||
expect(result.newState.pendingToolsCalling).toEqual([
|
||||
expect.objectContaining({
|
||||
apiName: 'callAgent',
|
||||
id: 'tool-call-1',
|
||||
identifier: 'lobe-agent-management',
|
||||
}),
|
||||
]);
|
||||
expect(result.events).toEqual([
|
||||
expect.objectContaining({
|
||||
canResume: true,
|
||||
reason: 'async_tool',
|
||||
type: 'interrupted',
|
||||
}),
|
||||
]);
|
||||
expect(result.nextContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
|
||||
const mockExecSubAgent = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
|
||||
const ctxWithCallback = {
|
||||
...ctx,
|
||||
execSubAgent: mockExecSubAgent,
|
||||
topicId: 'topic-123',
|
||||
};
|
||||
|
||||
@@ -4926,6 +5028,9 @@ describe('RuntimeExecutors', () => {
|
||||
expect(mockMessageModel.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'parent-agent-id',
|
||||
metadata: expect.objectContaining({
|
||||
targetAgentId: 'target-agent-id',
|
||||
}),
|
||||
role: 'task',
|
||||
parentId: 'tool-msg-id',
|
||||
topicId: 'topic-123',
|
||||
@@ -4933,7 +5038,7 @@ describe('RuntimeExecutors', () => {
|
||||
);
|
||||
|
||||
// execSubAgent callback fired with targetAgentId
|
||||
expect(mockExecSubAgentTask).toHaveBeenCalledWith(
|
||||
expect(mockExecSubAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'target-agent-id',
|
||||
instruction: 'Do something useful',
|
||||
@@ -4947,10 +5052,10 @@ describe('RuntimeExecutors', () => {
|
||||
});
|
||||
|
||||
it('exec_sub_agent blocks nested dispatch when current state is already a sub-agent', async () => {
|
||||
const mockExecSubAgentTask = vi.fn();
|
||||
const mockExecSubAgent = vi.fn();
|
||||
const ctxWithCallback = {
|
||||
...ctx,
|
||||
execSubAgentTask: mockExecSubAgentTask,
|
||||
execSubAgent: mockExecSubAgent,
|
||||
topicId: 'topic-123',
|
||||
};
|
||||
|
||||
@@ -4983,7 +5088,7 @@ describe('RuntimeExecutors', () => {
|
||||
success: false,
|
||||
});
|
||||
expect(mockMessageModel.create).not.toHaveBeenCalled();
|
||||
expect(mockExecSubAgentTask).not.toHaveBeenCalled();
|
||||
expect(mockExecSubAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exec_sub_agent gracefully skips dispatch when execSubAgent not injected', async () => {
|
||||
|
||||
@@ -659,6 +659,59 @@ describe('createServerAgentToolsEngine', () => {
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
|
||||
it('should disable RemoteDevice when a device is explicitly bound (locked to the selection)', () => {
|
||||
// A user-selected (bound) device locks the run to that device — the
|
||||
// activate-device tool is never offered, so the model cannot switch.
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
|
||||
canUseDevice: true,
|
||||
deviceContext: {
|
||||
autoActivated: true,
|
||||
boundDeviceId: 'device-001',
|
||||
deviceOnline: true,
|
||||
gatewayConfigured: true,
|
||||
},
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [RemoteDeviceManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
|
||||
it('should disable RemoteDevice when the bound device is OFFLINE — no silent hop to another machine', () => {
|
||||
// The bound device going offline makes the plan device-unrouted, so
|
||||
// `autoActivated` is false. Without the `boundDeviceId` gate the tool
|
||||
// would resurface and let the model activate a *different* online device.
|
||||
// The explicit selection must keep the run locked instead.
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
|
||||
canUseDevice: true,
|
||||
deviceContext: {
|
||||
boundDeviceId: 'device-001',
|
||||
deviceOnline: true,
|
||||
gatewayConfigured: true,
|
||||
},
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [RemoteDeviceManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
|
||||
it('should enable RemoteDevice in bot conversations when caller is trusted (canUseDevice=true)', () => {
|
||||
// The `!isBotConversation` clause was dropped in — the
|
||||
// confused-deputy concern that motivated it is now handled at a
|
||||
|
||||
@@ -86,7 +86,7 @@ export const createServerToolsEngine = (
|
||||
// Combine all manifests, then drop anything whose identifier the caller
|
||||
// has explicitly forbidden for this turn. The post-merge filter closes
|
||||
// the second half of the wall: an installed plugin or a
|
||||
// Skill/Klavis manifest claiming `lobe-remote-device` would otherwise
|
||||
// Skill/Composio manifest claiming `lobe-remote-device` would otherwise
|
||||
// slip through `buildAllowedBuiltinTools` (which only touches the
|
||||
// builtin source).
|
||||
const combinedManifests = [...pluginManifests, ...builtinManifests, ...additionalManifests];
|
||||
@@ -231,12 +231,20 @@ export const createServerAgentToolsEngine = (
|
||||
// Only auto-enable in bot conversations; otherwise let user's plugin selection take effect
|
||||
...(isBotConversation && { [MessageManifest.identifier]: true }),
|
||||
// Remote-device proxy: shown only for device-capable targets when the
|
||||
// server has a proxy but no specific device is auto-activated yet (user
|
||||
// must pick). External bot senders never reach it: the plan degrades
|
||||
// denied targets to `none` (→ not deviceCapable) and the physical
|
||||
// manifest walls drop it for `canUseDevice=false` turns.
|
||||
// server has a proxy, no specific device is auto-activated yet, AND the
|
||||
// user has NOT explicitly selected a device. Once a device is explicitly
|
||||
// selected (`boundDeviceId`), the run is locked to it: we never expose the
|
||||
// activate-device tool, so the model can never switch to another machine —
|
||||
// not even when the selected device is offline (the run stays unrouted
|
||||
// until that device comes back, rather than silently hopping elsewhere).
|
||||
// External bot senders never reach it: the plan degrades denied targets to
|
||||
// `none` (→ not deviceCapable) and the physical manifest walls drop it for
|
||||
// `canUseDevice=false` turns.
|
||||
[RemoteDeviceManifest.identifier]:
|
||||
deviceCapable && hasDeviceProxy && !deviceContext?.autoActivated,
|
||||
deviceCapable &&
|
||||
hasDeviceProxy &&
|
||||
!deviceContext?.autoActivated &&
|
||||
!deviceContext?.boundDeviceId,
|
||||
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
|
||||
[WebBrowsingManifest.identifier]: isSearchEnabled,
|
||||
};
|
||||
@@ -256,7 +264,7 @@ export const createServerAgentToolsEngine = (
|
||||
: isChatMode
|
||||
? chatModeAllowedToolIds
|
||||
: defaultToolIds,
|
||||
// Post-merge wall: a plugin or Skill/Klavis manifest claiming a
|
||||
// Post-merge wall: a plugin or Skill/Composio manifest claiming a
|
||||
// device identifier survives `buildAllowedBuiltinTools` (which only
|
||||
// filters the builtin source). Excluding the identifiers here drops
|
||||
// them from the combined `manifestSchemas` so the activator cannot
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface ServerAgentToolsContext {
|
||||
* Configuration options for createServerToolsEngine
|
||||
*/
|
||||
export interface ServerAgentToolsEngineConfig {
|
||||
/** Additional manifests to include (e.g., Klavis tools) */
|
||||
/** Additional manifests to include (e.g., Composio tools) */
|
||||
additionalManifests?: LobeToolManifest[];
|
||||
/**
|
||||
* Override the list of builtin tools fed into the engine's
|
||||
@@ -39,7 +39,7 @@ export interface ServerAgentToolsEngineConfig {
|
||||
/**
|
||||
* Identifiers to drop from `manifestSchemas` after combining plugin,
|
||||
* builtin, and additional manifests. Filtering builtins alone is not
|
||||
* enough: an installed plugin or a Skill/Klavis manifest can declare
|
||||
* enough: an installed plugin or a Skill/Composio manifest can declare
|
||||
* `identifier: 'lobe-remote-device'` and slip past `buildAllowedBuiltinTools`.
|
||||
* This is the final post-merge wall referenced in .
|
||||
*/
|
||||
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* Integration test for the server `lobe-agent-management.callAgent` deferred
|
||||
* execution flow.
|
||||
*
|
||||
* Verifies the full lifecycle end-to-end on the in-memory runtime:
|
||||
* 1. Parent op LLM emits a `lobe-agent-management____callAgent` tool call.
|
||||
* 2. The real server executor parks the parent, creates a pending tool
|
||||
* placeholder, and forks the target agent as a child op.
|
||||
* 3. The child op completes.
|
||||
* 4. The completion bridge backfills the placeholder and resumes the parent.
|
||||
* 5. The parent reaches `done`.
|
||||
*/
|
||||
import { type LobeChatDatabase } from '@lobechat/database';
|
||||
import { agentOperations, agents, messagePlugins, messages } from '@lobechat/database/schemas';
|
||||
import { getTestDB } from '@lobechat/database/test-utils';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import OpenAI from 'openai';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { inMemoryAgentStateManager } from '@/server/modules/AgentRuntime/InMemoryAgentStateManager';
|
||||
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
|
||||
|
||||
import { aiAgentRouter } from '../../../aiAgent';
|
||||
import { cleanupTestUser, createTestUser } from '../setup';
|
||||
import { createMockResponsesStream, waitForOperationComplete } from './helpers';
|
||||
|
||||
process.env.OPENAI_API_KEY = 'sk-test-fake-api-key-for-testing';
|
||||
|
||||
let testDB: LobeChatDatabase;
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: vi.fn(() => testDB),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/file', () => ({
|
||||
FileService: vi.fn().mockImplementation(() => ({
|
||||
getFullFileUrl: vi.fn().mockImplementation((path: string) => (path ? `/files${path}` : null)),
|
||||
})),
|
||||
}));
|
||||
|
||||
let mockResponsesCreate: any;
|
||||
let serverDB: LobeChatDatabase;
|
||||
let userId: string;
|
||||
let parentAgentId: string;
|
||||
let targetAgentId: string;
|
||||
|
||||
const TARGET_ANSWER = 'The target agent completed the delegated callAgent work.';
|
||||
const PARENT_FINAL = 'I received the target agent result and the delegated work is complete.';
|
||||
|
||||
const createTestContext = () => ({ jwtPayload: { userId }, userId });
|
||||
|
||||
const createCallAgentResponse = () => {
|
||||
const responseId = `resp_call_agent_${Date.now()}`;
|
||||
const msgItemId = `msg_call_agent_${Date.now()}`;
|
||||
const callId = 'call_agent_1';
|
||||
const fnCall = {
|
||||
arguments: JSON.stringify({
|
||||
agentId: targetAgentId,
|
||||
instruction: 'Handle the delegated backend integration task.',
|
||||
runAsTask: true,
|
||||
taskTitle: 'Delegated backend integration task',
|
||||
timeout: 30_000,
|
||||
}),
|
||||
call_id: callId,
|
||||
name: 'lobe-agent-management____callAgent',
|
||||
type: 'function_call',
|
||||
};
|
||||
|
||||
return createMockResponsesStream([
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [],
|
||||
status: 'in_progress',
|
||||
},
|
||||
type: 'response.created',
|
||||
},
|
||||
{
|
||||
item: {
|
||||
content: [],
|
||||
id: msgItemId,
|
||||
role: 'assistant',
|
||||
status: 'in_progress',
|
||||
type: 'message',
|
||||
},
|
||||
output_index: 0,
|
||||
type: 'response.output_item.added',
|
||||
},
|
||||
{
|
||||
content_index: 0,
|
||||
delta: 'I will delegate this to the target agent.',
|
||||
item_id: msgItemId,
|
||||
output_index: 0,
|
||||
type: 'response.output_text.delta',
|
||||
},
|
||||
{ item: fnCall, output_index: 1, type: 'response.output_item.added' },
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [
|
||||
{
|
||||
content: [{ text: 'I will delegate this to the target agent.', type: 'output_text' }],
|
||||
id: msgItemId,
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
type: 'message',
|
||||
},
|
||||
fnCall,
|
||||
],
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 30, output_tokens: 20, total_tokens: 50 },
|
||||
},
|
||||
type: 'response.completed',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const createFinalTextResponse = (content: string) => {
|
||||
const responseId = `resp_final_${Date.now()}_${content.length}`;
|
||||
const msgItemId = `msg_final_${Date.now()}_${content.length}`;
|
||||
|
||||
return createMockResponsesStream([
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [],
|
||||
status: 'in_progress',
|
||||
},
|
||||
type: 'response.created',
|
||||
},
|
||||
{
|
||||
content_index: 0,
|
||||
delta: content,
|
||||
item_id: msgItemId,
|
||||
output_index: 0,
|
||||
type: 'response.output_text.delta',
|
||||
},
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [
|
||||
{
|
||||
content: [{ text: content, type: 'output_text' }],
|
||||
id: msgItemId,
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
type: 'message',
|
||||
},
|
||||
],
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 40, output_tokens: 20, total_tokens: 60 },
|
||||
},
|
||||
type: 'response.completed',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
serverDB = await getTestDB();
|
||||
testDB = serverDB;
|
||||
userId = await createTestUser(serverDB);
|
||||
|
||||
const insertedAgents = await serverDB
|
||||
.insert(agents)
|
||||
.values([
|
||||
{
|
||||
chatConfig: {},
|
||||
model: 'gpt-5-pro',
|
||||
plugins: ['lobe-agent-management'],
|
||||
provider: 'openai',
|
||||
systemRole: 'You are a supervisor that delegates work to other agents.',
|
||||
title: 'callAgent Supervisor',
|
||||
userId,
|
||||
},
|
||||
{
|
||||
chatConfig: {},
|
||||
model: 'gpt-5-pro',
|
||||
plugins: [],
|
||||
provider: 'openai',
|
||||
systemRole: 'You are the target agent. Return a concise result.',
|
||||
title: 'callAgent Target',
|
||||
userId,
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
parentAgentId = insertedAgents[0].id;
|
||||
targetAgentId = insertedAgents[1].id;
|
||||
|
||||
// `create` is overloaded (streaming / non-streaming); its precise spy type
|
||||
// isn't assignable to the generic MockInstance fallback, so widen via unknown.
|
||||
mockResponsesCreate = vi.spyOn(
|
||||
OpenAI.Responses.prototype,
|
||||
'create',
|
||||
) as unknown as typeof mockResponsesCreate;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestUser(serverDB, userId);
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
inMemoryAgentStateManager.clear();
|
||||
inMemoryStreamEventManager.clear();
|
||||
});
|
||||
|
||||
describe('Server callAgent deferred execution', () => {
|
||||
it('parks the parent, runs the target agent, backfills the tool message and resumes', async () => {
|
||||
let callCount = 0;
|
||||
mockResponsesCreate.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return Promise.resolve(createCallAgentResponse() as any);
|
||||
if (callCount === 2) return Promise.resolve(createFinalTextResponse(TARGET_ANSWER) as any);
|
||||
return Promise.resolve(createFinalTextResponse(PARENT_FINAL) as any);
|
||||
});
|
||||
|
||||
const caller = aiAgentRouter.createCaller(createTestContext());
|
||||
|
||||
const createResult = await caller.execAgent({
|
||||
agentId: parentAgentId,
|
||||
prompt: 'Delegate this work to the target agent and report back.',
|
||||
userInterventionConfig: { approvalMode: 'headless' },
|
||||
});
|
||||
expect(createResult.success).toBe(true);
|
||||
|
||||
const finalState = await waitForOperationComplete(
|
||||
inMemoryAgentStateManager,
|
||||
createResult.operationId,
|
||||
{ maxWaitTime: 20_000 },
|
||||
);
|
||||
|
||||
expect(finalState.status).toBe('done');
|
||||
expect(finalState.pendingToolsCalling ?? []).toHaveLength(0);
|
||||
expect(mockResponsesCreate).toHaveBeenCalledTimes(3);
|
||||
|
||||
const childOps = await serverDB
|
||||
.select()
|
||||
.from(agentOperations)
|
||||
.where(eq(agentOperations.parentOperationId, createResult.operationId));
|
||||
expect(childOps).toHaveLength(1);
|
||||
expect(childOps[0]).toMatchObject({
|
||||
agentId: targetAgentId,
|
||||
status: 'done',
|
||||
});
|
||||
|
||||
const toolMessages = await serverDB
|
||||
.select({
|
||||
content: messages.content,
|
||||
role: messages.role,
|
||||
state: messagePlugins.state,
|
||||
identifier: messagePlugins.identifier,
|
||||
apiName: messagePlugins.apiName,
|
||||
toolCallId: messagePlugins.toolCallId,
|
||||
})
|
||||
.from(messages)
|
||||
.innerJoin(messagePlugins, eq(messagePlugins.id, messages.id))
|
||||
.where(
|
||||
and(
|
||||
eq(messages.userId, userId),
|
||||
eq(messagePlugins.identifier, 'lobe-agent-management'),
|
||||
eq(messagePlugins.apiName, 'callAgent'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(toolMessages).toHaveLength(1);
|
||||
expect(toolMessages[0]).toMatchObject({
|
||||
apiName: 'callAgent',
|
||||
content: TARGET_ANSWER,
|
||||
identifier: 'lobe-agent-management',
|
||||
role: 'tool',
|
||||
toolCallId: 'call_agent_1',
|
||||
});
|
||||
expect(toolMessages[0].state).toMatchObject({
|
||||
status: 'completed',
|
||||
threadId: childOps[0].threadId,
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { topicRouter } from '../../topic';
|
||||
import { cleanupTestUser, createTestContext, createTestUser } from './setup';
|
||||
import { cleanupTestUser, createTestAgent, createTestContext, createTestUser } from './setup';
|
||||
|
||||
// We need to mock getServerDB to return our test database instance
|
||||
let testDB: LobeChatDatabase;
|
||||
@@ -332,31 +332,79 @@ describe('Topic Router Integration Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// BM25 search requires pg_search extension (ParadeDB), not available in integration test DB
|
||||
// BM25 search requires pg_search extension (ParadeDB), not available in the
|
||||
// default integration test DB (PGlite). Run with TEST_SERVER_DB=1 +
|
||||
// DATABASE_TEST_URL pointing at a ParadeDB instance to exercise these.
|
||||
describe.skip('searchTopics', () => {
|
||||
it('should search topics using agentId', async () => {
|
||||
const caller = topicRouter.createCaller(createTestContext(userId));
|
||||
|
||||
// Create test topics
|
||||
await caller.createTopic({
|
||||
title: 'TypeScript Discussion',
|
||||
sessionId: testSessionId,
|
||||
});
|
||||
// Topics are agent-native: stored with agentId directly.
|
||||
await serverDB.insert(topics).values([
|
||||
{ agentId: testAgentId, title: 'TypeScript Discussion', userId },
|
||||
{ agentId: testAgentId, title: 'JavaScript Basics', userId },
|
||||
]);
|
||||
|
||||
await caller.createTopic({
|
||||
title: 'JavaScript Basics',
|
||||
sessionId: testSessionId,
|
||||
});
|
||||
|
||||
// Search using agentId
|
||||
const result = await caller.searchTopics({
|
||||
keywords: 'TypeScript',
|
||||
agentId: testAgentId,
|
||||
keywords: 'TypeScript',
|
||||
});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0].title).toContain('TypeScript');
|
||||
});
|
||||
|
||||
// Regression for the "No topics match these filters" bug: topics created by
|
||||
// the new agent system carry `agentId` directly with a NULL `sessionId`.
|
||||
// The old search resolved agentId -> sessionId and filtered by the
|
||||
// container only, so these rows were never matched even though the topics
|
||||
// list (which filters by agentId) showed them.
|
||||
it('should find agentId-scoped topics that have no sessionId', async () => {
|
||||
const caller = topicRouter.createCaller(createTestContext(userId));
|
||||
|
||||
// Insert a topic the way the agent runtime does: agentId set, sessionId null.
|
||||
await serverDB.insert(topics).values({
|
||||
agentId: testAgentId,
|
||||
sessionId: null,
|
||||
title: 'rinabrown84@gmail.com',
|
||||
userId,
|
||||
});
|
||||
|
||||
const result = await caller.searchTopics({
|
||||
agentId: testAgentId,
|
||||
keywords: 'rinabrown84@gmail.com',
|
||||
});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0].title).toBe('rinabrown84@gmail.com');
|
||||
});
|
||||
|
||||
// The agent scope mirrors the topics list exactly (agentId only). A row that
|
||||
// shares this agent's resolved session but is owned by a DIFFERENT agent
|
||||
// must not leak in — the bug the constrained-session-fallback review flagged.
|
||||
it('should not leak another agent topic that shares the session mapping', async () => {
|
||||
const caller = topicRouter.createCaller(createTestContext(userId));
|
||||
|
||||
const otherAgentId = await createTestAgent(serverDB, userId);
|
||||
|
||||
await serverDB.insert(topics).values([
|
||||
{ agentId: testAgentId, title: 'mine rinabrown84@gmail.com', userId },
|
||||
// Same session, different agent — used to leak via the session fallback.
|
||||
{
|
||||
agentId: otherAgentId,
|
||||
sessionId: testSessionId,
|
||||
title: 'theirs rinabrown84@gmail.com',
|
||||
userId,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.searchTopics({
|
||||
agentId: testAgentId,
|
||||
keywords: 'rinabrown84@gmail.com',
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.title)).toEqual(['mine rinabrown84@gmail.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTopic', () => {
|
||||
@@ -719,7 +767,7 @@ describe('Topic Router Integration Tests', () => {
|
||||
sessionId: testSessionId,
|
||||
});
|
||||
|
||||
const allTopics = await caller.getAllTopics();
|
||||
const allTopics = await caller.queryTopics();
|
||||
|
||||
expect(allTopics).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -139,6 +139,8 @@ const ExecAgentSchema = z
|
||||
.object({
|
||||
defaultTaskAssigneeAgentId: z.string().optional(),
|
||||
documentId: z.string().optional().nullable(),
|
||||
/** The agent being edited when scope is 'agent_builder' (not the builder builtin itself). */
|
||||
editingAgentId: z.string().optional(),
|
||||
groupId: z.string().optional().nullable(),
|
||||
initialTopicMetadata: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { type ToolManifest } from '@lobechat/types';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getServerComposioAuthConfigId } from '@/config/composio';
|
||||
import { PluginModel } from '@/database/models/plugin';
|
||||
import { getComposioClient } from '@/libs/composio';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
|
||||
const composioProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const client = getComposioClient();
|
||||
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId);
|
||||
|
||||
return opts.next({
|
||||
ctx: { ...opts.ctx, composioClient: client, pluginModel },
|
||||
});
|
||||
});
|
||||
|
||||
export const composioRouter = router({
|
||||
createConnection: composioProcedure
|
||||
.input(
|
||||
z.object({
|
||||
appSlug: z.string(),
|
||||
identifier: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { appSlug, identifier, label } = input;
|
||||
const { userId } = ctx;
|
||||
|
||||
const callbackUrl = `${process.env.APP_URL || process.env.NEXTAUTH_URL || ''}/api/composio/oauth/callback`;
|
||||
|
||||
// Prefer a pre-configured auth config (e.g. a custom/white-label config
|
||||
// created in the Composio dashboard), pinned per toolkit via env. Falls
|
||||
// back to discovering an existing config for this toolkit, and finally to
|
||||
// auto-creating a Composio-managed one.
|
||||
let authConfigId = getServerComposioAuthConfigId(identifier);
|
||||
if (!authConfigId) {
|
||||
const authConfigs = await (ctx.composioClient.authConfigs as any).list();
|
||||
let authConfig = authConfigs?.items?.find(
|
||||
(c: any) => c.toolkit?.slug?.toLowerCase() === appSlug.toLowerCase(),
|
||||
);
|
||||
if (!authConfig) {
|
||||
authConfig = await (ctx.composioClient.authConfigs as any).create(appSlug, {
|
||||
name: appSlug,
|
||||
type: 'use_composio_managed_auth',
|
||||
});
|
||||
}
|
||||
authConfigId = authConfig.id;
|
||||
}
|
||||
|
||||
if (!authConfigId) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `Failed to resolve a Composio auth config for "${appSlug}".`,
|
||||
});
|
||||
}
|
||||
|
||||
// Composio-managed OAuth auth configs no longer support `initiate`; use
|
||||
// `link` (POST /api/v3/connected_accounts/link) to get the redirect URL.
|
||||
const connReq = await (ctx.composioClient.connectedAccounts as any).link(
|
||||
userId,
|
||||
authConfigId,
|
||||
{ callbackUrl },
|
||||
);
|
||||
|
||||
let rawTools: any[] = [];
|
||||
try {
|
||||
const toolsResp = await (ctx.composioClient.tools as any).getRawComposioTools({
|
||||
toolkits: [appSlug],
|
||||
});
|
||||
rawTools = toolsResp?.items || toolsResp || [];
|
||||
} catch {
|
||||
// tools may not be available before auth
|
||||
}
|
||||
|
||||
const manifest: ToolManifest = {
|
||||
api: Array.isArray(rawTools)
|
||||
? rawTools.map((tool: any) => ({
|
||||
description: tool.description || '',
|
||||
name: tool.slug || tool.name || '',
|
||||
parameters: tool.inputParameters ||
|
||||
tool.inputSchema || {
|
||||
properties: {},
|
||||
type: 'object',
|
||||
},
|
||||
}))
|
||||
: [],
|
||||
identifier,
|
||||
meta: {
|
||||
avatar: '🔌',
|
||||
description: `Composio: ${label}`,
|
||||
title: label,
|
||||
},
|
||||
type: 'default',
|
||||
};
|
||||
|
||||
await ctx.pluginModel.create({
|
||||
customParams: {
|
||||
composio: {
|
||||
appSlug,
|
||||
authConfigId,
|
||||
connectedAccountId: connReq.id,
|
||||
redirectUrl: connReq.redirectUrl,
|
||||
status: 'PENDING',
|
||||
},
|
||||
},
|
||||
identifier,
|
||||
manifest,
|
||||
source: 'composio',
|
||||
type: 'plugin',
|
||||
});
|
||||
|
||||
return {
|
||||
authConfigId,
|
||||
connectedAccountId: connReq.id,
|
||||
identifier,
|
||||
redirectUrl: connReq.redirectUrl,
|
||||
};
|
||||
}),
|
||||
|
||||
deleteConnection: composioProcedure
|
||||
.input(
|
||||
z.object({
|
||||
connectedAccountId: z.string(),
|
||||
identifier: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
await (ctx.composioClient.connectedAccounts as any).delete(input.connectedAccountId);
|
||||
} catch (error) {
|
||||
console.warn('[Composio] Failed to delete remote connection:', error);
|
||||
}
|
||||
|
||||
await ctx.pluginModel.delete(input.identifier);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getComposioPlugins: composioProcedure.query(async ({ ctx }) => {
|
||||
const allPlugins = await ctx.pluginModel.query();
|
||||
return allPlugins.filter((plugin) => plugin.customParams?.composio);
|
||||
}),
|
||||
|
||||
getConnection: composioProcedure
|
||||
.input(
|
||||
z.object({
|
||||
connectedAccountId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const account = await (ctx.composioClient.connectedAccounts as any).get(
|
||||
input.connectedAccountId,
|
||||
);
|
||||
return {
|
||||
appSlug: account?.toolkit?.slug || '',
|
||||
connectedAccountId: input.connectedAccountId,
|
||||
error: undefined as 'AUTH_ERROR' | undefined,
|
||||
status: (account?.status || 'PENDING') as string,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const isAuthError = errorMessage.includes('401') || errorMessage.includes('Unauthorized');
|
||||
|
||||
if (isAuthError) {
|
||||
return {
|
||||
appSlug: '',
|
||||
connectedAccountId: input.connectedAccountId,
|
||||
error: 'AUTH_ERROR' as const,
|
||||
status: 'FAILED',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
removeComposioPlugin: composioProcedure
|
||||
.input(z.object({ identifier: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await ctx.pluginModel.delete(input.identifier);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
updateComposioPlugin: composioProcedure
|
||||
.input(
|
||||
z.object({
|
||||
appSlug: z.string(),
|
||||
authConfigId: z.string(),
|
||||
connectedAccountId: z.string(),
|
||||
identifier: z.string(),
|
||||
label: z.string(),
|
||||
redirectUrl: z.string().optional(),
|
||||
status: z.string(),
|
||||
tools: z.array(
|
||||
z.object({
|
||||
description: z.string().optional(),
|
||||
inputSchema: z.any().optional(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const {
|
||||
identifier,
|
||||
label,
|
||||
appSlug,
|
||||
authConfigId,
|
||||
connectedAccountId,
|
||||
tools,
|
||||
status,
|
||||
redirectUrl,
|
||||
} = input;
|
||||
|
||||
const existingPlugin = await ctx.pluginModel.findById(identifier);
|
||||
|
||||
const manifest: ToolManifest = {
|
||||
api: tools.map((tool) => ({
|
||||
description: tool.description || '',
|
||||
name: tool.name,
|
||||
parameters: tool.inputSchema || { properties: {}, type: 'object' },
|
||||
})),
|
||||
identifier,
|
||||
meta: existingPlugin?.manifest?.meta || {
|
||||
avatar: '🔌',
|
||||
description: `Composio: ${label}`,
|
||||
title: label,
|
||||
},
|
||||
type: 'default',
|
||||
};
|
||||
|
||||
const customParams = {
|
||||
composio: { appSlug, authConfigId, connectedAccountId, redirectUrl, status },
|
||||
};
|
||||
|
||||
if (existingPlugin) {
|
||||
await ctx.pluginModel.update(identifier, { customParams, manifest });
|
||||
} else {
|
||||
await ctx.pluginModel.create({
|
||||
customParams,
|
||||
identifier,
|
||||
manifest,
|
||||
source: 'composio',
|
||||
type: 'plugin',
|
||||
});
|
||||
}
|
||||
|
||||
return { savedCount: tools.length };
|
||||
}),
|
||||
});
|
||||
|
||||
export type ComposioRouter = typeof composioRouter;
|
||||
@@ -115,6 +115,33 @@ export const connectorRouter = router({
|
||||
return toolsByConnector;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Return the connector record with decrypted user-set credentials so the
|
||||
* edit form can pre-fill accurately. Only the connector owner can call this
|
||||
* (enforced by connectorProcedure ownership check).
|
||||
*
|
||||
* Machine-managed secrets are intentionally excluded:
|
||||
* - OAuth access/refresh tokens (type 'oauth2') → stripped, returned as null
|
||||
* - oidcConfig.clientSecret (DCR-registered secret) → stripped
|
||||
* User-set credentials (bearer token, custom headers) are returned as-is so
|
||||
* the edit form can display them.
|
||||
*/
|
||||
getForEdit: connectorProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const connector = await ctx.connectorModel.findById(input.id);
|
||||
if (!connector)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Connector not found' });
|
||||
|
||||
const { oidcConfig, credentials, ...rest } = connector;
|
||||
const safeOidcConfig = oidcConfig ? { ...oidcConfig, clientSecret: undefined } : oidcConfig;
|
||||
// OAuth tokens are machine-managed — don't return them; the UI only needs
|
||||
// to know an OAuth flow is configured (reflected via oidcConfig presence).
|
||||
const safeCredentials = credentials?.type === 'oauth2' ? null : credentials;
|
||||
|
||||
return { ...rest, credentials: safeCredentials, oidcConfig: safeOidcConfig };
|
||||
}),
|
||||
|
||||
/**
|
||||
* The exact redirect URI the server will send to the OAuth/DCR endpoints.
|
||||
* The Add modal must display THIS value (not a client-derived origin) so the
|
||||
@@ -268,9 +295,14 @@ export const connectorRouter = router({
|
||||
await ctx.connectorModel.update(input.id, {
|
||||
...patch,
|
||||
// undefined → leave untouched; null → clear; object → encrypt the JSON string.
|
||||
// When credentials are cleared, also drop the cached expiry timestamp so
|
||||
// token-refresh logic doesn't act on a stale value for the new server.
|
||||
...(credentials === undefined
|
||||
? {}
|
||||
: { credentials: credentials ? JSON.stringify(credentials) : null }),
|
||||
: {
|
||||
credentials: credentials ? JSON.stringify(credentials) : null,
|
||||
...(credentials === null ? { tokenExpiresAt: null } : {}),
|
||||
}),
|
||||
} as any);
|
||||
}),
|
||||
|
||||
@@ -358,7 +390,7 @@ export const connectorRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Sync tools from a client-provided list (for Lobehub OAuth skills, Klavis, etc.
|
||||
* Sync tools from a client-provided list (for Lobehub OAuth skills, Composio, etc.
|
||||
* that already have their tool list available on the client side).
|
||||
* Idempotent — safe to call whenever the detail panel opens.
|
||||
*/
|
||||
|
||||
@@ -119,6 +119,22 @@ export const deviceRouter = router({
|
||||
return result ?? null;
|
||||
}),
|
||||
|
||||
/**
|
||||
* List the git worktrees attached to the same repository as a directory on a
|
||||
* remote device, via the device's `listGitWorktrees` RPC. Lets the web/remote
|
||||
* worktree picker mirror the local desktop's, populated over IPC.
|
||||
*/
|
||||
listGitWorktrees: deviceProcedure
|
||||
.input(z.object({ deviceId: z.string(), path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await deviceGateway.listGitWorktrees({
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return result ?? [];
|
||||
}),
|
||||
|
||||
/**
|
||||
* List the local branches of a directory on a remote device, via the device's
|
||||
* `listGitBranches` RPC. Lets the web/remote branch switcher populate the same
|
||||
|
||||
@@ -37,6 +37,7 @@ import { briefRouter } from './brief';
|
||||
import { changelogRouter } from './changelog';
|
||||
import { chunkRouter } from './chunk';
|
||||
import { comfyuiRouter } from './comfyui';
|
||||
import { composioRouter } from './composio';
|
||||
import { configRouter } from './config';
|
||||
import { connectorRouter } from './connector';
|
||||
import { deviceRouter } from './device';
|
||||
@@ -50,7 +51,6 @@ import { generationTopicRouter } from './generationTopic';
|
||||
import { homeRouter } from './home';
|
||||
import { imageRouter } from './image';
|
||||
import { importerRouter } from './importer';
|
||||
import { klavisRouter } from './klavis';
|
||||
import { knowledgeRouter } from './knowledge';
|
||||
import { knowledgeBaseRouter } from './knowledgeBase';
|
||||
import { llmGenerationTracingRouter } from './llmGenerationTracing';
|
||||
@@ -115,7 +115,8 @@ export const lambdaRouter = router({
|
||||
home: homeRouter,
|
||||
image: imageRouter,
|
||||
importer: importerRouter,
|
||||
klavis: klavisRouter,
|
||||
composio: composioRouter,
|
||||
|
||||
knowledge: knowledgeRouter,
|
||||
knowledgeBase: knowledgeBaseRouter,
|
||||
llmGenerationTracing: llmGenerationTracingRouter,
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
import { type ToolManifest } from '@lobechat/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { PluginModel } from '@/database/models/plugin';
|
||||
import { getKlavisClient } from '@/libs/klavis';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
|
||||
/**
|
||||
* Klavis procedure with API key validation and database access
|
||||
*/
|
||||
const klavisProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const client = getKlavisClient();
|
||||
const wsId = opts.ctx.workspaceId ?? undefined;
|
||||
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId, wsId);
|
||||
|
||||
return opts.next({
|
||||
ctx: { ...opts.ctx, klavisClient: client, pluginModel },
|
||||
});
|
||||
});
|
||||
|
||||
export const klavisRouter = router({
|
||||
/**
|
||||
* Create a single MCP server instance and save to database
|
||||
* Returns: { serverUrl, instanceId, oauthUrl?, identifier, serverName }
|
||||
*/
|
||||
createServerInstance: klavisProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(
|
||||
z.object({
|
||||
/** Identifier for storage (e.g., 'google-calendar') */
|
||||
identifier: z.string(),
|
||||
/** Server name for Klavis API (e.g., 'Google Calendar') */
|
||||
serverName: z.string(),
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { serverName, userId, identifier } = input;
|
||||
|
||||
// Create a single server instance
|
||||
const response = await ctx.klavisClient.mcpServer.createServerInstance({
|
||||
serverName: serverName as any,
|
||||
userId,
|
||||
});
|
||||
|
||||
const { serverUrl, instanceId, oauthUrl } = response;
|
||||
|
||||
// Get the tool list for this server
|
||||
const toolsResponse = await ctx.klavisClient.mcpServer.getTools(serverName as any);
|
||||
const tools = toolsResponse.tools || [];
|
||||
|
||||
// Save to database using the provided identifier (format: lowercase, spaces replaced with hyphens)
|
||||
const manifest: ToolManifest = {
|
||||
api: tools.map((tool: any) => ({
|
||||
description: tool.description || '',
|
||||
name: tool.name,
|
||||
parameters: tool.inputSchema || { properties: {}, type: 'object' },
|
||||
})),
|
||||
identifier,
|
||||
meta: {
|
||||
avatar: '🔌',
|
||||
description: `LobeHub Mcp Server: ${serverName}`,
|
||||
title: serverName,
|
||||
},
|
||||
type: 'default',
|
||||
};
|
||||
|
||||
// Save to database with oauthUrl and isAuthenticated status
|
||||
const isAuthenticated = !oauthUrl; // If there's no oauthUrl, authentication is not required or already authenticated
|
||||
await ctx.pluginModel.create({
|
||||
customParams: {
|
||||
klavis: {
|
||||
instanceId,
|
||||
isAuthenticated,
|
||||
oauthUrl,
|
||||
serverName,
|
||||
serverUrl,
|
||||
},
|
||||
},
|
||||
identifier,
|
||||
manifest,
|
||||
source: 'klavis',
|
||||
type: 'plugin',
|
||||
});
|
||||
|
||||
return {
|
||||
identifier,
|
||||
instanceId,
|
||||
isAuthenticated,
|
||||
oauthUrl,
|
||||
serverName,
|
||||
serverUrl,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a server instance
|
||||
*/
|
||||
deleteServerInstance: klavisProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(
|
||||
z.object({
|
||||
/** Identifier for storage (e.g., 'google-calendar') */
|
||||
identifier: z.string(),
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Call Klavis API to delete server instance
|
||||
await ctx.klavisClient.mcpServer.deleteServerInstance(input.instanceId);
|
||||
|
||||
// Delete from database (using identifier)
|
||||
await ctx.pluginModel.delete(input.identifier);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get Klavis plugins from database
|
||||
*/
|
||||
getKlavisPlugins: klavisProcedure.query(async ({ ctx }) => {
|
||||
const allPlugins = await ctx.pluginModel.query();
|
||||
// Filter plugins that have klavis customParams
|
||||
return allPlugins.filter((plugin) => plugin.customParams?.klavis);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get server instance status from Klavis API
|
||||
* Returns error object instead of throwing on auth errors (useful for polling)
|
||||
*/
|
||||
getServerInstance: klavisProcedure
|
||||
.input(
|
||||
z.object({
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const response = await ctx.klavisClient.mcpServer.getServerInstance(input.instanceId);
|
||||
return {
|
||||
authNeeded: response.authNeeded,
|
||||
error: undefined,
|
||||
externalUserId: response.externalUserId,
|
||||
instanceId: response.instanceId,
|
||||
isAuthenticated: response.isAuthenticated,
|
||||
oauthUrl: response.oauthUrl,
|
||||
platform: response.platform,
|
||||
serverName: response.serverName,
|
||||
};
|
||||
} catch (error) {
|
||||
// Check if this is an authentication error
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const isAuthError =
|
||||
errorMessage.includes('Invalid API key or instance ID') ||
|
||||
errorMessage.includes('Status code: 401');
|
||||
|
||||
// For auth errors, return error object instead of throwing
|
||||
// This prevents 500 errors in logs during polling
|
||||
if (isAuthError) {
|
||||
return {
|
||||
authNeeded: true,
|
||||
error: 'AUTH_ERROR',
|
||||
externalUserId: undefined,
|
||||
instanceId: input.instanceId,
|
||||
isAuthenticated: false,
|
||||
oauthUrl: undefined,
|
||||
platform: undefined,
|
||||
serverName: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// For other errors, still throw
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
getUserIntergrations: klavisProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const response = await ctx.klavisClient.user.getUserIntegrations(input.userId);
|
||||
|
||||
return {
|
||||
integrations: response.integrations,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Remove Klavis plugin from database by identifier
|
||||
*/
|
||||
removeKlavisPlugin: klavisProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(
|
||||
z.object({
|
||||
/** Identifier for storage (e.g., 'google-calendar') */
|
||||
identifier: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await ctx.pluginModel.delete(input.identifier);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update Klavis plugin with tools and auth status in database
|
||||
*/
|
||||
updateKlavisPlugin: klavisProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(
|
||||
z.object({
|
||||
/** Identifier for storage (e.g., 'google-calendar') */
|
||||
identifier: z.string(),
|
||||
instanceId: z.string(),
|
||||
isAuthenticated: z.boolean(),
|
||||
oauthUrl: z.string().optional(),
|
||||
/** Server name for Klavis API (e.g., 'Google Calendar') */
|
||||
serverName: z.string(),
|
||||
serverUrl: z.string(),
|
||||
tools: z.array(
|
||||
z.object({
|
||||
description: z.string().optional(),
|
||||
inputSchema: z.any().optional(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { identifier, serverName, serverUrl, instanceId, tools, isAuthenticated, oauthUrl } =
|
||||
input;
|
||||
|
||||
// Get existing plugin (using identifier)
|
||||
const existingPlugin = await ctx.pluginModel.findById(identifier);
|
||||
|
||||
// Build manifest containing all tools
|
||||
const manifest: ToolManifest = {
|
||||
api: tools.map((tool) => ({
|
||||
description: tool.description || '',
|
||||
name: tool.name,
|
||||
parameters: tool.inputSchema || { properties: {}, type: 'object' },
|
||||
})),
|
||||
identifier,
|
||||
meta: existingPlugin?.manifest?.meta || {
|
||||
avatar: '🔌',
|
||||
description: `LobeHub Mcp Server: ${serverName}`,
|
||||
title: serverName,
|
||||
},
|
||||
type: 'default',
|
||||
};
|
||||
|
||||
const customParams = {
|
||||
klavis: {
|
||||
instanceId,
|
||||
isAuthenticated,
|
||||
oauthUrl,
|
||||
serverName,
|
||||
serverUrl,
|
||||
},
|
||||
};
|
||||
|
||||
// Update or create plugin
|
||||
if (existingPlugin) {
|
||||
await ctx.pluginModel.update(identifier, { customParams, manifest });
|
||||
} else {
|
||||
await ctx.pluginModel.create({
|
||||
customParams,
|
||||
identifier,
|
||||
manifest,
|
||||
source: 'klavis',
|
||||
type: 'plugin',
|
||||
});
|
||||
}
|
||||
|
||||
return { savedCount: tools.length };
|
||||
}),
|
||||
});
|
||||
|
||||
export type KlavisRouter = typeof klavisRouter;
|
||||
@@ -53,7 +53,7 @@ export const oauthDeviceFlowRouter = router({
|
||||
);
|
||||
|
||||
if (!providerDetail?.keyVaults) {
|
||||
return { isAuthenticated: false };
|
||||
return { status: 'PENDING' };
|
||||
}
|
||||
|
||||
const keyVaults = providerDetail.keyVaults as Record<string, any>;
|
||||
@@ -63,12 +63,12 @@ export const oauthDeviceFlowRouter = router({
|
||||
return {
|
||||
avatarUrl: keyVaults.githubAvatarUrl as string | undefined,
|
||||
expiresAt: keyVaults.oauthTokenExpiresAt || keyVaults.bearerTokenExpiresAt,
|
||||
isAuthenticated: true,
|
||||
status: 'ACTIVE',
|
||||
username: keyVaults.githubUsername as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { isAuthenticated: false };
|
||||
return { status: 'PENDING' };
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -251,9 +251,18 @@ export const topicRouter = router({
|
||||
return ctx.topicShareModel.create(input.topicId, input.visibility);
|
||||
}),
|
||||
|
||||
getAllTopics: topicProcedure.query(async ({ ctx }) => {
|
||||
return ctx.topicModel.queryAll();
|
||||
}),
|
||||
queryTopics: topicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
pageSize: z.number().max(500).optional(),
|
||||
statuses: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return ctx.topicModel.queryTopics({ pageSize: input?.pageSize, statuses: input?.statuses });
|
||||
}),
|
||||
|
||||
getShareInfo: topicProcedure
|
||||
.input(z.object({ topicId: z.string() }))
|
||||
@@ -582,7 +591,17 @@ export const topicRouter = router({
|
||||
ctx.workspaceId ?? undefined,
|
||||
);
|
||||
|
||||
return ctx.topicModel.queryByKeyword(input.keywords, resolved.sessionId);
|
||||
// Scope the search exactly like the topics list (`query`): by agentId
|
||||
// directly (the new agent system stamps every topic with an agentId).
|
||||
// Passing only the resolved sessionId used to miss every agentId-scoped
|
||||
// topic — the cause of "no topics match" in the per-agent Topics search.
|
||||
// `containerId` is only the fallback for legacy callers that pass no
|
||||
// agentId/groupId.
|
||||
return ctx.topicModel.queryByKeyword(input.keywords, {
|
||||
agentId: input.agentId,
|
||||
containerId: resolved.sessionId,
|
||||
groupId: input.groupId,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import { aiProviderRouter } from '../lambda/aiProvider';
|
||||
import { briefRouter } from '../lambda/brief';
|
||||
import { chunkRouter } from '../lambda/chunk';
|
||||
import { configRouter } from '../lambda/config';
|
||||
import { deviceRouter } from '../lambda/device';
|
||||
import { documentRouter } from '../lambda/document';
|
||||
import { fileRouter } from '../lambda/file';
|
||||
import { homeRouter } from '../lambda/home';
|
||||
@@ -36,6 +37,7 @@ export const mobileRouter = router({
|
||||
aiProvider: aiProviderRouter,
|
||||
chunk: chunkRouter,
|
||||
config: configRouter,
|
||||
device: deviceRouter,
|
||||
document: documentRouter,
|
||||
file: fileRouter,
|
||||
healthcheck: publicProcedure.query(() => "i'm live!"),
|
||||
|
||||
@@ -3,8 +3,7 @@ import { z } from 'zod';
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import { getServerDB } from '@/database/server';
|
||||
import { publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { type BatchTaskResult } from '@/types/service';
|
||||
|
||||
@@ -95,12 +94,7 @@ export const topicRouter = router({
|
||||
return data.id;
|
||||
}),
|
||||
|
||||
getAllTopics: topicProcedure.query(async ({ ctx }) => {
|
||||
return ctx.topicModel.queryAll();
|
||||
}),
|
||||
|
||||
// TODO: this procedure should be used with authedProcedure
|
||||
getTopics: publicProcedure
|
||||
getTopics: topicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z.string().nullable().optional(),
|
||||
@@ -109,12 +103,7 @@ export const topicRouter = router({
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (!ctx.userId) return [];
|
||||
|
||||
const serverDB = await getServerDB();
|
||||
const topicModel = new TopicModel(serverDB, ctx.userId, ctx.workspaceId ?? undefined);
|
||||
|
||||
return topicModel.query(input);
|
||||
return ctx.topicModel.query(input);
|
||||
}),
|
||||
|
||||
hasTopics: topicProcedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
isMarketConnectionsAuthError,
|
||||
isMarketConnectionsTimeoutError,
|
||||
listMarketConnectionsWithTimeout,
|
||||
listOptionalMarketConnectionsWithTimeout,
|
||||
MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
|
||||
} from './marketConnections';
|
||||
|
||||
@@ -31,4 +33,33 @@ describe('marketConnections helpers', () => {
|
||||
expect(isMarketConnectionsTimeoutError(new DOMException('Aborted', 'AbortError'))).toBe(true);
|
||||
expect(isMarketConnectionsTimeoutError(new Error('market failed'))).toBe(false);
|
||||
});
|
||||
|
||||
it('detects Market auth failures', () => {
|
||||
expect(
|
||||
isMarketConnectionsAuthError({
|
||||
errorBody: { error: 'unauthorized', error_description: 'Missing bearer token' },
|
||||
status: 401,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isMarketConnectionsAuthError(new Error('Network error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty connections for optional auth failures', async () => {
|
||||
const listConnections = vi.fn().mockRejectedValue({
|
||||
errorBody: { error: 'unauthorized', error_description: 'Missing bearer token' },
|
||||
status: 401,
|
||||
});
|
||||
|
||||
await expect(listOptionalMarketConnectionsWithTimeout({ listConnections })).resolves.toEqual({
|
||||
connections: [],
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows non-auth failures for optional connections', async () => {
|
||||
const error = new Error('Market API unavailable');
|
||||
const listConnections = vi.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(listOptionalMarketConnectionsWithTimeout({ listConnections })).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,47 @@ export const MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
type MarketConnectClient = Pick<MarketSDK['connect'], 'listConnections'>;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const getStringField = (value: unknown, key: string) => {
|
||||
if (!isRecord(value)) return;
|
||||
|
||||
const field = value[key];
|
||||
return typeof field === 'string' ? field : undefined;
|
||||
};
|
||||
|
||||
const includesAuthError = (value?: string) => {
|
||||
const normalized = value?.toLowerCase();
|
||||
|
||||
if (!normalized) return false;
|
||||
|
||||
return (
|
||||
normalized === 'unauthorized' ||
|
||||
normalized === 'invalid_token' ||
|
||||
normalized === 'token_expired' ||
|
||||
normalized.includes('missing bearer token') ||
|
||||
normalized.includes('unauthorized') ||
|
||||
normalized.includes('invalid_token') ||
|
||||
normalized.includes('token expired')
|
||||
);
|
||||
};
|
||||
|
||||
export const isMarketConnectionsAuthError = (error: unknown): boolean => {
|
||||
if (!isRecord(error)) return false;
|
||||
|
||||
const status = error.status;
|
||||
const errorBody = error.errorBody;
|
||||
|
||||
return (
|
||||
status === 401 ||
|
||||
includesAuthError(getStringField(error, 'name')) ||
|
||||
includesAuthError(getStringField(error, 'message')) ||
|
||||
includesAuthError(getStringField(errorBody, 'error')) ||
|
||||
includesAuthError(getStringField(errorBody, 'error_description'))
|
||||
);
|
||||
};
|
||||
|
||||
export const isMarketConnectionsTimeoutError = (error: unknown): boolean =>
|
||||
error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
|
||||
|
||||
@@ -15,3 +56,18 @@ export const listMarketConnectionsWithTimeout = async (
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
};
|
||||
|
||||
export const listOptionalMarketConnectionsWithTimeout = async (
|
||||
marketConnect: MarketConnectClient,
|
||||
timeoutMs = MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
|
||||
): Promise<ListConnectionsResponse> => {
|
||||
try {
|
||||
return await listMarketConnectionsWithTimeout(marketConnect, timeoutMs);
|
||||
} catch (error) {
|
||||
if (isMarketConnectionsAuthError(error)) {
|
||||
return { connections: [], success: true };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PluginModel } from '@/database/models/plugin';
|
||||
import { getComposioClient } from '@/libs/composio';
|
||||
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { MCPService } from '@/server/services/mcp';
|
||||
|
||||
const composioProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const composioClient = getComposioClient();
|
||||
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId);
|
||||
return opts.next({ ctx: { ...opts.ctx, composioClient, pluginModel } });
|
||||
});
|
||||
|
||||
export const composioToolsRouter = router({
|
||||
executeAction: composioProcedure
|
||||
.input(
|
||||
z.object({
|
||||
identifier: z.string(),
|
||||
toolArgs: z.record(z.unknown()).optional(),
|
||||
toolSlug: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Resolve the connected account server-side from the caller's own plugin
|
||||
// record (PluginModel is user-scoped). Never trust a connectedAccountId
|
||||
// supplied by the client — that would let a user drive another user's
|
||||
// connection.
|
||||
const plugin = await ctx.pluginModel.findById(input.identifier);
|
||||
const connectedAccountId = plugin?.customParams?.composio?.connectedAccountId;
|
||||
|
||||
if (!connectedAccountId) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `No Composio connection found for "${input.identifier}".`,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await (ctx.composioClient.tools as any).execute(input.toolSlug, {
|
||||
arguments: input.toolArgs || {},
|
||||
connectedAccountId,
|
||||
// Toolkit version resolves to "latest"; allow manual execution without a
|
||||
// pinned version (Composio otherwise throws ComposioToolVersionRequiredError).
|
||||
dangerouslySkipVersionCheck: true,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
content: 'Unknown error',
|
||||
state: { content: [{ text: 'Unknown error', type: 'text' }], isError: true },
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const data = result as any;
|
||||
const content = data?.data || data?.result || data;
|
||||
const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
|
||||
|
||||
return await MCPService.processToolCallResult({
|
||||
content: [{ text: contentStr, type: 'text' }],
|
||||
isError: false,
|
||||
});
|
||||
}),
|
||||
|
||||
getActions: publicProcedure.input(z.object({ appSlug: z.string() })).query(async ({ input }) => {
|
||||
const client = getComposioClient();
|
||||
const response = await (client.tools as any).getRawComposioTools({
|
||||
toolkits: [input.appSlug],
|
||||
});
|
||||
|
||||
const items = response?.items || response || [];
|
||||
const tools = Array.isArray(items)
|
||||
? items.map((tool: any) => ({
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputParameters ||
|
||||
tool.inputSchema || {
|
||||
properties: {},
|
||||
type: 'object',
|
||||
},
|
||||
name: tool.slug || tool.name || '',
|
||||
}))
|
||||
: [];
|
||||
|
||||
return { tools };
|
||||
}),
|
||||
|
||||
listActions: composioProcedure
|
||||
.input(z.object({ appSlug: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Use getRawComposioTools (raw tool defs with slug/inputParameters), NOT
|
||||
// tools.get() — the latter returns provider-wrapped (OpenAI-format) tools
|
||||
// whose name/params live under `.function`, so slug/name/inputSchema come
|
||||
// back empty and every tool collapses to the same `${identifier}____` name.
|
||||
const response = await (ctx.composioClient.tools as any).getRawComposioTools({
|
||||
toolkits: [input.appSlug],
|
||||
});
|
||||
|
||||
const items = response?.items || response || [];
|
||||
const tools = Array.isArray(items)
|
||||
? items.map((tool: any) => ({
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputParameters ||
|
||||
tool.inputSchema || {
|
||||
properties: {},
|
||||
type: 'object',
|
||||
},
|
||||
name: tool.slug || tool.name || '',
|
||||
}))
|
||||
: [];
|
||||
|
||||
return { tools };
|
||||
}),
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
import { publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
|
||||
import { klavisRouter } from './klavis';
|
||||
import { composioToolsRouter } from './composio';
|
||||
import { marketRouter } from './market';
|
||||
import { mcpRouter } from './mcp';
|
||||
import { searchRouter } from './search';
|
||||
|
||||
export const toolsRouter = router({
|
||||
healthcheck: publicProcedure.query(() => "i'm live!"),
|
||||
klavis: klavisRouter,
|
||||
composio: composioToolsRouter,
|
||||
|
||||
market: marketRouter,
|
||||
mcp: mcpRouter,
|
||||
search: searchRouter,
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { ConnectorModel } from '@/database/models/connector';
|
||||
import { ConnectorToolModel } from '@/database/models/connectorTool';
|
||||
import { ConnectorToolPermission } from '@/database/schemas';
|
||||
import { getKlavisClient } from '@/libs/klavis';
|
||||
import { publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { MCPService } from '@/server/services/mcp';
|
||||
|
||||
/**
|
||||
* Klavis procedure with client initialized in context
|
||||
*/
|
||||
const klavisProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const klavisClient = getKlavisClient();
|
||||
|
||||
return opts.next({
|
||||
ctx: { ...opts.ctx, klavisClient },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Klavis router for tools
|
||||
* Contains callTool and listTools which call external Klavis API
|
||||
*/
|
||||
export const klavisRouter = router({
|
||||
/**
|
||||
* Call a tool on a Klavis Strata server
|
||||
*/
|
||||
callTool: klavisProcedure
|
||||
.input(
|
||||
z.object({
|
||||
/** Klavis server identifier (e.g. 'gmail', 'google-calendar') for precise permission lookup */
|
||||
identifier: z.string().optional(),
|
||||
serverUrl: z.string(),
|
||||
toolArgs: z.record(z.unknown()).optional(),
|
||||
toolName: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// ── Connector tool permission gate ────────────────────────────────────
|
||||
// Use identifier + toolName when available for a precise lookup (avoids
|
||||
// same-name collisions across connectors). Falls back to toolName-only
|
||||
// if identifier is absent (legacy callers).
|
||||
if (ctx.userId && ctx.serverDB) {
|
||||
const wsId = ctx.workspaceId ?? undefined;
|
||||
const connectorToolModel = new ConnectorToolModel(ctx.serverDB, ctx.userId, wsId);
|
||||
let connectorTool:
|
||||
| Awaited<ReturnType<typeof connectorToolModel.findByToolName>>
|
||||
| undefined;
|
||||
|
||||
if (input.identifier) {
|
||||
const connectorModel = new ConnectorModel(ctx.serverDB, ctx.userId, wsId);
|
||||
const [connector] = await connectorModel.queryByIdentifiers([input.identifier]);
|
||||
if (connector) {
|
||||
const tools = await connectorToolModel.queryByConnector(connector.id);
|
||||
connectorTool = tools.find((t) => t.toolName === input.toolName);
|
||||
}
|
||||
} else {
|
||||
connectorTool = await connectorToolModel.findByToolName(input.toolName);
|
||||
}
|
||||
|
||||
if (connectorTool?.permission === ConnectorToolPermission.disabled) {
|
||||
const message =
|
||||
`The tool "${input.toolName}" has been disabled by the user and cannot be executed. ` +
|
||||
`Please inform the user that this tool is currently disabled. ` +
|
||||
`They can re-enable it in Settings > Connectors.`;
|
||||
return {
|
||||
content: message,
|
||||
state: { content: [{ text: message, type: 'text' }], isError: false },
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
// ── End permission gate ───────────────────────────────────────────────
|
||||
|
||||
const response = await ctx.klavisClient.mcpServer.callTools({
|
||||
serverUrl: input.serverUrl,
|
||||
toolArgs: input.toolArgs,
|
||||
toolName: input.toolName,
|
||||
});
|
||||
|
||||
// Handle error case
|
||||
if (!response.success || !response.result) {
|
||||
return {
|
||||
content: response.error || 'Unknown error',
|
||||
state: {
|
||||
content: [{ text: response.error || 'Unknown error', type: 'text' }],
|
||||
isError: true,
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Process the response using the common MCP tool call result processor
|
||||
const processedResult = await MCPService.processToolCallResult({
|
||||
content: (response.result.content || []) as any[],
|
||||
isError: response.result.isError,
|
||||
});
|
||||
|
||||
return processedResult;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get tools by server name (public endpoint, no auth required)
|
||||
*/
|
||||
getTools: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
serverName: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const klavisClient = getKlavisClient();
|
||||
const response = await klavisClient.mcpServer.getTools(input.serverName as any);
|
||||
|
||||
return {
|
||||
tools: response.tools,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* List tools available on a Klavis Strata server
|
||||
*/
|
||||
listTools: klavisProcedure
|
||||
.input(
|
||||
z.object({
|
||||
serverUrl: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const response = await ctx.klavisClient.mcpServer.listTools({
|
||||
serverUrl: input.serverUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
tools: response.tools,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import { createSandboxService } from '@/server/services/sandbox';
|
||||
import { scheduleToolCallReport } from './_helpers';
|
||||
import {
|
||||
isMarketConnectionsTimeoutError,
|
||||
listMarketConnectionsWithTimeout,
|
||||
listOptionalMarketConnectionsWithTimeout,
|
||||
MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
|
||||
} from './_helpers/marketConnections';
|
||||
|
||||
@@ -540,7 +540,7 @@ export const marketRouter = router({
|
||||
log('connectListConnections');
|
||||
|
||||
try {
|
||||
const response = await listMarketConnectionsWithTimeout(ctx.marketSDK.connect);
|
||||
const response = await listOptionalMarketConnectionsWithTimeout(ctx.marketSDK.connect);
|
||||
// Debug logging
|
||||
log('connectListConnections raw response: %O', response);
|
||||
log('connectListConnections connections: %O', response.connections);
|
||||
|
||||
@@ -98,6 +98,8 @@ describe('AgentDocumentsService', () => {
|
||||
findByFilename: vi.fn(),
|
||||
findSkillDocsByAgent: vi.fn(),
|
||||
hasByAgent: vi.fn(),
|
||||
listByAgent: vi.fn(),
|
||||
listByDocumentIds: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
@@ -282,21 +284,19 @@ describe('AgentDocumentsService', () => {
|
||||
|
||||
describe('listDocuments', () => {
|
||||
it('should return a list of documents with documentId, filename, id, and title', async () => {
|
||||
mockModel.findByAgent.mockResolvedValue([
|
||||
mockModel.listByAgent.mockResolvedValue([
|
||||
{
|
||||
content: 'c1',
|
||||
documentId: 'documents-1',
|
||||
filename: 'a.md',
|
||||
id: 'doc-1',
|
||||
policy: null,
|
||||
loadPosition: undefined,
|
||||
title: 'A',
|
||||
},
|
||||
{
|
||||
content: 'c2',
|
||||
documentId: 'documents-2',
|
||||
filename: 'b.md',
|
||||
id: 'doc-2',
|
||||
policy: null,
|
||||
loadPosition: undefined,
|
||||
title: 'B',
|
||||
},
|
||||
]);
|
||||
@@ -304,7 +304,8 @@ describe('AgentDocumentsService', () => {
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
const result = await service.listDocuments('agent-1');
|
||||
|
||||
expect(mockModel.findByAgent).toHaveBeenCalledWith('agent-1');
|
||||
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1');
|
||||
expect(mockModel.findByAgent).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
documentId: 'documents-1',
|
||||
@@ -322,6 +323,16 @@ describe('AgentDocumentsService', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pass sourceType filtering to the model', async () => {
|
||||
mockModel.listByAgent.mockResolvedValue([]);
|
||||
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
await service.listDocuments('agent-1', 'web');
|
||||
|
||||
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1', { sourceType: 'web' });
|
||||
expect(mockModel.findByAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listDocumentsForTopic', () => {
|
||||
@@ -330,19 +341,19 @@ describe('AgentDocumentsService', () => {
|
||||
{ id: 'documents-2', title: 'B' },
|
||||
{ id: 'documents-1', title: 'A' },
|
||||
]);
|
||||
mockModel.findByDocumentIds.mockResolvedValue([
|
||||
mockModel.listByDocumentIds.mockResolvedValue([
|
||||
{
|
||||
documentId: 'documents-1',
|
||||
filename: 'a.md',
|
||||
id: 'agent-doc-1',
|
||||
policy: null,
|
||||
loadPosition: undefined,
|
||||
title: 'A',
|
||||
},
|
||||
{
|
||||
documentId: 'documents-2',
|
||||
filename: 'b.md',
|
||||
id: 'agent-doc-2',
|
||||
policy: null,
|
||||
loadPosition: undefined,
|
||||
title: 'B',
|
||||
},
|
||||
]);
|
||||
@@ -351,10 +362,11 @@ describe('AgentDocumentsService', () => {
|
||||
const result = await service.listDocumentsForTopic('agent-1', 'topic-1');
|
||||
|
||||
expect(mockTopicDocumentModel.findByTopicId).toHaveBeenCalledWith('topic-1');
|
||||
expect(mockModel.findByDocumentIds).toHaveBeenCalledWith('agent-1', [
|
||||
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', [
|
||||
'documents-2',
|
||||
'documents-1',
|
||||
]);
|
||||
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
documentId: 'documents-2',
|
||||
@@ -372,6 +384,19 @@ describe('AgentDocumentsService', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pass sourceType filtering to the topic document summary query', async () => {
|
||||
mockTopicDocumentModel.findByTopicId.mockResolvedValue([{ id: 'documents-1' }]);
|
||||
mockModel.listByDocumentIds.mockResolvedValue([]);
|
||||
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
await service.listDocumentsForTopic('agent-1', 'topic-1', 'web');
|
||||
|
||||
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', ['documents-1'], {
|
||||
sourceType: 'web',
|
||||
});
|
||||
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDocumentByFilename', () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
AgentDocument,
|
||||
AgentDocumentContextPayload,
|
||||
AgentDocumentContextRow,
|
||||
AgentDocumentListItem,
|
||||
AgentDocumentListSourceType,
|
||||
AgentDocumentWithRules,
|
||||
ToolUpdateLoadRule,
|
||||
} from '@/database/models/agentDocuments';
|
||||
@@ -611,54 +613,27 @@ export class AgentDocumentsService {
|
||||
}
|
||||
}
|
||||
|
||||
async listDocuments(agentId: string, sourceType?: 'all' | 'file' | 'web') {
|
||||
const docs = await this.agentDocumentModel.findByAgent(agentId);
|
||||
const filtered =
|
||||
sourceType && sourceType !== 'all' ? docs.filter((d) => d.sourceType === sourceType) : docs;
|
||||
return filtered.map((d) => ({
|
||||
...deriveAgentDocumentFields(d),
|
||||
description: d.description,
|
||||
documentId: d.documentId,
|
||||
fileType: d.fileType,
|
||||
filename: d.filename,
|
||||
id: d.id,
|
||||
loadPosition: d.policy?.context?.position,
|
||||
parentId: d.parentId,
|
||||
sourceType: d.sourceType,
|
||||
templateId: d.templateId,
|
||||
title: d.title,
|
||||
updatedAt: d.updatedAt,
|
||||
}));
|
||||
async listDocuments(agentId: string, sourceType?: AgentDocumentListSourceType) {
|
||||
if (!sourceType) return this.agentDocumentModel.listByAgent(agentId);
|
||||
|
||||
return this.agentDocumentModel.listByAgent(agentId, { sourceType });
|
||||
}
|
||||
|
||||
async listDocumentsForTopic(
|
||||
agentId: string,
|
||||
topicId: string,
|
||||
sourceType?: 'all' | 'file' | 'web',
|
||||
sourceType?: AgentDocumentListSourceType,
|
||||
) {
|
||||
const topicDocs = await this.topicDocumentModel.findByTopicId(topicId);
|
||||
const documentIds = topicDocs.map((doc) => doc.id);
|
||||
const docs = await this.agentDocumentModel.findByDocumentIds(agentId, documentIds);
|
||||
const docs = sourceType
|
||||
? await this.agentDocumentModel.listByDocumentIds(agentId, documentIds, { sourceType })
|
||||
: await this.agentDocumentModel.listByDocumentIds(agentId, documentIds);
|
||||
const docsByDocumentId = new Map(docs.map((doc) => [doc.documentId, doc]));
|
||||
|
||||
return topicDocs
|
||||
.map((topicDoc) => docsByDocumentId.get(topicDoc.id))
|
||||
.filter((doc): doc is AgentDocumentWithRules => Boolean(doc))
|
||||
.filter((doc) => !sourceType || sourceType === 'all' || doc.sourceType === sourceType)
|
||||
.map((doc) => ({
|
||||
...deriveAgentDocumentFields(doc),
|
||||
description: doc.description,
|
||||
documentId: doc.documentId,
|
||||
fileType: doc.fileType,
|
||||
filename: doc.filename,
|
||||
id: doc.id,
|
||||
loadPosition: doc.policy?.context?.position,
|
||||
parentId: doc.parentId,
|
||||
sourceType: doc.sourceType,
|
||||
templateId: doc.templateId,
|
||||
title: doc.title,
|
||||
updatedAt: doc.updatedAt,
|
||||
}));
|
||||
.filter((doc): doc is AgentDocumentListItem => Boolean(doc));
|
||||
}
|
||||
|
||||
async getDocumentByFilename(agentId: string, filename: string) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { LobeChatDatabase } from '@/database/type';
|
||||
import { AgentRuntimeCoordinator } from '@/server/modules/AgentRuntime/AgentRuntimeCoordinator';
|
||||
|
||||
import { OperationTraceRecorder } from './OperationTraceRecorder';
|
||||
import { createDefaultSnapshotStore } from './snapshotStore';
|
||||
|
||||
const log = debug('lobe-server:abandon-operation');
|
||||
|
||||
@@ -127,25 +128,3 @@ export class AbandonOperationService {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultSnapshotStore(): ISnapshotStore | null {
|
||||
if (process.env.ENABLE_AGENT_S3_TRACING === '1') {
|
||||
try {
|
||||
const { S3SnapshotStore } = require('@/server/modules/AgentTracing');
|
||||
return new S3SnapshotStore();
|
||||
} catch {
|
||||
/* S3SnapshotStore not available */
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
try {
|
||||
const { FileSnapshotStore } = require('@lobechat/agent-tracing');
|
||||
return new FileSnapshotStore();
|
||||
} catch {
|
||||
/* agent-tracing not available */
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
} from './types';
|
||||
|
||||
vi.mock('@lobechat/model-runtime', () => ({
|
||||
// RuntimeExecutors (loaded transitively) resolves extend params via this
|
||||
// helper; an empty result keeps the runtime payload unchanged.
|
||||
applyModelExtendParams: vi.fn(() => ({})),
|
||||
getModelPropertyWithFallback: vi.fn(),
|
||||
// `llmErrorClassification.ts` reads these at module-load time; an empty
|
||||
// spec map is fine here because this suite never exercises the runtime
|
||||
@@ -1696,7 +1699,7 @@ describe('AgentRuntimeService', () => {
|
||||
expect(casSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('arms a one-shot verify when the parent has not parked yet and scheduleVerifyOnHold is set', async () => {
|
||||
it('arms the first verify (attempt 1, 15s) when the parent has not parked yet and scheduleVerifyOnHold is set', async () => {
|
||||
// Child completed before the parent's parking step persisted its state.
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({
|
||||
pendingToolsCalling: [],
|
||||
@@ -1712,14 +1715,15 @@ describe('AgentRuntimeService', () => {
|
||||
expect(won).toBe(false);
|
||||
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
delay: 15_000,
|
||||
operationId: parentOpId,
|
||||
payload: { verifyAsyncToolBarrier: true },
|
||||
payload: { asyncToolVerifyAttempt: 1, verifyAsyncToolBarrier: true },
|
||||
stepIndex: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('arms a one-shot verify when the barrier is unsatisfied and scheduleVerifyOnHold is set', async () => {
|
||||
it('arms a verify when the barrier is unsatisfied and scheduleVerifyOnHold is set', async () => {
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({
|
||||
pendingToolsCalling: [{ id: 'tc1' }],
|
||||
status: 'waiting_for_async_tool',
|
||||
@@ -1736,7 +1740,104 @@ describe('AgentRuntimeService', () => {
|
||||
|
||||
expect(won).toBe(false);
|
||||
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payload: { verifyAsyncToolBarrier: true } }),
|
||||
expect.objectContaining({
|
||||
payload: { asyncToolVerifyAttempt: 1, verifyAsyncToolBarrier: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-arms the next verify with exponential backoff while the barrier holds', async () => {
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({
|
||||
pendingToolsCalling: [{ id: 'tc1' }],
|
||||
status: 'waiting_for_async_tool',
|
||||
stepCount: 1,
|
||||
});
|
||||
(service as any).serverDB.query = {
|
||||
messagePlugins: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
};
|
||||
|
||||
// A verify handler running as attempt 2 re-arms attempt 3 (60s).
|
||||
await service.tryResumeParentFromAsyncTool(
|
||||
{ parentOperationId: parentOpId },
|
||||
{ scheduleVerifyOnHold: true, verifyAttempt: 3 },
|
||||
);
|
||||
|
||||
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
delay: 60_000,
|
||||
payload: { asyncToolVerifyAttempt: 3, verifyAsyncToolBarrier: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stops re-arming once the bounded attempts are exhausted', async () => {
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({
|
||||
pendingToolsCalling: [{ id: 'tc1' }],
|
||||
status: 'waiting_for_async_tool',
|
||||
stepCount: 1,
|
||||
});
|
||||
(service as any).serverDB.query = {
|
||||
messagePlugins: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
};
|
||||
|
||||
const won = await service.tryResumeParentFromAsyncTool(
|
||||
{ parentOperationId: parentOpId },
|
||||
{ scheduleVerifyOnHold: true, verifyAttempt: 6 },
|
||||
);
|
||||
|
||||
expect(won).toBe(false);
|
||||
expect(mockQueueService.scheduleMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trusts a just-backfilled message id without re-reading it (read-your-writes)', async () => {
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({
|
||||
pendingToolsCalling: [{ id: 'tc1' }],
|
||||
status: 'waiting_for_async_tool',
|
||||
stepCount: 3,
|
||||
});
|
||||
// Plugin row exists (created at park) but its state still reads stale.
|
||||
const findById = vi.fn().mockResolvedValue({ content: '' });
|
||||
(service as any).serverDB.query = {
|
||||
messagePlugins: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: 'msg-tc1', state: null, toolCallId: 'tc1' }),
|
||||
},
|
||||
};
|
||||
(service as any).messageModel.findById = findById;
|
||||
const casSpy = vi
|
||||
.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const won = await service.tryResumeParentFromAsyncTool(
|
||||
{ parentOperationId: parentOpId },
|
||||
{ knownFulfilledMessageId: 'msg-tc1' },
|
||||
);
|
||||
|
||||
expect(won).toBe(true);
|
||||
expect(casSpy).toHaveBeenCalledWith(parentOpId);
|
||||
// The stale read must be skipped — barrier trusted the local backfill.
|
||||
expect(findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('arms a fallback verify when a parked op has no pending tools', async () => {
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({
|
||||
pendingToolsCalling: [],
|
||||
status: 'waiting_for_async_tool',
|
||||
stepCount: 4,
|
||||
});
|
||||
const casSpy = vi.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool');
|
||||
|
||||
const won = await service.tryResumeParentFromAsyncTool(
|
||||
{ parentOperationId: parentOpId },
|
||||
{ scheduleVerifyOnHold: true },
|
||||
);
|
||||
|
||||
expect(won).toBe(false);
|
||||
expect(casSpy).not.toHaveBeenCalled();
|
||||
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: { asyncToolVerifyAttempt: 1, verifyAsyncToolBarrier: true },
|
||||
stepIndex: 4,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1755,6 +1856,186 @@ describe('AgentRuntimeService', () => {
|
||||
expect(won).toBe(false);
|
||||
expect(mockQueueService.scheduleMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedules a finish step when the parked tool requests onComplete=finish (skipCallSupervisor / delegate)', async () => {
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({
|
||||
pendingToolsCalling: [{ id: 'tc1' }],
|
||||
status: 'waiting_for_async_tool',
|
||||
stepCount: 4,
|
||||
});
|
||||
(service as any).serverDB.query = {
|
||||
messagePlugins: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: 'msg-tc1',
|
||||
state: { onComplete: 'finish', status: 'completed' },
|
||||
toolCallId: 'tc1',
|
||||
}),
|
||||
},
|
||||
};
|
||||
(service as any).messageModel.findById = vi.fn().mockResolvedValue({ content: 'answer' });
|
||||
vi.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool').mockResolvedValue(true);
|
||||
|
||||
const won = await service.tryResumeParentFromAsyncTool({ parentOperationId: parentOpId });
|
||||
|
||||
expect(won).toBe(true);
|
||||
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payload: { finishAfterAsyncTool: true }, stepIndex: 4 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeGroupActionMember', () => {
|
||||
const memberState = {
|
||||
messages: [
|
||||
{ content: 'question', role: 'user' },
|
||||
{ content: 'final answer', role: 'assistant' },
|
||||
],
|
||||
metadata: { agentId: 'agent-a' },
|
||||
modelRuntimeConfig: { model: 'gpt-test' },
|
||||
status: 'done',
|
||||
usage: { llm: { tokens: { total: 42 } }, tools: { totalCalls: 2 } },
|
||||
};
|
||||
|
||||
let updateToolMessage: ReturnType<typeof vi.fn>;
|
||||
let resumeSpy: MockInstance<AgentRuntimeService['tryResumeParentFromAsyncTool']>;
|
||||
|
||||
beforeEach(() => {
|
||||
updateToolMessage = vi.fn().mockResolvedValue({ success: true });
|
||||
(service as any).messageModel.updateToolMessage = updateToolMessage;
|
||||
resumeSpy = vi.spyOn(service, 'tryResumeParentFromAsyncTool').mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('single in-group member: backfills a receipt onto the group tool and resumes', async () => {
|
||||
const won = await service.completeGroupActionMember({
|
||||
anchorMessageId: 'grp-tool-1',
|
||||
expectedMembers: 1,
|
||||
finalState: memberState as any,
|
||||
groupToolMessageId: 'grp-tool-1',
|
||||
mode: 'in_group',
|
||||
onComplete: 'resume',
|
||||
operationId: 'child-1',
|
||||
parentOperationId: 'parent-1',
|
||||
reason: 'done',
|
||||
});
|
||||
|
||||
expect(won).toBe(true);
|
||||
expect(updateToolMessage).toHaveBeenCalledWith(
|
||||
'grp-tool-1',
|
||||
expect.objectContaining({
|
||||
content: 'Agent agent-a responded in the group.',
|
||||
pluginState: expect.objectContaining({ status: 'completed' }),
|
||||
}),
|
||||
);
|
||||
expect(resumeSpy).toHaveBeenCalledWith(
|
||||
{ parentOperationId: 'parent-1' },
|
||||
{ scheduleVerifyOnHold: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('single isolated member: backfills the final answer', async () => {
|
||||
await service.completeGroupActionMember({
|
||||
anchorMessageId: 'grp-tool-1',
|
||||
expectedMembers: 1,
|
||||
finalState: memberState as any,
|
||||
groupToolMessageId: 'grp-tool-1',
|
||||
mode: 'isolated',
|
||||
onComplete: 'resume',
|
||||
operationId: 'child-1',
|
||||
parentOperationId: 'parent-1',
|
||||
reason: 'done',
|
||||
});
|
||||
|
||||
expect(updateToolMessage).toHaveBeenCalledWith(
|
||||
'grp-tool-1',
|
||||
expect.objectContaining({ content: 'final answer' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('multi-member: holds (no group-tool backfill, no resume) until the barrier is met', async () => {
|
||||
(service as any).serverDB.query = {
|
||||
messagePlugins: { findFirst: vi.fn() },
|
||||
messages: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ content: 'a note', id: 'anchor-0', role: 'tool' }]),
|
||||
},
|
||||
};
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({
|
||||
status: 'waiting_for_async_tool',
|
||||
stepCount: 1,
|
||||
});
|
||||
|
||||
const won = await service.completeGroupActionMember({
|
||||
anchorMessageId: 'anchor-0',
|
||||
expectedMembers: 2,
|
||||
finalState: memberState as any,
|
||||
groupToolMessageId: 'grp-tool-1',
|
||||
mode: 'in_group',
|
||||
onComplete: 'resume',
|
||||
operationId: 'child-1',
|
||||
parentOperationId: 'parent-1',
|
||||
reason: 'done',
|
||||
});
|
||||
|
||||
expect(won).toBe(false);
|
||||
expect(updateToolMessage).toHaveBeenCalledWith('anchor-0', expect.anything());
|
||||
expect(updateToolMessage).not.toHaveBeenCalledWith('grp-tool-1', expect.anything());
|
||||
expect(resumeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('multi-member: last completion backfills the group tool and resumes', async () => {
|
||||
(service as any).serverDB.query = {
|
||||
messagePlugins: { findFirst: vi.fn() },
|
||||
messages: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ content: 'a', id: 'anchor-0', role: 'tool' },
|
||||
{ content: 'b', id: 'anchor-1', role: 'tool' },
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const won = await service.completeGroupActionMember({
|
||||
anchorMessageId: 'anchor-1',
|
||||
expectedMembers: 2,
|
||||
finalState: memberState as any,
|
||||
groupToolMessageId: 'grp-tool-1',
|
||||
mode: 'in_group',
|
||||
onComplete: 'resume',
|
||||
operationId: 'child-2',
|
||||
parentOperationId: 'parent-1',
|
||||
reason: 'done',
|
||||
});
|
||||
|
||||
expect(won).toBe(true);
|
||||
expect(updateToolMessage).toHaveBeenCalledWith('anchor-1', expect.anything());
|
||||
expect(updateToolMessage).toHaveBeenCalledWith(
|
||||
'grp-tool-1',
|
||||
expect.objectContaining({
|
||||
content: 'All 2 agent members completed.',
|
||||
pluginState: expect.objectContaining({ status: 'completed' }),
|
||||
}),
|
||||
);
|
||||
expect(resumeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the anchor backfill fails so the webhook redelivers', async () => {
|
||||
updateToolMessage.mockResolvedValue({ success: false });
|
||||
|
||||
await expect(
|
||||
service.completeGroupActionMember({
|
||||
anchorMessageId: 'grp-tool-1',
|
||||
expectedMembers: 1,
|
||||
finalState: memberState as any,
|
||||
groupToolMessageId: 'grp-tool-1',
|
||||
mode: 'in_group',
|
||||
onComplete: 'resume',
|
||||
operationId: 'child-1',
|
||||
parentOperationId: 'parent-1',
|
||||
reason: 'done',
|
||||
}),
|
||||
).rejects.toThrow(/failed to backfill anchor/);
|
||||
expect(resumeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeSubAgentBridge', () => {
|
||||
@@ -1805,7 +2086,7 @@ describe('AgentRuntimeService', () => {
|
||||
});
|
||||
expect(resumeSpy).toHaveBeenCalledWith(
|
||||
{ parentOperationId: 'parent-op-1' },
|
||||
{ scheduleVerifyOnHold: true },
|
||||
{ knownFulfilledMessageId: 'tool-msg-1', scheduleVerifyOnHold: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1859,4 +2140,93 @@ describe('AgentRuntimeService', () => {
|
||||
expect(resumeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAsyncToolOnComplete', () => {
|
||||
it('returns finish when ANY pending tool requests finish (not just the first)', async () => {
|
||||
// First pending tool resumes; a later one is a group finish action. The
|
||||
// disposition must scan all pending tools, not only pending[0].
|
||||
(service as any).serverDB.query = {
|
||||
messagePlugins: {
|
||||
findFirst: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ state: { status: 'completed' } })
|
||||
.mockResolvedValueOnce({ state: { onComplete: 'finish', status: 'completed' } }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await (service as any).resolveAsyncToolOnComplete([
|
||||
{ id: 'tc1' },
|
||||
{ id: 'tc2' },
|
||||
]);
|
||||
|
||||
expect(result).toBe('finish');
|
||||
});
|
||||
|
||||
it('returns resume when no pending tool requests finish', async () => {
|
||||
(service as any).serverDB.query = {
|
||||
messagePlugins: {
|
||||
findFirst: vi.fn().mockResolvedValue({ state: { status: 'completed' } }),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await (service as any).resolveAsyncToolOnComplete([
|
||||
{ id: 'tc1' },
|
||||
{ id: 'tc2' },
|
||||
]);
|
||||
|
||||
expect(result).toBe('resume');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group member timeout watchdog', () => {
|
||||
const timeoutParams = {
|
||||
anchorMessageId: 'anchor-1',
|
||||
expectedMembers: 1,
|
||||
groupToolMessageId: 'grp-tool-1',
|
||||
memberOperationId: 'member-op-1',
|
||||
mode: 'isolated' as const,
|
||||
onComplete: 'resume' as const,
|
||||
parentOperationId: 'parent-1',
|
||||
};
|
||||
|
||||
it('no-ops when the member already reached a terminal state', async () => {
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({ status: 'done' });
|
||||
const interruptSpy = vi.spyOn(service, 'interruptOperation');
|
||||
const bridgeSpy = vi.spyOn(service, 'completeGroupActionMember');
|
||||
|
||||
const result = await service.executeStep({
|
||||
groupMemberTimeout: timeoutParams,
|
||||
operationId: 'member-op-1',
|
||||
stepIndex: 0,
|
||||
} as any);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.nextStepScheduled).toBe(false);
|
||||
expect(interruptSpy).not.toHaveBeenCalled();
|
||||
expect(bridgeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('interrupts the member and bridges a timeout when it is still running', async () => {
|
||||
mockCoordinator.loadAgentState.mockResolvedValue({ status: 'running' });
|
||||
const interruptSpy = vi.spyOn(service, 'interruptOperation').mockResolvedValue(true);
|
||||
const bridgeSpy = vi.spyOn(service, 'completeGroupActionMember').mockResolvedValue(true);
|
||||
|
||||
const result = await service.executeStep({
|
||||
groupMemberTimeout: timeoutParams,
|
||||
operationId: 'member-op-1',
|
||||
stepIndex: 0,
|
||||
} as any);
|
||||
|
||||
expect(interruptSpy).toHaveBeenCalledWith('member-op-1');
|
||||
expect(bridgeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onComplete: 'resume',
|
||||
operationId: 'member-op-1',
|
||||
parentOperationId: 'parent-1',
|
||||
reason: 'timeout',
|
||||
}),
|
||||
);
|
||||
expect(result.nextStepScheduled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
trace as otelTrace,
|
||||
} from '@lobechat/observability-otel/api';
|
||||
import {
|
||||
asyncToolResumeCounter,
|
||||
buildInvokeAgentAttributes,
|
||||
buildInvokeAgentResultAttributes,
|
||||
invokeAgentSpanName,
|
||||
@@ -61,10 +62,16 @@ import { CompletionLifecycle } from './CompletionLifecycle';
|
||||
import { hookDispatcher } from './hooks';
|
||||
import { HumanInterventionHandler } from './HumanInterventionHandler';
|
||||
import { OperationTraceRecorder } from './OperationTraceRecorder';
|
||||
import { createDefaultSnapshotStore } from './snapshotStore';
|
||||
import { buildStepPresentation, formatTokenCount } from './stepPresentation';
|
||||
import {
|
||||
type AgentExecutionParams,
|
||||
type AgentExecutionResult,
|
||||
type ExecGroupMemberParams,
|
||||
type ExecGroupMemberResult,
|
||||
type GroupActionMemberBridgeParams,
|
||||
type GroupActionOnComplete,
|
||||
type GroupMemberTimeoutParams,
|
||||
type OperationCreationParams,
|
||||
type OperationCreationResult,
|
||||
type OperationStatusResult,
|
||||
@@ -83,13 +90,37 @@ if (process.env.VERCEL) {
|
||||
const log = debug('lobe-server:agent-runtime-service');
|
||||
|
||||
/**
|
||||
* Delay before a one-shot `verifyAsyncToolBarrier` re-check fires after a
|
||||
* Base delay before the first `verifyAsyncToolBarrier` re-check fires after a
|
||||
* sub-agent completion found the parent not yet resumable. Long enough for
|
||||
* the parent's parking step to finish persisting, short enough that a lost
|
||||
* resume is recovered promptly.
|
||||
* resume is recovered promptly. Subsequent attempts back off exponentially —
|
||||
* see {@link asyncToolVerifyDelayMs}.
|
||||
*/
|
||||
const ASYNC_TOOL_VERIFY_DELAY_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Maximum number of bounded watchdog re-checks armed per parked parent. The
|
||||
* watchdog re-arms after each unsatisfied check (instead of the old single
|
||||
* shot) so a transient miss — a read-replica lag, a sibling dying between
|
||||
* backfill and resume — is retried rather than leaving the parent stuck in
|
||||
* `waiting_for_async_tool` forever. With exponential backoff from a 15s base,
|
||||
* 5 attempts span ~15s → ~7.75min total before giving up. For details see: async sub-agent suspend/resume stability hardening — bounded watchdog retry with exponential backoff instead of single-shot verification.
|
||||
*/
|
||||
const ASYNC_TOOL_VERIFY_MAX_ATTEMPTS = 5;
|
||||
|
||||
/** Hard ceiling on a single backoff delay so late attempts don't overshoot. */
|
||||
const ASYNC_TOOL_VERIFY_MAX_DELAY_MS = 240_000;
|
||||
|
||||
/**
|
||||
* Exponential backoff delay for the Nth (1-based) watchdog re-check:
|
||||
* 15s, 30s, 60s, 120s, 240s, capped at {@link ASYNC_TOOL_VERIFY_MAX_DELAY_MS}.
|
||||
*/
|
||||
const asyncToolVerifyDelayMs = (attempt: number): number =>
|
||||
Math.min(
|
||||
ASYNC_TOOL_VERIFY_DELAY_MS * 2 ** (Math.max(1, attempt) - 1),
|
||||
ASYNC_TOOL_VERIFY_MAX_DELAY_MS,
|
||||
);
|
||||
|
||||
/**
|
||||
* Format error for storage in message pluginError metadata.
|
||||
* Handles Error objects which don't serialize properly with JSON.stringify.
|
||||
@@ -130,6 +161,13 @@ const toAgentSignalSnapshotEvents = (
|
||||
* top-level option. One named home for the whole upward-call surface.
|
||||
*/
|
||||
export interface AgentRuntimeDelegate {
|
||||
/**
|
||||
* Fork a group member ("call agent member") under a `lobe-group-management`
|
||||
* tool call. Handles both in-group (non-isolated, shared group session) and
|
||||
* isolated members, installing the group-action member completion bridge that
|
||||
* enforces the K=N member barrier before resuming/finishing the supervisor.
|
||||
*/
|
||||
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
|
||||
/**
|
||||
* Run a legacy agent invocation through the full high-level pipeline
|
||||
* (AiAgentService.execSubAgent → execAgent: agent-config resolution, tool
|
||||
@@ -256,7 +294,7 @@ export class AgentRuntimeService {
|
||||
this.queueService =
|
||||
options?.queueService === null ? null : (options?.queueService ?? new QueueService());
|
||||
this.traceRecorder = new OperationTraceRecorder(
|
||||
options?.snapshotStore ?? this.createDefaultSnapshotStore(),
|
||||
options?.snapshotStore ?? createDefaultSnapshotStore(),
|
||||
);
|
||||
this.agentFactory = options?.agentFactory;
|
||||
this.delegate = options?.delegate ?? {};
|
||||
@@ -587,17 +625,39 @@ export class AgentRuntimeService {
|
||||
rejectionReason,
|
||||
rejectAndContinue,
|
||||
resumeAsyncTool,
|
||||
finishAfterAsyncTool,
|
||||
groupMemberTimeout,
|
||||
toolMessageId,
|
||||
verifyAsyncToolBarrier,
|
||||
asyncToolVerifyAttempt,
|
||||
externalRetryCount = 0,
|
||||
} = params;
|
||||
|
||||
// Group member timeout watchdog: enforce a member's deadline without claiming
|
||||
// the step lock. No-op if the member already finished; otherwise interrupt it
|
||||
// and bridge a `timeout` completion so the parked supervisor resumes/finishes.
|
||||
if (groupMemberTimeout) {
|
||||
return this.handleGroupMemberTimeout(groupMemberTimeout);
|
||||
}
|
||||
|
||||
// Watchdog re-check for a parked async-tool wait: re-run the barrier + CAS
|
||||
// without claiming the step lock or executing anything. Idempotent — the
|
||||
// CAS guarantees at most one real resume regardless of how many checks run.
|
||||
// Opt back into `scheduleVerifyOnHold` with the next attempt so an
|
||||
// unsatisfied barrier re-arms (bounded backoff) instead of giving up after
|
||||
// a single shot — bounded watchdog retry ensures transient misses are recovered.
|
||||
if (verifyAsyncToolBarrier) {
|
||||
log('[%s][%d] Running async-tool barrier verify', operationId, stepIndex);
|
||||
const resumed = await this.tryResumeParentFromAsyncTool({ parentOperationId: operationId });
|
||||
const attempt = asyncToolVerifyAttempt ?? 1;
|
||||
log(
|
||||
'[%s][%d] Running async-tool barrier verify (attempt %d)',
|
||||
operationId,
|
||||
stepIndex,
|
||||
attempt,
|
||||
);
|
||||
const resumed = await this.tryResumeParentFromAsyncTool(
|
||||
{ parentOperationId: operationId },
|
||||
{ scheduleVerifyOnHold: true, verifyAttempt: attempt + 1 },
|
||||
);
|
||||
return {
|
||||
nextStepScheduled: resumed,
|
||||
state: {},
|
||||
@@ -848,6 +908,29 @@ export class AgentRuntimeService {
|
||||
);
|
||||
}
|
||||
|
||||
// Finish a parked supervisor op WITHOUT another LLM turn (group
|
||||
// orchestration skipCallSupervisor / delegate). Refresh messages so the
|
||||
// final group conversation is captured, transition straight to `done`,
|
||||
// and let the standard `!shouldContinue` finalization below record
|
||||
// completion + dispatch hooks. Skips runtime.step entirely.
|
||||
let forcedFinishState: AgentState | undefined;
|
||||
if (finishAfterAsyncTool && currentState.status === 'waiting_for_async_tool') {
|
||||
const refreshed = await this.refreshMessagesFromDB(currentState);
|
||||
currentState = structuredClone(currentState);
|
||||
currentState.messages = refreshed;
|
||||
currentState.pendingToolsCalling = [];
|
||||
currentState.status = 'done';
|
||||
currentState.interruption = undefined;
|
||||
currentState.lastModified = new Date().toISOString();
|
||||
forcedFinishState = currentState;
|
||||
log(
|
||||
'[%s][%d] Finishing parked supervisor op after async tool (%d messages)',
|
||||
operationId,
|
||||
stepIndex,
|
||||
refreshed.length,
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-step computation: extract device context from DB messages
|
||||
// Follows front-end computeStepContext pattern — computed at step boundary, not inside executors
|
||||
if (!currentState.metadata?.activeDeviceId) {
|
||||
@@ -865,9 +948,11 @@ export class AgentRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Execute step
|
||||
// Execute step (skipped when force-finishing a parked supervisor op).
|
||||
const startAt = Date.now();
|
||||
const stepResult = await runtime.step(currentState, currentContext);
|
||||
const stepResult = forcedFinishState
|
||||
? { events: [], newState: forcedFinishState, nextContext: undefined }
|
||||
: await runtime.step(currentState, currentContext);
|
||||
|
||||
// Inner runtime.step() catches model-runtime exceptions and stuffs the
|
||||
// raw error into newState.error without re-throwing — so the outer
|
||||
@@ -1626,12 +1711,35 @@ export class AgentRuntimeService {
|
||||
*/
|
||||
async tryResumeParentFromAsyncTool(
|
||||
params: { parentOperationId: string },
|
||||
options?: { scheduleVerifyOnHold?: boolean },
|
||||
options?: {
|
||||
/**
|
||||
* Message id of a tool placeholder the caller just backfilled to a
|
||||
* terminal state. Trusted by the barrier as fulfilled without re-reading
|
||||
* `message_plugins` — closes the read-your-writes gap where the barrier
|
||||
* query hits a read replica that hasn't seen the just-committed write.
|
||||
*/
|
||||
knownFulfilledMessageId?: string;
|
||||
/**
|
||||
* Group orchestration disposition (skipCallSupervisor / delegate → finish).
|
||||
* When omitted, resolved from the parked tool message's pluginState.
|
||||
*/
|
||||
onComplete?: GroupActionOnComplete;
|
||||
scheduleVerifyOnHold?: boolean;
|
||||
/** 1-based watchdog attempt to arm when the parent isn't resumable yet. */
|
||||
verifyAttempt?: number;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const { parentOperationId } = params;
|
||||
|
||||
const state = await this.coordinator.loadAgentState(parentOperationId);
|
||||
if (!state) return false;
|
||||
if (!state) {
|
||||
// State expired (Redis TTL) or never persisted — nothing left to resume.
|
||||
// Surface it: a missing state at completion time is how a parent silently
|
||||
// strands. There is no stepCount/status to arm a verify against.
|
||||
log('[%s] async-tool resume: parent state missing/expired, cannot resume', parentOperationId);
|
||||
asyncToolResumeCounter.add(1, { outcome: 'no_state' });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.status !== 'waiting_for_async_tool') {
|
||||
// Not parked (yet). Either the op already resumed/finished — nothing to
|
||||
@@ -1642,26 +1750,58 @@ export class AgentRuntimeService {
|
||||
}
|
||||
|
||||
const pending = (state.pendingToolsCalling ?? []) as ChatToolPayload[];
|
||||
if (pending.length === 0) return false;
|
||||
|
||||
// Barrier: every pending tool must have a fulfilled tool_result message.
|
||||
const allFulfilled = await this.allPendingToolsFulfilled(pending);
|
||||
if (!allFulfilled) {
|
||||
log('[%s] async-tool barrier not yet satisfied, holding', parentOperationId);
|
||||
if (pending.length === 0) {
|
||||
// Parked but no pending tools recorded — usually the parked snapshot's
|
||||
// `pendingToolsCalling` hasn't finished persisting yet. Warn, report, and
|
||||
// arm a fallback re-check rather than returning silently (the old bug).
|
||||
log(
|
||||
'[%s] async-tool resume: parked op has no pending tools, arming fallback',
|
||||
parentOperationId,
|
||||
);
|
||||
asyncToolResumeCounter.add(1, { outcome: 'no_pending' });
|
||||
await this.maybeScheduleAsyncToolVerify(parentOperationId, state, options);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Barrier: every pending tool must have a fulfilled tool_result message.
|
||||
const allFulfilled = await this.allPendingToolsFulfilled(
|
||||
pending,
|
||||
options?.knownFulfilledMessageId,
|
||||
);
|
||||
if (!allFulfilled) {
|
||||
log('[%s] async-tool barrier not yet satisfied, holding', parentOperationId);
|
||||
asyncToolResumeCounter.add(1, { outcome: 'barrier_held' });
|
||||
await this.maybeScheduleAsyncToolVerify(parentOperationId, state, options);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Group orchestration's skipCallSupervisor / delegate ends the supervisor
|
||||
// op without another LLM turn: the same CAS gate flips the parked op, but
|
||||
// the scheduled step finishes it (`finishAfterAsyncTool`) instead of
|
||||
// re-entering the LLM (`resumeAsyncTool`). Self-describing so the generic
|
||||
// verify watchdog resolves it correctly: the option (if any) wins, else the
|
||||
// hint persisted on the parked tool message's pluginState, else resume.
|
||||
const onComplete: GroupActionOnComplete =
|
||||
options?.onComplete ?? (await this.resolveAsyncToolOnComplete(pending));
|
||||
|
||||
// Single-fire guard: only one concurrent completion flips the op.
|
||||
const won = await new AgentOperationModel(this.serverDB, this.userId).tryResumeFromAsyncTool(
|
||||
parentOperationId,
|
||||
);
|
||||
if (!won) {
|
||||
log('[%s] lost async-tool resume CAS, no-op', parentOperationId);
|
||||
asyncToolResumeCounter.add(1, { outcome: 'lost_cas' });
|
||||
return false;
|
||||
}
|
||||
|
||||
log('[%s] won async-tool resume CAS, scheduling step %d', parentOperationId, state.stepCount);
|
||||
asyncToolResumeCounter.add(1, { outcome: 'resumed' });
|
||||
|
||||
log(
|
||||
'[%s] won async-tool resume CAS, scheduling step %d (onComplete: %s)',
|
||||
parentOperationId,
|
||||
state.stepCount,
|
||||
onComplete,
|
||||
);
|
||||
|
||||
if (this.queueService) {
|
||||
await this.queueService.scheduleMessage({
|
||||
@@ -1669,7 +1809,8 @@ export class AgentRuntimeService {
|
||||
delay: 100,
|
||||
endpoint: `${this.baseURL}/run`,
|
||||
operationId: parentOperationId,
|
||||
payload: { resumeAsyncTool: true },
|
||||
payload:
|
||||
onComplete === 'finish' ? { finishAfterAsyncTool: true } : { resumeAsyncTool: true },
|
||||
priority: 'high',
|
||||
stepIndex: state.stepCount,
|
||||
});
|
||||
@@ -1681,36 +1822,60 @@ export class AgentRuntimeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm a one-shot delayed `verifyAsyncToolBarrier` re-check for a parent op
|
||||
* whose resume attempt found it not yet resumable. Skipped for terminal
|
||||
* states (nothing left to resume) and when the caller didn't opt in — the
|
||||
* verify execution itself never re-arms, keeping retries bounded to one
|
||||
* per completion event.
|
||||
* Arm the next bounded `verifyAsyncToolBarrier` re-check for a parent op whose
|
||||
* resume attempt found it not yet resumable. Skipped for terminal states
|
||||
* (nothing left to resume) and when the caller didn't opt in.
|
||||
*
|
||||
* Unlike the original single shot, the watchdog re-arms after each unsatisfied
|
||||
* check: the verify handler re-enters here with `verifyAttempt + 1`, backing
|
||||
* off exponentially up to {@link ASYNC_TOOL_VERIFY_MAX_ATTEMPTS}. A transient
|
||||
* miss (read-replica lag, a sibling dying between backfill and resume) is thus
|
||||
* retried instead of permanently stranding the parent. Once attempts are
|
||||
* exhausted the chain stops and the `verify_exhausted` metric fires so the
|
||||
* orphan is observable. For details see: async sub-agent suspend/resume stability hardening — bounded watchdog retry with exponential backoff.
|
||||
*/
|
||||
private async maybeScheduleAsyncToolVerify(
|
||||
parentOperationId: string,
|
||||
state: AgentState,
|
||||
options?: { scheduleVerifyOnHold?: boolean },
|
||||
options?: { scheduleVerifyOnHold?: boolean; verifyAttempt?: number },
|
||||
): Promise<void> {
|
||||
if (!options?.scheduleVerifyOnHold || !this.queueService) return;
|
||||
|
||||
const status = state.status as string;
|
||||
if (status === 'done' || status === 'error' || status === 'interrupted') return;
|
||||
|
||||
const attempt = options.verifyAttempt ?? 1;
|
||||
if (attempt > ASYNC_TOOL_VERIFY_MAX_ATTEMPTS) {
|
||||
// Bounded retries spent and the parent is still not resumable — give up
|
||||
// re-arming and report so the stuck wait can be detected, not silently
|
||||
// accumulated.
|
||||
log(
|
||||
'[%s] async-tool barrier verify exhausted after %d attempts, giving up (status: %s)',
|
||||
parentOperationId,
|
||||
ASYNC_TOOL_VERIFY_MAX_ATTEMPTS,
|
||||
status,
|
||||
);
|
||||
asyncToolResumeCounter.add(1, { outcome: 'verify_exhausted' });
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = asyncToolVerifyDelayMs(attempt);
|
||||
log(
|
||||
'[%s] scheduling async-tool barrier verify in %dms (status: %s)',
|
||||
'[%s] scheduling async-tool barrier verify attempt %d/%d in %dms (status: %s)',
|
||||
parentOperationId,
|
||||
ASYNC_TOOL_VERIFY_DELAY_MS,
|
||||
attempt,
|
||||
ASYNC_TOOL_VERIFY_MAX_ATTEMPTS,
|
||||
delay,
|
||||
status,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.queueService.scheduleMessage({
|
||||
context: undefined,
|
||||
delay: ASYNC_TOOL_VERIFY_DELAY_MS,
|
||||
delay,
|
||||
endpoint: `${this.baseURL}/run`,
|
||||
operationId: parentOperationId,
|
||||
payload: { verifyAsyncToolBarrier: true },
|
||||
payload: { asyncToolVerifyAttempt: attempt, verifyAsyncToolBarrier: true },
|
||||
priority: 'high',
|
||||
stepIndex: state.stepCount,
|
||||
});
|
||||
@@ -1791,22 +1956,40 @@ export class AgentRuntimeService {
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Barrier + CAS + resume the parent op (infra errors propagate too)
|
||||
return this.tryResumeParentFromAsyncTool({ parentOperationId }, { scheduleVerifyOnHold: true });
|
||||
// 2. Barrier + CAS + resume the parent op (infra errors propagate too).
|
||||
// Pass the just-backfilled message id so the barrier trusts this write
|
||||
// instead of re-reading a possibly-stale replica.
|
||||
return this.tryResumeParentFromAsyncTool(
|
||||
{ parentOperationId },
|
||||
{ knownFulfilledMessageId: toolMessageId, scheduleVerifyOnHold: true },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every pending tool call has a fulfilled tool_result message — i.e.
|
||||
* a tool message exists for its `tool_call_id` with non-empty content or a
|
||||
* terminal pluginState. Looks up by `tool_call_id` (plugin id === message id).
|
||||
*
|
||||
* `knownFulfilledMessageId` short-circuits the per-tool content/state read for
|
||||
* a placeholder the caller just backfilled in the same request: its terminal
|
||||
* write is a local fact, so re-reading it (possibly from a lagging read
|
||||
* replica) would only risk a false negative that strands the parent. The
|
||||
* plugin row itself predates the park, so the `tool_call_id → plugin.id`
|
||||
* lookup still resolves; only the freshly written content/state is trusted.
|
||||
*/
|
||||
private async allPendingToolsFulfilled(pending: ChatToolPayload[]): Promise<boolean> {
|
||||
private async allPendingToolsFulfilled(
|
||||
pending: ChatToolPayload[],
|
||||
knownFulfilledMessageId?: string,
|
||||
): Promise<boolean> {
|
||||
for (const tc of pending) {
|
||||
const plugin = await this.serverDB.query.messagePlugins.findFirst({
|
||||
where: (mp, { eq }) => eq(mp.toolCallId, tc.id),
|
||||
});
|
||||
if (!plugin) return false;
|
||||
|
||||
// Trust the caller's own just-committed backfill (read-your-writes).
|
||||
if (knownFulfilledMessageId && plugin.id === knownFulfilledMessageId) continue;
|
||||
|
||||
const message = await this.messageModel.findById(plugin.id);
|
||||
const pluginState = plugin.state as { status?: string } | null;
|
||||
const fulfilled =
|
||||
@@ -1818,6 +2001,252 @@ export class AgentRuntimeService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the resume disposition for a parked op from the disposition hint
|
||||
* persisted on its first pending tool message's pluginState. Group
|
||||
* orchestration stamps `onComplete: 'finish'` there for skipCallSupervisor /
|
||||
* delegate; everything else (sub-agents, client tools) resolves to `resume`.
|
||||
* Self-describing so the generic verify watchdog finishes the right ops.
|
||||
*/
|
||||
private async resolveAsyncToolOnComplete(
|
||||
pending: ChatToolPayload[],
|
||||
): Promise<GroupActionOnComplete> {
|
||||
// A batched turn can park multiple deferred/client tools. If ANY of them is
|
||||
// a group action requesting finish (skipCallSupervisor / delegate), the
|
||||
// orchestration must finish — reading only pending[0] would miss a group
|
||||
// finish call that isn't the first pending tool and wrongly resume.
|
||||
for (const tool of pending) {
|
||||
const plugin = await this.serverDB.query.messagePlugins.findFirst({
|
||||
where: (mp, { eq }) => eq(mp.toolCallId, tool.id),
|
||||
});
|
||||
const pluginState = plugin?.state as { onComplete?: string } | null;
|
||||
if (pluginState?.onComplete === 'finish') return 'finish';
|
||||
}
|
||||
return 'resume';
|
||||
}
|
||||
|
||||
/**
|
||||
* Count fulfilled member anchors under a group-management tool call — child
|
||||
* `role: 'tool'` messages whose content is non-empty or whose pluginState is
|
||||
* terminal. The K=N member barrier for broadcast / executeAgentTasks: the
|
||||
* group tool message is only backfilled (satisfying the parked op's
|
||||
* single-tool barrier) once this reaches the expected member count.
|
||||
*/
|
||||
private async countFulfilledMemberAnchors(groupToolMessageId: string): Promise<number> {
|
||||
const children = await this.serverDB.query.messages.findMany({
|
||||
where: (m, { and, eq }) => and(eq(m.parentId, groupToolMessageId), eq(m.role, 'tool')),
|
||||
});
|
||||
let fulfilled = 0;
|
||||
for (const child of children) {
|
||||
if (child.content && child.content.length > 0) {
|
||||
fulfilled += 1;
|
||||
continue;
|
||||
}
|
||||
const plugin = await this.serverDB.query.messagePlugins.findFirst({
|
||||
where: (mp, { eq }) => eq(mp.id, child.id),
|
||||
});
|
||||
const pluginState = plugin?.state as { status?: string } | null;
|
||||
if (pluginState?.status === 'completed' || pluginState?.status === 'error') fulfilled += 1;
|
||||
}
|
||||
return fulfilled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion bridge for the group orchestration "call agent member" path
|
||||
* (`lobe-group-management`: speak / broadcast / delegate / executeAgentTask(s)).
|
||||
* Mirrors {@link completeSubAgentBridge} but enforces a K=N member barrier:
|
||||
*
|
||||
* 1. Backfill this member's anchor tool message (in_group → a short receipt,
|
||||
* since the member already spoke in the shared group conversation;
|
||||
* isolated → the member's final answer from its hidden thread).
|
||||
* 2. Multi-member actions: hold until every member anchor is fulfilled, then
|
||||
* backfill the supervisor's group tool message so the parked op's
|
||||
* single-tool barrier passes. Single-member actions collapse the anchor
|
||||
* onto the group tool call, so step 1 already satisfies the barrier.
|
||||
* 3. Barrier-check + CAS resume/finish the parked supervisor via
|
||||
* `tryResumeParentFromAsyncTool` (finish disposition read from the group
|
||||
* tool message's pluginState).
|
||||
*
|
||||
* THROWS on infra failure of any backfill so the queue-mode callback returns
|
||||
* non-2xx and QStash redelivers — backfills are idempotent and the resume is
|
||||
* CAS-guarded, so redelivery is safe.
|
||||
*/
|
||||
async completeGroupActionMember(params: GroupActionMemberBridgeParams): Promise<boolean> {
|
||||
const {
|
||||
anchorMessageId,
|
||||
expectedMembers,
|
||||
groupToolMessageId,
|
||||
mode,
|
||||
operationId,
|
||||
parentOperationId,
|
||||
reason,
|
||||
threadId,
|
||||
} = params;
|
||||
const failed = reason === 'error' || reason === 'interrupted' || reason === 'timeout';
|
||||
|
||||
const finalState =
|
||||
params.finalState ?? (await this.coordinator.loadAgentState(operationId)) ?? undefined;
|
||||
|
||||
log(
|
||||
'[%s] group-member bridge → parent %s (mode: %s, reason: %s, %d members)',
|
||||
operationId,
|
||||
parentOperationId,
|
||||
mode,
|
||||
reason,
|
||||
expectedMembers,
|
||||
);
|
||||
|
||||
// 1. Backfill this member's anchor.
|
||||
const messages = Array.isArray(finalState?.messages) ? finalState.messages : [];
|
||||
const lastAssistant = [...messages]
|
||||
.reverse()
|
||||
.find((m: { role?: string }) => m?.role === 'assistant');
|
||||
const agentLabel = (finalState?.metadata?.agentId as string | undefined) ?? 'member';
|
||||
const anchorContent = failed
|
||||
? `Agent member did not complete (${reason}).`
|
||||
: mode === 'in_group'
|
||||
? `Agent ${agentLabel} responded in the group.`
|
||||
: (lastAssistant?.content as string | undefined) ||
|
||||
'Agent member completed without a textual answer.';
|
||||
|
||||
const anchorBackfill = await this.messageModel.updateToolMessage(anchorMessageId, {
|
||||
content: anchorContent,
|
||||
pluginError: failed ? formatErrorForMetadata(finalState?.error) : undefined,
|
||||
pluginState: {
|
||||
model: finalState?.modelRuntimeConfig?.model,
|
||||
status: failed ? 'error' : 'completed',
|
||||
threadId,
|
||||
totalToolCalls: finalState?.usage?.tools?.totalCalls,
|
||||
totalTokens: finalState?.usage?.llm?.tokens?.total,
|
||||
},
|
||||
});
|
||||
if (!anchorBackfill.success) {
|
||||
throw new Error(
|
||||
`Group-member bridge: failed to backfill anchor ${anchorMessageId} for parent ${parentOperationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. K=N member barrier (multi-member actions only — single-member actions
|
||||
// use the group tool call itself as the anchor, already backfilled above).
|
||||
if (expectedMembers > 1 && anchorMessageId !== groupToolMessageId) {
|
||||
const fulfilled = await this.countFulfilledMemberAnchors(groupToolMessageId);
|
||||
if (fulfilled < expectedMembers) {
|
||||
log(
|
||||
'[%s] group-member barrier %d/%d, holding parent %s',
|
||||
operationId,
|
||||
fulfilled,
|
||||
expectedMembers,
|
||||
parentOperationId,
|
||||
);
|
||||
const parentState = await this.coordinator.loadAgentState(parentOperationId);
|
||||
if (parentState) {
|
||||
await this.maybeScheduleAsyncToolVerify(parentOperationId, parentState, {
|
||||
scheduleVerifyOnHold: true,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// All members done — backfill the group tool call so the parked op's
|
||||
// single-tool barrier ([groupTool]) passes. Idempotent across racing
|
||||
// last-committers; the resume/finish CAS guarantees one transition.
|
||||
const groupBackfill = await this.messageModel.updateToolMessage(groupToolMessageId, {
|
||||
content: `All ${expectedMembers} agent members completed.`,
|
||||
pluginState: { expectedMembers, status: 'completed' },
|
||||
});
|
||||
if (!groupBackfill.success) {
|
||||
throw new Error(
|
||||
`Group-member bridge: failed to backfill group tool ${groupToolMessageId} for parent ${parentOperationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Barrier + CAS + resume/finish the parked supervisor op.
|
||||
return this.tryResumeParentFromAsyncTool({ parentOperationId }, { scheduleVerifyOnHold: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the group-member timeout watchdog. Fired `delayMs` after the member
|
||||
* op is forked; if the member hasn't finished by then, the watchdog interrupts
|
||||
* it and bridges a `timeout` completion so the parked supervisor doesn't wait
|
||||
* forever. No-op when the queue is disabled or the timeout is non-positive.
|
||||
*/
|
||||
async scheduleGroupMemberTimeout(
|
||||
params: GroupMemberTimeoutParams,
|
||||
delayMs: number,
|
||||
): Promise<void> {
|
||||
if (!this.queueService || !(delayMs > 0)) return;
|
||||
try {
|
||||
await this.queueService.scheduleMessage({
|
||||
context: undefined,
|
||||
delay: delayMs,
|
||||
endpoint: `${this.baseURL}/run`,
|
||||
// Keyed on the member op so the /run worker can resolve userId from its
|
||||
// metadata, same trust chain as every other scheduled step.
|
||||
operationId: params.memberOperationId,
|
||||
payload: { groupMemberTimeout: params },
|
||||
priority: 'normal',
|
||||
stepIndex: 0,
|
||||
});
|
||||
log(
|
||||
'[%s] scheduled group-member timeout in %dms (parent %s)',
|
||||
params.memberOperationId,
|
||||
delayMs,
|
||||
params.parentOperationId,
|
||||
);
|
||||
} catch (error) {
|
||||
log(
|
||||
'[%s] failed to schedule group-member timeout (non-fatal): %O',
|
||||
params.memberOperationId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce a group member's timeout. No-op if the member already reached a
|
||||
* terminal state (its own completion bridge handles that). Otherwise interrupt
|
||||
* the member and bridge a `timeout` completion — backfilling its anchor and
|
||||
* resuming/finishing the parked supervisor via the K=N barrier. The member's
|
||||
* own interrupt bridge may also fire; both are idempotent (anchor rewrite +
|
||||
* CAS-guarded resume).
|
||||
*/
|
||||
private async handleGroupMemberTimeout(
|
||||
params: GroupMemberTimeoutParams,
|
||||
): Promise<AgentExecutionResult> {
|
||||
const state = await this.coordinator.loadAgentState(params.memberOperationId);
|
||||
const status = state?.status as string | undefined;
|
||||
if (!state || status === 'done' || status === 'error' || status === 'interrupted') {
|
||||
log(
|
||||
'[%s] group-member timeout: member already terminal (%s), no-op',
|
||||
params.memberOperationId,
|
||||
status,
|
||||
);
|
||||
return { nextStepScheduled: false, state: {}, success: true };
|
||||
}
|
||||
|
||||
log(
|
||||
'[%s] group-member timeout fired, interrupting + bridging timeout to parent %s',
|
||||
params.memberOperationId,
|
||||
params.parentOperationId,
|
||||
);
|
||||
await this.interruptOperation(params.memberOperationId);
|
||||
|
||||
const resumed = await this.completeGroupActionMember({
|
||||
anchorMessageId: params.anchorMessageId,
|
||||
expectedMembers: params.expectedMembers,
|
||||
finalState: state,
|
||||
groupToolMessageId: params.groupToolMessageId,
|
||||
mode: params.mode,
|
||||
onComplete: params.onComplete,
|
||||
operationId: params.memberOperationId,
|
||||
parentOperationId: params.parentOperationId,
|
||||
reason: 'timeout',
|
||||
});
|
||||
|
||||
return { nextStepScheduled: resumed, state: {}, success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the conversation messages from the database and flatten them for the
|
||||
* runtime. Used when resuming a parked op so the next LLM step sees tool
|
||||
@@ -1951,6 +2380,7 @@ export class AgentRuntimeService {
|
||||
evalContext: metadata?.evalContext,
|
||||
execSubAgent: this.delegate.execSubAgent,
|
||||
execVirtualSubAgent: this.delegate.execVirtualSubAgent,
|
||||
execGroupMember: this.delegate.execGroupMember,
|
||||
hookDispatcher,
|
||||
loadAgentState: this.coordinator.loadAgentState.bind(this.coordinator),
|
||||
messageModel: this.messageModel,
|
||||
@@ -1974,34 +2404,6 @@ export class AgentRuntimeService {
|
||||
return { agent, runtime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default snapshot store based on environment.
|
||||
* - ENABLE_AGENT_S3_TRACING=1 → S3SnapshotStore
|
||||
* - NODE_ENV=development → FileSnapshotStore
|
||||
* - Otherwise → null (no tracing)
|
||||
*/
|
||||
private createDefaultSnapshotStore(): ISnapshotStore | null {
|
||||
if (process.env.ENABLE_AGENT_S3_TRACING === '1') {
|
||||
try {
|
||||
const { S3SnapshotStore } = require('@/server/modules/AgentTracing');
|
||||
return new S3SnapshotStore();
|
||||
} catch {
|
||||
// S3SnapshotStore not available
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
try {
|
||||
const { FileSnapshotStore } = require('@lobechat/agent-tracing');
|
||||
return new FileSnapshotStore();
|
||||
} catch {
|
||||
// agent-tracing not available
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute device context from DB messages at step boundary.
|
||||
* Uses findInMessages visitor to scan tool messages for device activation.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDefaultSnapshotStore, shouldUseAgentS3Tracing } from '../snapshotStore';
|
||||
|
||||
const s3SnapshotStoreMock = vi.fn(() => ({ kind: 's3' }));
|
||||
const fileSnapshotStoreMock = vi.fn(() => ({ kind: 'file' }));
|
||||
|
||||
const setEnv = (nodeEnv: string, agentS3Tracing?: string) => {
|
||||
vi.stubEnv('NODE_ENV', nodeEnv);
|
||||
vi.stubEnv('ENABLE_AGENT_S3_TRACING', agentS3Tracing);
|
||||
};
|
||||
|
||||
const loadModule = vi.fn((moduleName: string) => {
|
||||
if (moduleName === '@/server/modules/AgentTracing') {
|
||||
return { S3SnapshotStore: s3SnapshotStoreMock };
|
||||
}
|
||||
|
||||
if (moduleName === '@lobechat/agent-tracing') {
|
||||
return { FileSnapshotStore: fileSnapshotStoreMock };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected module: ${moduleName}`);
|
||||
});
|
||||
|
||||
describe('agent runtime snapshot store defaults', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('enables S3 tracing by default in production when env is unset', () => {
|
||||
setEnv('production');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(true);
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
|
||||
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the local file snapshot store in development when env is unset', () => {
|
||||
setEnv('development');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(false);
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 'file' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@lobechat/agent-tracing');
|
||||
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(fileSnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets ENABLE_AGENT_S3_TRACING=1 force S3 tracing outside production', () => {
|
||||
setEnv('development', '1');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(true);
|
||||
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
|
||||
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
|
||||
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an explicit ENABLE_AGENT_S3_TRACING value disable the production default', () => {
|
||||
setEnv('production', '0');
|
||||
|
||||
expect(shouldUseAgentS3Tracing()).toBe(false);
|
||||
expect(createDefaultSnapshotStore(loadModule)).toBeNull();
|
||||
expect(loadModule).not.toHaveBeenCalled();
|
||||
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
|
||||
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ISnapshotStore } from '@lobechat/agent-tracing';
|
||||
|
||||
const ENABLE_AGENT_S3_TRACING_VALUE = '1';
|
||||
|
||||
type SnapshotStoreConstructor = new () => ISnapshotStore;
|
||||
type SnapshotStoreModuleLoader = (moduleName: string) => unknown;
|
||||
|
||||
interface FileSnapshotStoreModule {
|
||||
FileSnapshotStore: SnapshotStoreConstructor;
|
||||
}
|
||||
|
||||
interface S3SnapshotStoreModule {
|
||||
S3SnapshotStore: SnapshotStoreConstructor;
|
||||
}
|
||||
|
||||
const nodeRequire: SnapshotStoreModuleLoader = (moduleName) => require(moduleName);
|
||||
|
||||
export const shouldUseAgentS3Tracing = () => {
|
||||
const explicitValue = process.env.ENABLE_AGENT_S3_TRACING;
|
||||
|
||||
if (explicitValue !== undefined) return explicitValue === ENABLE_AGENT_S3_TRACING_VALUE;
|
||||
|
||||
return process.env.NODE_ENV === 'production';
|
||||
};
|
||||
|
||||
/**
|
||||
* Create default snapshot store based on environment.
|
||||
* - ENABLE_AGENT_S3_TRACING=1 -> S3SnapshotStore
|
||||
* - NODE_ENV=production with ENABLE_AGENT_S3_TRACING unset -> S3SnapshotStore
|
||||
* - NODE_ENV=development -> FileSnapshotStore
|
||||
* - Otherwise -> null (no tracing)
|
||||
*/
|
||||
export const createDefaultSnapshotStore = (
|
||||
loadModule: SnapshotStoreModuleLoader = nodeRequire,
|
||||
): ISnapshotStore | null => {
|
||||
if (shouldUseAgentS3Tracing()) {
|
||||
try {
|
||||
const { S3SnapshotStore } = loadModule(
|
||||
'@/server/modules/AgentTracing',
|
||||
) as S3SnapshotStoreModule;
|
||||
return new S3SnapshotStore();
|
||||
} catch {
|
||||
// S3SnapshotStore not available
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
try {
|
||||
const { FileSnapshotStore } = loadModule(
|
||||
'@lobechat/agent-tracing',
|
||||
) as FileSnapshotStoreModule;
|
||||
return new FileSnapshotStore();
|
||||
} catch {
|
||||
// agent-tracing not available
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -121,8 +121,30 @@ export type StepCompletionReason =
|
||||
|
||||
export interface AgentExecutionParams {
|
||||
approvedToolCall?: any;
|
||||
/**
|
||||
* 1-based attempt number carried by a `verifyAsyncToolBarrier` re-check so the
|
||||
* bounded watchdog can back off and stop after a fixed number of tries. Absent
|
||||
* (treated as attempt 1) on the first re-check armed by a completion bridge.
|
||||
*/
|
||||
asyncToolVerifyAttempt?: number;
|
||||
context?: AgentRuntimeContext;
|
||||
externalRetryCount?: number;
|
||||
/**
|
||||
* Finish (rather than resume) a `waiting_for_async_tool` supervisor op after
|
||||
* its group members have completed. Used by `skipCallSupervisor` / delegate in
|
||||
* group orchestration: the orchestration ends without another supervisor LLM
|
||||
* turn. Scheduled by the group-action member barrier via
|
||||
* `tryResumeParentFromAsyncTool({ onComplete: 'finish' })`.
|
||||
*/
|
||||
finishAfterAsyncTool?: boolean;
|
||||
/**
|
||||
* Watchdog payload to enforce a group member's timeout: when the member op
|
||||
* hasn't reached a terminal state by its deadline, interrupt it and bridge a
|
||||
* `timeout` completion so the parked supervisor resumes/finishes instead of
|
||||
* waiting forever. Scheduled by `scheduleGroupMemberTimeout` after the member
|
||||
* op is forked.
|
||||
*/
|
||||
groupMemberTimeout?: GroupMemberTimeoutParams;
|
||||
humanInput?: any;
|
||||
operationId: string;
|
||||
/**
|
||||
@@ -144,10 +166,13 @@ export interface AgentExecutionParams {
|
||||
/**
|
||||
* Watchdog re-check for a parked `waiting_for_async_tool` op: re-runs the
|
||||
* resume barrier + CAS without claiming the step lock or executing a step.
|
||||
* A no-op when the op already resumed or the barrier is still unsatisfied.
|
||||
* Scheduled one-shot by `tryResumeParentFromAsyncTool` when a sub-agent
|
||||
* completion found the parent not yet resumable (covers the
|
||||
* child-finishes-before-parent-parks race and transient barrier failures).
|
||||
* A no-op when the op already resumed. While the barrier is still unsatisfied
|
||||
* it re-arms the next check with exponential backoff (see
|
||||
* `asyncToolVerifyAttempt`) up to a bounded number of attempts, so a transient
|
||||
* miss is retried rather than permanently stranding the parent. First armed by
|
||||
* `tryResumeParentFromAsyncTool` when a sub-agent completion found the parent
|
||||
* not yet resumable (covers the child-finishes-before-parent-parks race and
|
||||
* transient barrier failures).
|
||||
*/
|
||||
verifyAsyncToolBarrier?: boolean;
|
||||
}
|
||||
@@ -180,6 +205,106 @@ export interface SubAgentBridgeParams {
|
||||
toolMessageId: string;
|
||||
}
|
||||
|
||||
// ==================== Group Orchestration (call agent member) ====================
|
||||
|
||||
/** Whether a group member runs in the shared group session or an isolated thread. */
|
||||
export type GroupActionMemberMode = 'in_group' | 'isolated';
|
||||
|
||||
/** Whether the supervisor resumes or finishes once all members complete. */
|
||||
export type GroupActionOnComplete = 'resume' | 'finish';
|
||||
|
||||
/**
|
||||
* Params for the group-action member completion bridge — see
|
||||
* `AgentRuntimeService.completeGroupActionMember`. Mirrors the sub-agent bridge
|
||||
* but enforces a K=N member barrier: each member backfills its own anchor, and
|
||||
* the supervisor's group tool message is only backfilled (which satisfies the
|
||||
* parked op's barrier) once every member's anchor is fulfilled.
|
||||
*/
|
||||
export interface GroupActionMemberBridgeParams {
|
||||
/**
|
||||
* The per-member anchor `role: 'tool'` message to backfill. Equals
|
||||
* `groupToolMessageId` when `expectedMembers === 1` (single-member actions
|
||||
* collapse the anchor onto the group tool call itself).
|
||||
*/
|
||||
anchorMessageId: string;
|
||||
/** Total members forked under this group tool call — the K=N barrier target. */
|
||||
expectedMembers: number;
|
||||
/** Child member op's final state — passed in local mode; loaded otherwise. */
|
||||
finalState?: AgentState;
|
||||
/** The supervisor's parked group-management tool message (`tool_call_id` = call id). */
|
||||
groupToolMessageId: string;
|
||||
/** in_group → backfill a short note; isolated → backfill the member's final answer. */
|
||||
mode: GroupActionMemberMode;
|
||||
/** Resume the supervisor LLM, or finish the orchestration (skipCallSupervisor/delegate). */
|
||||
onComplete: GroupActionOnComplete;
|
||||
/** Child (member) operation ID. */
|
||||
operationId: string;
|
||||
parentOperationId: string;
|
||||
reason: string;
|
||||
/** Isolation thread id (isolated mode only). */
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watchdog payload that enforces a group member's timeout. Scheduled after an
|
||||
* isolated member op is forked; when it fires, if the member op hasn't reached a
|
||||
* terminal state it is interrupted and a `timeout` completion is bridged so the
|
||||
* parked supervisor resumes/finishes (satisfying the K=N barrier) instead of
|
||||
* waiting indefinitely.
|
||||
*/
|
||||
export interface GroupMemberTimeoutParams {
|
||||
anchorMessageId: string;
|
||||
expectedMembers: number;
|
||||
groupToolMessageId: string;
|
||||
/** The forked member operation id whose deadline this enforces. */
|
||||
memberOperationId: string;
|
||||
mode: GroupActionMemberMode;
|
||||
onComplete: GroupActionOnComplete;
|
||||
parentOperationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Params handed to the {@link AgentRuntimeDelegate.execGroupMember} callback —
|
||||
* fork one group member (in-group or isolated) under a group-management tool
|
||||
* call, installing the group-action member completion bridge.
|
||||
*/
|
||||
export interface ExecGroupMemberParams {
|
||||
/** Member agent id. */
|
||||
agentId: string;
|
||||
/** Per-member anchor message id the bridge backfills. */
|
||||
anchorMessageId: string;
|
||||
/** Disable tools for this member (broadcast — voice opinions only). */
|
||||
disableTools?: boolean;
|
||||
/** K=N barrier target stored on the group tool message. */
|
||||
expectedMembers: number;
|
||||
/** Group id. */
|
||||
groupId: string;
|
||||
/** Supervisor's group-management tool message id (the parked tool call). */
|
||||
groupToolMessageId: string;
|
||||
/** Optional supervisor instruction guiding the member's response. */
|
||||
instruction?: string;
|
||||
/** in_group (non-isolated group session) or isolated (own thread). */
|
||||
mode: GroupActionMemberMode;
|
||||
/** Resume or finish the supervisor once all members complete. */
|
||||
onComplete: GroupActionOnComplete;
|
||||
/** Parent (supervisor) operation id. */
|
||||
parentOperationId: string;
|
||||
/** Per-member timeout (ms), isolated mode. */
|
||||
timeout?: number;
|
||||
/** Group topic id. */
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
export interface ExecGroupMemberResult {
|
||||
error?: string;
|
||||
/** Forked member operation id (when started). */
|
||||
operationId?: string;
|
||||
/** Whether the member op was forked. */
|
||||
started: boolean;
|
||||
/** Isolation thread id (isolated mode only). */
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
export interface OperationCreationParams {
|
||||
activeDeviceId?: string;
|
||||
agentConfig?: any;
|
||||
@@ -221,6 +346,9 @@ export interface OperationCreationParams {
|
||||
deviceAccessPolicy?: { canUseDevice: boolean; reason: DeviceAccessReason };
|
||||
/** Device system info for placeholder variable replacement in Local System systemRole */
|
||||
deviceSystemInfo?: Record<string, string>;
|
||||
/** Discord context for injecting channel/guild info into agent system message */
|
||||
discordContext?: any;
|
||||
evalContext?: any;
|
||||
/**
|
||||
* Resolved execution plan for the run (see `resolveExecutionPlan`).
|
||||
* Forwarded into `state.metadata.executionPlan` so step-level layers (the
|
||||
@@ -228,9 +356,6 @@ export interface OperationCreationParams {
|
||||
* device capability from raw config.
|
||||
*/
|
||||
executionPlan?: ExecutionPlan;
|
||||
/** Discord context for injecting channel/guild info into agent system message */
|
||||
discordContext?: any;
|
||||
evalContext?: any;
|
||||
/**
|
||||
* External lifecycle hooks
|
||||
* Registered once, auto-adapt to local (in-memory) or production (webhook) mode
|
||||
|
||||
+33
@@ -54,6 +54,17 @@ const MEMORY_WRITE_TARGET_BY_API_NAME: Record<string, { idKey: string; layer: La
|
||||
[MemoryApiName.updateIdentityMemory]: { idKey: 'identityId', layer: LayersEnum.Identity },
|
||||
};
|
||||
const TOOL_NAME_SEPARATOR = '____';
|
||||
const DEFAULT_MEMORY_TARGET_TITLE = 'Memory saved';
|
||||
|
||||
const getMemoryWriteApiNameFromToolName = (name: unknown) => {
|
||||
const toolName = getString(name);
|
||||
if (!toolName) return;
|
||||
|
||||
const slashIndex = toolName.indexOf('/');
|
||||
const apiName = slashIndex >= 0 ? toolName.slice(slashIndex + 1) : toolName;
|
||||
|
||||
return MEMORY_WRITE_API_NAME_SET.has(apiName) ? apiName : undefined;
|
||||
};
|
||||
|
||||
const hasSuccessfulMemoryWrite = (state: AgentState) => {
|
||||
const byTool = state.usage?.tools?.byTool ?? [];
|
||||
@@ -72,6 +83,19 @@ const hasFailedMemoryWrite = (state: AgentState) => {
|
||||
);
|
||||
};
|
||||
|
||||
const getSuccessfulMemoryWriteTargetConfig = (state: AgentState) => {
|
||||
const byTool = state.usage?.tools?.byTool ?? [];
|
||||
|
||||
for (const entry of byTool) {
|
||||
if (entry.calls <= entry.errors) continue;
|
||||
|
||||
const apiName = getMemoryWriteApiNameFromToolName(entry.name);
|
||||
if (!apiName) continue;
|
||||
|
||||
return MEMORY_WRITE_TARGET_BY_API_NAME[apiName];
|
||||
}
|
||||
};
|
||||
|
||||
const getString = (value: unknown) => {
|
||||
return pickTrimmedString(value);
|
||||
};
|
||||
@@ -257,6 +281,15 @@ export const resolveMemoryActionTargetFromState = (
|
||||
if (target) return target;
|
||||
}
|
||||
}
|
||||
|
||||
const targetConfig = getSuccessfulMemoryWriteTargetConfig(state);
|
||||
if (!targetConfig) return;
|
||||
|
||||
return {
|
||||
memoryLayer: targetConfig.layer,
|
||||
title: DEFAULT_MEMORY_TARGET_TITLE,
|
||||
type: 'memory',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+37
@@ -1,3 +1,4 @@
|
||||
import { LayersEnum } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
|
||||
@@ -69,6 +70,42 @@ describe('buildSelfIterationReceipts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves structured memory target metadata for layer-specific navigation', () => {
|
||||
const [, memory] = buildSelfIterationReceipts({
|
||||
...baseInput,
|
||||
mutations: [
|
||||
{
|
||||
apiName: 'writeMemory',
|
||||
data: {
|
||||
kind: 'mutation',
|
||||
resourceId: 'pref_1',
|
||||
status: 'applied',
|
||||
summary: 'Use concise implementation notes.',
|
||||
target: {
|
||||
id: 'pref_1',
|
||||
memoryId: 'mem_1',
|
||||
memoryLayer: LayersEnum.Preference,
|
||||
title: 'Prefers concise implementation notes',
|
||||
type: 'memory',
|
||||
},
|
||||
},
|
||||
kind: 'mutation',
|
||||
toolCallId: 'call_pref',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(memory.title).toBe('Prefers concise implementation notes');
|
||||
expect(memory.target).toEqual({
|
||||
id: 'pref_1',
|
||||
memoryId: 'mem_1',
|
||||
memoryLayer: LayersEnum.Preference,
|
||||
summary: 'Use concise implementation notes.',
|
||||
title: 'Prefers concise implementation notes',
|
||||
type: 'memory',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps proposal creation to a proposed review receipt without a target', () => {
|
||||
const [, proposal] = buildSelfIterationReceipts({
|
||||
...baseInput,
|
||||
|
||||
+19
-2
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
|
||||
import { LayersEnum } from '@lobechat/types';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createCompletionPolicy } from '../../../../policies/completionPolicy';
|
||||
@@ -149,7 +150,18 @@ describe('S2 completion loop (policy → handler → projection → persist)', (
|
||||
mutations: [
|
||||
{
|
||||
apiName: 'writeMemory',
|
||||
data: { resourceId: 'mem_1', status: 'applied', summary: 'Saved tone preference' },
|
||||
data: {
|
||||
resourceId: 'pref_1',
|
||||
status: 'applied',
|
||||
summary: 'Saved tone preference',
|
||||
target: {
|
||||
id: 'pref_1',
|
||||
memoryId: 'mem_1',
|
||||
memoryLayer: LayersEnum.Preference,
|
||||
title: 'Tone preference',
|
||||
type: 'memory',
|
||||
},
|
||||
},
|
||||
kind: 'mutation',
|
||||
},
|
||||
],
|
||||
@@ -169,7 +181,12 @@ describe('S2 completion loop (policy → handler → projection → persist)', (
|
||||
expect(memory.anchorMessageId).toBe('assistant_msg_1');
|
||||
expect(memory.triggerMessageId).toBe('user_msg_1');
|
||||
expect(memory.topicId).toBe('topic_1');
|
||||
expect(memory.target).toMatchObject({ id: 'mem_1', type: 'memory' });
|
||||
expect(memory.target).toMatchObject({
|
||||
id: 'pref_1',
|
||||
memoryId: 'mem_1',
|
||||
memoryLayer: LayersEnum.Preference,
|
||||
type: 'memory',
|
||||
});
|
||||
});
|
||||
|
||||
it('no-ops when the completion carries no self-iteration payload (no marker stamped)', async () => {
|
||||
|
||||
+72
-1
@@ -1,4 +1,5 @@
|
||||
import { MemoryApiName, MemoryIdentifier } from '@lobechat/builtin-tool-memory';
|
||||
import { LayersEnum } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractSelfIterationCompletionPayload } from '../extractCompletionPayload';
|
||||
@@ -57,7 +58,7 @@ describe('extractSelfIterationCompletionPayload', () => {
|
||||
expect(result?.artifacts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('synthesizes a writeMemory mutation for a memory-kind run from finalState usage', () => {
|
||||
it('synthesizes a writeMemory mutation with a preference target for a memory-kind run', () => {
|
||||
const result = extractSelfIterationCompletionPayload(
|
||||
buildState(
|
||||
{
|
||||
@@ -66,6 +67,34 @@ describe('extractSelfIterationCompletionPayload', () => {
|
||||
userId: 'user_1',
|
||||
},
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: 'msg_preference',
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
arguments: JSON.stringify({
|
||||
summary: 'Prefer direct implementation with focused tests.',
|
||||
title: 'Prefers direct implementation',
|
||||
withPreference: {
|
||||
conclusionDirectives: 'Prefer direct implementation with focused tests.',
|
||||
},
|
||||
}),
|
||||
name: `${MemoryIdentifier}____${MemoryApiName.addPreferenceMemory}`,
|
||||
},
|
||||
id: 'call_preference',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
content:
|
||||
'Preference memory "Prefers direct implementation" saved with memoryId: "mem_1" and preferenceId: "pref_1"',
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_preference',
|
||||
},
|
||||
],
|
||||
status: 'finished',
|
||||
usage: {
|
||||
tools: {
|
||||
@@ -87,6 +116,48 @@ describe('extractSelfIterationCompletionPayload', () => {
|
||||
expect(result?.mutations).toHaveLength(1);
|
||||
expect(result?.mutations[0].apiName).toBe('writeMemory');
|
||||
expect((result?.mutations[0].data as { status?: string }).status).toBe('applied');
|
||||
expect((result?.mutations[0].data as { resourceId?: string }).resourceId).toBe('pref_1');
|
||||
expect((result?.mutations[0].data as { target?: Record<string, unknown> }).target).toEqual({
|
||||
id: 'pref_1',
|
||||
memoryId: 'mem_1',
|
||||
memoryLayer: LayersEnum.Preference,
|
||||
summary: 'Prefer direct implementation with focused tests.',
|
||||
title: 'Prefers direct implementation',
|
||||
type: 'memory',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the successful memory tool api when finalState lacks tool call details', () => {
|
||||
const result = extractSelfIterationCompletionPayload(
|
||||
buildState(
|
||||
{
|
||||
agentId: 'agent_user_1',
|
||||
agentSignal: { kind: 'memory', sourceId: 'mem-src_fallback' },
|
||||
userId: 'user_1',
|
||||
},
|
||||
{
|
||||
status: 'finished',
|
||||
usage: {
|
||||
tools: {
|
||||
byTool: [
|
||||
{
|
||||
calls: 1,
|
||||
errors: 0,
|
||||
name: `${MemoryIdentifier}/${MemoryApiName.addPreferenceMemory}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(result?.mutations).toHaveLength(1);
|
||||
expect((result?.mutations[0].data as { target?: Record<string, unknown> }).target).toEqual({
|
||||
memoryLayer: LayersEnum.Preference,
|
||||
title: 'Memory saved',
|
||||
type: 'memory',
|
||||
});
|
||||
});
|
||||
|
||||
it('yields no memory mutation when the memory run did not apply a write', () => {
|
||||
|
||||
+16
-3
@@ -1,3 +1,5 @@
|
||||
import { LayersEnum } from '@lobechat/types';
|
||||
|
||||
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
|
||||
|
||||
import type { AgentSignalReceipt } from '../../receiptService';
|
||||
@@ -53,6 +55,9 @@ const str = (value: unknown): string | undefined =>
|
||||
const isSkippedStatus = (status: unknown): boolean =>
|
||||
typeof status === 'string' && status.startsWith('skipped');
|
||||
|
||||
const isMemoryLayer = (value: unknown): value is LayersEnum =>
|
||||
Object.values(LayersEnum).includes(value as LayersEnum);
|
||||
|
||||
export interface BuildSelfIterationReceiptsInput {
|
||||
agentId: string;
|
||||
/** Non-actionable idea / intent recorder outputs (kind: artifact). */
|
||||
@@ -151,9 +156,15 @@ export const buildSelfIterationReceipts = (
|
||||
? 'skipped'
|
||||
: (SUCCESS_STATUS_BY_API[apiName] ?? 'applied');
|
||||
|
||||
const target = isRecord(data.target) ? data.target : undefined;
|
||||
const targetId = str(target?.id) ?? str(data.resourceId);
|
||||
const memoryId = kind === 'memory' ? str(target?.memoryId) : undefined;
|
||||
const memoryLayer =
|
||||
kind === 'memory' && isMemoryLayer(target?.memoryLayer) ? target.memoryLayer : undefined;
|
||||
const summaryText = str(data.summary);
|
||||
const resourceId = str(data.resourceId);
|
||||
const title = summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
|
||||
const targetTitle = str(target?.title);
|
||||
const title =
|
||||
targetTitle ?? summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -172,7 +183,9 @@ export const buildSelfIterationReceipts = (
|
||||
? {}
|
||||
: {
|
||||
target: {
|
||||
...(resourceId ? { id: resourceId } : {}),
|
||||
...(targetId ? { id: targetId } : {}),
|
||||
...(memoryId ? { memoryId } : {}),
|
||||
...(memoryLayer ? { memoryLayer } : {}),
|
||||
...(summaryText ? { summary: summaryText } : {}),
|
||||
title,
|
||||
type: kind,
|
||||
|
||||
+2
-1
@@ -49,9 +49,10 @@ const extractMemoryMutations = (finalState: AgentState): ToolResultWithKind[] =>
|
||||
apiName: 'writeMemory',
|
||||
data: {
|
||||
kind: 'mutation',
|
||||
...(result.target ? { target: result.target } : {}),
|
||||
resourceId: result.target?.id ?? result.target?.memoryId,
|
||||
status: 'applied',
|
||||
summary: result.detail,
|
||||
summary: result.detail ?? result.target?.summary,
|
||||
},
|
||||
kind: 'mutation',
|
||||
},
|
||||
|
||||
@@ -141,9 +141,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -97,9 +97,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -101,9 +101,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -98,9 +98,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const {
|
||||
mockCreateOperation,
|
||||
mockCreateServerAgentToolsEngine,
|
||||
mockGetAgentConfig,
|
||||
mockGetKlavisManifests,
|
||||
mockGetComposioManifests,
|
||||
mockGetLobehubSkillManifests,
|
||||
mockMessageCreate,
|
||||
mockPluginQuery,
|
||||
@@ -18,7 +18,7 @@ const {
|
||||
getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()),
|
||||
}),
|
||||
mockGetAgentConfig: vi.fn(),
|
||||
mockGetKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
mockGetComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
mockGetLobehubSkillManifests: vi.fn().mockResolvedValue([]),
|
||||
mockMessageCreate: vi.fn(),
|
||||
mockPluginQuery: vi.fn().mockResolvedValue([]),
|
||||
@@ -97,9 +97,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: mockGetKlavisManifests,
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: mockGetComposioManifests,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('AiAgentService.execAgent - disableTools', () => {
|
||||
|
||||
// Manifest fetches should NOT be called
|
||||
expect(mockGetLobehubSkillManifests).not.toHaveBeenCalled();
|
||||
expect(mockGetKlavisManifests).not.toHaveBeenCalled();
|
||||
expect(mockGetComposioManifests).not.toHaveBeenCalled();
|
||||
|
||||
// ToolsEngine should NOT be created
|
||||
expect(mockCreateServerAgentToolsEngine).not.toHaveBeenCalled();
|
||||
@@ -196,7 +196,7 @@ describe('AiAgentService.execAgent - disableTools', () => {
|
||||
// All tool discovery steps should be called
|
||||
expect(mockPluginQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetLobehubSkillManifests).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetKlavisManifests).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetComposioManifests).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateServerAgentToolsEngine).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,9 +100,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -424,8 +424,9 @@ describe('AiAgentService.execAgent - file upload handling', () => {
|
||||
expect(mockCreateOperation).toHaveBeenCalled();
|
||||
|
||||
const userMessageCall = mockMessageCreate.mock.calls.find((call) => call[0].role === 'user');
|
||||
// files array is empty since upload failed, so should be undefined-ish
|
||||
expect(userMessageCall![0].files).toEqual([]);
|
||||
// all uploads failed → no fileIds, normalized to undefined (no empty
|
||||
// messagesFiles relation attached)
|
||||
expect(userMessageCall![0].files).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,13 +2,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AiAgentService } from '../index';
|
||||
|
||||
const { mockMessageCreate, mockResolveAttachmentMetadata, mockSpawnHeteroSandbox } = vi.hoisted(
|
||||
() => ({
|
||||
mockMessageCreate: vi.fn(),
|
||||
mockResolveAttachmentMetadata: vi.fn(),
|
||||
mockSpawnHeteroSandbox: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
const {
|
||||
mockMessageCreate,
|
||||
mockResolveAttachmentsByFileIds,
|
||||
mockSpawnHeteroSandbox,
|
||||
mockIngestAttachment,
|
||||
} = vi.hoisted(() => ({
|
||||
mockIngestAttachment: vi.fn(),
|
||||
mockMessageCreate: vi.fn(),
|
||||
mockResolveAttachmentsByFileIds: vi.fn(),
|
||||
mockSpawnHeteroSandbox: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const emptyResolvedAttachments = {
|
||||
fileList: [],
|
||||
imageList: [],
|
||||
orderedFileIds: [],
|
||||
videoList: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
vi.mock('@/server/services/file', () => ({
|
||||
FileService: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../ingestAttachment', () => ({
|
||||
ingestAttachment: mockIngestAttachment,
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/trusted-client', () => ({
|
||||
generateTrustedClientToken: vi.fn().mockReturnValue(undefined),
|
||||
@@ -100,14 +120,13 @@ vi.mock('@/server/services/heterogeneousAgent/sandboxRunner', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/file/resolveAttachments', () => ({
|
||||
resolveAttachmentMetadata: mockResolveAttachmentMetadata,
|
||||
resolveAttachmentsByFileIds: vi.fn().mockResolvedValue({
|
||||
fileList: [],
|
||||
imageList: [],
|
||||
orderedFileIds: [],
|
||||
videoList: [],
|
||||
warnings: [],
|
||||
}),
|
||||
resolveAttachmentsByFileIds: mockResolveAttachmentsByFileIds,
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/document', () => ({
|
||||
DocumentService: vi.fn().mockImplementation(() => ({
|
||||
parseFile: vi.fn().mockResolvedValue({ content: '' }),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/agentRuntime', () => ({
|
||||
@@ -147,8 +166,9 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
|
||||
topicMock.findById.mockResolvedValue(undefined);
|
||||
topicMock.updateMetadata.mockResolvedValue(undefined);
|
||||
mockMessageCreate.mockResolvedValue({ id: 'msg-1' });
|
||||
mockResolveAttachmentMetadata.mockResolvedValue([]);
|
||||
mockResolveAttachmentsByFileIds.mockResolvedValue({ ...emptyResolvedAttachments });
|
||||
mockSpawnHeteroSandbox.mockResolvedValue(undefined);
|
||||
mockIngestAttachment.mockReset();
|
||||
|
||||
service = new AiAgentService(mockDb, userId);
|
||||
});
|
||||
@@ -165,6 +185,11 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
|
||||
// without `files`, so images attached in device mode were never linked
|
||||
// via messagesFiles and disappeared after the optimistic message was
|
||||
// replaced by the server snapshot.
|
||||
mockResolveAttachmentsByFileIds.mockResolvedValue({
|
||||
...emptyResolvedAttachments,
|
||||
orderedFileIds: ['file-1', 'file-2'],
|
||||
});
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
fileIds: ['file-1', 'file-2'],
|
||||
@@ -176,13 +201,23 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
|
||||
expect(userCall![0].files).toEqual(['file-1', 'file-2']);
|
||||
});
|
||||
|
||||
it('should dedupe repeated fileIds (messagesFiles PK is fileId+messageId)', async () => {
|
||||
it('should attach the resolver-deduped fileIds (dedup lives in resolveAttachmentsByFileIds)', async () => {
|
||||
// resolveAttachmentsByFileIds dedupes internally and returns orderedFileIds;
|
||||
// execAgent attaches exactly what it returns (messagesFiles PK is fileId+messageId).
|
||||
mockResolveAttachmentsByFileIds.mockResolvedValue({
|
||||
...emptyResolvedAttachments,
|
||||
orderedFileIds: ['file-1', 'file-2'],
|
||||
});
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
fileIds: ['file-1', 'file-1', 'file-2'],
|
||||
prompt: 'Look at this image',
|
||||
});
|
||||
|
||||
expect(mockResolveAttachmentsByFileIds).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileIds: ['file-1', 'file-1', 'file-2'] }),
|
||||
);
|
||||
const userCall = findUserMessageCreate();
|
||||
expect(userCall![0].files).toEqual(['file-1', 'file-2']);
|
||||
});
|
||||
@@ -211,22 +246,21 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
|
||||
|
||||
describe('image delivery to the dispatched CLI', () => {
|
||||
it('should resolve image attachments and pass imageList to the sandbox dispatch', async () => {
|
||||
mockResolveAttachmentMetadata.mockResolvedValue([
|
||||
{
|
||||
fileType: 'image/png',
|
||||
id: 'file-1',
|
||||
name: 'screenshot.png',
|
||||
size: 100,
|
||||
url: 'https://signed/file-1.png',
|
||||
},
|
||||
{
|
||||
fileType: 'application/pdf',
|
||||
id: 'file-2',
|
||||
name: 'doc.pdf',
|
||||
size: 200,
|
||||
url: 'https://signed/file-2.pdf',
|
||||
},
|
||||
]);
|
||||
mockResolveAttachmentsByFileIds.mockResolvedValue({
|
||||
...emptyResolvedAttachments,
|
||||
fileList: [
|
||||
{
|
||||
content: '',
|
||||
fileType: 'application/pdf',
|
||||
id: 'file-2',
|
||||
name: 'doc.pdf',
|
||||
size: 200,
|
||||
url: 'https://signed/file-2.pdf',
|
||||
},
|
||||
],
|
||||
imageList: [{ alt: 'screenshot.png', id: 'file-1', url: 'https://signed/file-1.png' }],
|
||||
orderedFileIds: ['file-1', 'file-2'],
|
||||
});
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -242,15 +276,20 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
|
||||
});
|
||||
|
||||
it('should pass imageList undefined when attachments contain no images', async () => {
|
||||
mockResolveAttachmentMetadata.mockResolvedValue([
|
||||
{
|
||||
fileType: 'application/pdf',
|
||||
id: 'file-2',
|
||||
name: 'doc.pdf',
|
||||
size: 200,
|
||||
url: 'https://signed/file-2.pdf',
|
||||
},
|
||||
]);
|
||||
mockResolveAttachmentsByFileIds.mockResolvedValue({
|
||||
...emptyResolvedAttachments,
|
||||
fileList: [
|
||||
{
|
||||
content: '',
|
||||
fileType: 'application/pdf',
|
||||
id: 'file-2',
|
||||
name: 'doc.pdf',
|
||||
size: 200,
|
||||
url: 'https://signed/file-2.pdf',
|
||||
},
|
||||
],
|
||||
orderedFileIds: ['file-2'],
|
||||
});
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -264,7 +303,7 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
|
||||
});
|
||||
|
||||
it('should not block the run when attachment resolution fails', async () => {
|
||||
mockResolveAttachmentMetadata.mockRejectedValue(new Error('S3 down'));
|
||||
mockResolveAttachmentsByFileIds.mockRejectedValue(new Error('S3 down'));
|
||||
|
||||
const result = await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
@@ -287,7 +326,90 @@ describe('AiAgentService.execAgent - hetero early-exit file attachments', () =>
|
||||
prompt: 'No attachments here',
|
||||
});
|
||||
|
||||
expect(mockResolveAttachmentMetadata).not.toHaveBeenCalled();
|
||||
expect(mockResolveAttachmentsByFileIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw bot/IM file ingestion (files param)', () => {
|
||||
// regression: bot/IM channels deliver attachments as raw `files` buffers
|
||||
// (not pre-uploaded `fileIds`). The hetero branch returns before the main
|
||||
// ingestion block, so images sent through a bot were silently dropped and
|
||||
// the CLI received text only.
|
||||
it('should ingest raw files, attach them to the user message and forward images', async () => {
|
||||
mockIngestAttachment.mockResolvedValue({
|
||||
fileId: 'uploaded-1',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
resolvedUrl: 'https://signed/uploaded-1.png',
|
||||
});
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
files: [{ mimeType: 'image/png', name: 'shot.png', url: 'https://im/shot.png' }],
|
||||
prompt: 'What is this image?',
|
||||
});
|
||||
|
||||
expect(mockIngestAttachment).toHaveBeenCalledTimes(1);
|
||||
|
||||
const userCall = findUserMessageCreate();
|
||||
expect(userCall![0].files).toEqual(['uploaded-1']);
|
||||
|
||||
expect(mockSpawnHeteroSandbox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
imageList: [{ id: 'uploaded-1', url: 'https://signed/uploaded-1.png' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge ingested files with pre-uploaded fileIds (both images forwarded)', async () => {
|
||||
mockIngestAttachment.mockResolvedValue({
|
||||
fileId: 'uploaded-1',
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
resolvedUrl: 'https://signed/uploaded-1.png',
|
||||
});
|
||||
mockResolveAttachmentsByFileIds.mockResolvedValue({
|
||||
...emptyResolvedAttachments,
|
||||
imageList: [{ alt: 'pre.jpg', id: 'file-1', url: 'https://signed/file-1.jpg' }],
|
||||
orderedFileIds: ['file-1'],
|
||||
});
|
||||
|
||||
await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
fileIds: ['file-1'],
|
||||
files: [{ mimeType: 'image/png', name: 'shot.png', url: 'https://im/shot.png' }],
|
||||
prompt: 'Compare these images',
|
||||
});
|
||||
|
||||
// Raw `files` are ingested first, then pre-uploaded `attachedFileIds`.
|
||||
const userCall = findUserMessageCreate();
|
||||
expect(userCall![0].files).toEqual(['uploaded-1', 'file-1']);
|
||||
|
||||
expect(mockSpawnHeteroSandbox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
imageList: [
|
||||
{ id: 'uploaded-1', url: 'https://signed/uploaded-1.png' },
|
||||
{ id: 'file-1', url: 'https://signed/file-1.jpg' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not block the run when a raw file fails to ingest', async () => {
|
||||
mockIngestAttachment.mockRejectedValue(new Error('S3 down'));
|
||||
|
||||
const result = await service.execAgent({
|
||||
agentId: 'agent-1',
|
||||
files: [{ mimeType: 'image/png', name: 'shot.png', url: 'https://im/shot.png' }],
|
||||
prompt: 'What is this image?',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const userCall = findUserMessageCreate();
|
||||
expect(userCall![0].files).toBeUndefined();
|
||||
expect(mockSpawnHeteroSandbox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ imageList: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,9 +68,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -101,9 +101,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -100,10 +100,10 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock KlavisService (for getKlavisManifests)
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
// Mock ComposioService (for getComposioManifests)
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -90,9 +90,9 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
@@ -87,10 +87,10 @@ vi.mock('@/server/services/market', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock KlavisService
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({
|
||||
getKlavisManifests: vi.fn().mockResolvedValue([]),
|
||||
// Mock ComposioService
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({
|
||||
getComposioManifests: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import type {
|
||||
DeviceGitWorkingTreeFiles,
|
||||
DeviceGitWorkingTreePatches,
|
||||
DeviceGitWorkingTreeStatus,
|
||||
DeviceGitWorktreeListItem,
|
||||
DeviceListProjectSkillsResult,
|
||||
DeviceLocalFilePreviewResult,
|
||||
DeviceProjectFileIndexResult,
|
||||
@@ -207,6 +208,13 @@ export class DeviceGateway {
|
||||
});
|
||||
}
|
||||
|
||||
/** Git worktrees attached to the same repository as a directory on a remote device. */
|
||||
listGitWorktrees(params: { deviceId: string; path: string; userId: string }) {
|
||||
return this.invokeGitRead<DeviceGitWorktreeListItem[]>('listGitWorktrees', params, {
|
||||
path: params.path,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the local branches of a directory on a remote device via the
|
||||
* `listGitBranches` device RPC, so the web/remote branch switcher can populate
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
COMPOSIO_APP_TYPES,
|
||||
CURRENT_VERSION,
|
||||
DEFAULT_DISCOVER_ASSISTANT_ITEM,
|
||||
DEFAULT_DISCOVER_PLUGIN_ITEM,
|
||||
DEFAULT_DISCOVER_PROVIDER_ITEM,
|
||||
isDesktop,
|
||||
KLAVIS_SERVER_TYPES,
|
||||
} from '@lobechat/const';
|
||||
import {
|
||||
type AgentStatus,
|
||||
@@ -1165,29 +1165,29 @@ export class DiscoverService {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
// Step 4: Try to find in Klavis server types (builtin tools that require env config)
|
||||
const klavisTool = KLAVIS_SERVER_TYPES.find((tool) => tool.identifier === identifier);
|
||||
if (klavisTool) {
|
||||
log('getPluginDetail: found Klavis tool for identifier=%s', identifier);
|
||||
// Step 4: Try to find in Composio server types (builtin tools that require env config)
|
||||
const composioTool = COMPOSIO_APP_TYPES.find((tool) => tool.identifier === identifier);
|
||||
if (composioTool) {
|
||||
log('getPluginDetail: found Composio tool for identifier=%s', identifier);
|
||||
|
||||
// Avatar is empty here because frontend will render Klavis icons using KlavisIcon component
|
||||
// Avatar is empty here because frontend will render Composio icons using ComposioIcon component
|
||||
// which handles both string URLs and React component icons
|
||||
const plugin: DiscoverPluginDetail = {
|
||||
author: 'Klavis',
|
||||
avatar: typeof klavisTool.icon === 'string' ? klavisTool.icon : '',
|
||||
author: 'Composio',
|
||||
avatar: typeof composioTool.icon === 'string' ? composioTool.icon : '',
|
||||
category: undefined,
|
||||
createdAt: '',
|
||||
description: `LobeHub Mcp Server: ${klavisTool.label}`,
|
||||
homepage: 'https://klavis.ai',
|
||||
identifier: klavisTool.identifier,
|
||||
description: `LobeHub Mcp Server: ${composioTool.label}`,
|
||||
homepage: 'https://composio.dev',
|
||||
identifier: composioTool.identifier,
|
||||
manifest: undefined,
|
||||
related: [],
|
||||
schemaVersion: 1,
|
||||
source: 'builtin',
|
||||
tags: ['klavis', 'mcp'],
|
||||
title: klavisTool.label,
|
||||
tags: ['composio', 'mcp'],
|
||||
title: composioTool.label,
|
||||
};
|
||||
log('getPluginDetail: returning Klavis tool plugin');
|
||||
log('getPluginDetail: returning Composio tool plugin');
|
||||
return plugin;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,14 @@ interface OperationState {
|
||||
main: MainAgentRunState;
|
||||
operationId: string;
|
||||
processedKeys: Set<string>;
|
||||
/**
|
||||
* The operation's seeded placeholder assistant (the row `execAgent` creates
|
||||
* before the first ingest). Immutable for the run's lifetime. Used as the
|
||||
* `createdAt` floor when anchoring the chain to the run's real last tool —
|
||||
* a topic runs at most one operation at a time, so "tool messages on/after
|
||||
* the seed" scopes to THIS run without a recursive parent walk.
|
||||
*/
|
||||
seedAssistantMessageId: string;
|
||||
/**
|
||||
* Run-global DB index for every tool message in the topic, keyed by
|
||||
* `tool_call_id`. Main and subagent reducers keep only their per-turn maps;
|
||||
@@ -396,6 +404,7 @@ export class HeterogeneousPersistenceHandler {
|
||||
main: createMainAgentRunState(currentAssistantMessageId),
|
||||
operationId,
|
||||
processedKeys: new Set(),
|
||||
seedAssistantMessageId: baseAssistantMessageId,
|
||||
toolMsgIdByCallId: new Map(),
|
||||
topicId,
|
||||
};
|
||||
@@ -502,6 +511,14 @@ export class HeterogeneousPersistenceHandler {
|
||||
const currentMsg = await this.deps.messageModel.findById(state.main.currentAssistantId);
|
||||
const snapshot = this.toAssistantSnapshot(currentMsg);
|
||||
|
||||
// Recover the in-flight turn's CC message.id so a replayed `newStep` (cold
|
||||
// replica retry) is recognized as the SAME turn — no duplicate assistant,
|
||||
// no usage-only empty shell. Mirrors the subagent path's recovery of
|
||||
// `currentSubagentMessageId` from `metadata.subagentMessageId`.
|
||||
if (typeof snapshot.metadata.mainMessageId === 'string') {
|
||||
state.main.currentMainMessageId = snapshot.metadata.mainMessageId;
|
||||
}
|
||||
|
||||
if (snapshot.textSnapshotSeq > state.main.lastTextSnapshotSeq) {
|
||||
state.main.accContent = snapshot.content;
|
||||
state.main.lastTextSnapshotSeq = snapshot.textSnapshotSeq;
|
||||
@@ -536,11 +553,23 @@ export class HeterogeneousPersistenceHandler {
|
||||
if (snapshot.model) state.main.turnModel = snapshot.model;
|
||||
if (snapshot.provider) state.main.turnProvider = snapshot.provider;
|
||||
|
||||
// Prefer the authoritative child tool row over the assistant.tools[] JSONB
|
||||
// mirror. During multi-tool batches, an earlier tool may already have
|
||||
// result_msg_id backfilled while a later tool row exists but Phase 3 has not
|
||||
// rewritten the JSONB payload yet; anchoring from the snapshot would pick
|
||||
// the earlier tool and fork the main wire.
|
||||
// Anchor the chain to the RUN's real latest main-thread tool message, read
|
||||
// straight from the DB and independent of `currentAssistantId`. The latter
|
||||
// can regress to the seeded placeholder on a cold / non-sticky replica
|
||||
// (see the multi-replica caveat on the class) when `heteroCurrentMsgId` is
|
||||
// not yet bound to this operation: anchoring off its child tools would then
|
||||
// collapse onto the run's FIRST tool, and every later step opens off that
|
||||
// same node — forking the wire into orphan siblings. Ordering by createdAt
|
||||
// also sidesteps the multi-tool-batch hazard where an earlier tool's
|
||||
// result_msg_id is backfilled before a later tool row's JSONB is rewritten.
|
||||
const runLastToolId = await this.getLastRunToolMessageId(state);
|
||||
if (runLastToolId) {
|
||||
state.main.lastToolMsgIdEver = runLastToolId;
|
||||
return;
|
||||
}
|
||||
|
||||
// No tool persisted in this run yet — fall back to the per-assistant lookups
|
||||
// so the very first turn still chains correctly before any tool exists.
|
||||
const currentTurnToolId =
|
||||
(await this.getLastChildToolMessageId(state.main.currentAssistantId)) ??
|
||||
this.getLastSnapshotToolMessageId(snapshot, state.toolMsgIdByCallId);
|
||||
@@ -555,6 +584,20 @@ export class HeterogeneousPersistenceHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest main-thread tool message created on/after the run's seed assistant.
|
||||
* Scopes to the current operation via the seed's `createdAt` floor without a
|
||||
* recursive walk, and stays correct even when `currentAssistantId` has
|
||||
* regressed on a cold replica. Optional on the model so test mocks that don't
|
||||
* implement it transparently fall back to the per-assistant anchors.
|
||||
*/
|
||||
private async getLastRunToolMessageId(state: OperationState): Promise<string | undefined> {
|
||||
return await this.deps.messageModel.getLastMainThreadToolMessageIdSince?.(
|
||||
state.topicId,
|
||||
state.seedAssistantMessageId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the in-flight subagent runs (`state.main.subagents`) from DB.
|
||||
*
|
||||
@@ -567,9 +610,16 @@ export class HeterogeneousPersistenceHandler {
|
||||
*
|
||||
* Merge semantics: only runs MISSING from the in-memory map are rehydrated, so
|
||||
* a warm replica's live per-turn accumulators (`accContent`, current
|
||||
* `toolState`) are never clobbered by the DB projection. Finalized runs are
|
||||
* excluded (their thread is `Active`, not `Processing`), so a completed spawn
|
||||
* is never resurrected.
|
||||
* `toolState`) are never clobbered by the DB projection.
|
||||
*
|
||||
* Finalized (`Active`) spawns are NOT rehydrated as live runs (a completed
|
||||
* spawn is never resurrected — that would mint spurious empty assistants and
|
||||
* re-finalize churn), but their `sourceToolCallId` IS recorded in
|
||||
* `finalizedParents` so a REPLAYED first-event on a cold replica can't fork a
|
||||
* duplicate thread for a spawn that already finished (the "一模一样的两个
|
||||
* thread" bug). This mirrors #15838's main-turn idempotency for the subagent
|
||||
* thread-create step: dedup keyed by the DB-homed `sourceToolCallId`,
|
||||
* independent of in-memory state and of thread status.
|
||||
*
|
||||
* Best-effort: any DB hiccup (or a partial test mock without the query
|
||||
* methods) leaves `state.main.subagents` untouched rather than aborting the
|
||||
@@ -580,12 +630,13 @@ export class HeterogeneousPersistenceHandler {
|
||||
const threads = await this.deps.threadModel.queryByTopicId(state.topicId);
|
||||
const existing = state.main.subagents.runs;
|
||||
const snapshots: SubagentRunSnapshot[] = [];
|
||||
// Union with any parents finalized in-memory on a warm replica.
|
||||
const finalizedParents = new Set(state.main.subagents.finalizedParents);
|
||||
|
||||
for (const thread of threads ?? []) {
|
||||
if (thread.type !== ThreadType.Isolation) continue;
|
||||
if (thread.status !== ThreadStatus.Processing) continue;
|
||||
const meta = thread.metadata as { operationId?: string; sourceToolCallId?: string } | null;
|
||||
// Operation-scoped: only rehydrate threads THIS operation created.
|
||||
// Operation-scoped: only attend to threads THIS operation created.
|
||||
// Topics are reused across turns, so a prior run that crashed / was
|
||||
// cancelled without an ingested terminal event can leave its subagent
|
||||
// thread stuck in `Processing`. Without this guard the next operation
|
||||
@@ -597,6 +648,13 @@ export class HeterogeneousPersistenceHandler {
|
||||
const parentToolCallId = meta?.sourceToolCallId;
|
||||
if (!parentToolCallId || existing.has(parentToolCallId)) continue;
|
||||
|
||||
// Finalized spawn → remember the key (blocks duplicate create), don't
|
||||
// rehydrate it as a live run.
|
||||
if (thread.status !== ThreadStatus.Processing) {
|
||||
finalizedParents.add(parentToolCallId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const messages = await this.deps.messageModel.query({
|
||||
threadId: thread.id,
|
||||
topicId: state.topicId,
|
||||
@@ -605,11 +663,20 @@ export class HeterogeneousPersistenceHandler {
|
||||
if (snapshot) snapshots.push(snapshot);
|
||||
}
|
||||
|
||||
if (snapshots.length === 0) return;
|
||||
// Nothing new to project: no rehydratable runs AND no finalized keys
|
||||
// beyond what memory already tracked (the set started as a copy of it and
|
||||
// only grows, so an unchanged size means no new Active threads were found).
|
||||
if (
|
||||
snapshots.length === 0 &&
|
||||
finalizedParents.size === state.main.subagents.finalizedParents.size
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Union: rehydrated (missing) runs + the in-memory ones (which win, since
|
||||
// they carry live accumulators the DB hasn't caught up to yet).
|
||||
const merged = rehydrateSubagentRunsState(snapshots);
|
||||
// they carry live accumulators the DB hasn't caught up to yet) + the
|
||||
// finalized-parent guard set.
|
||||
const merged = rehydrateSubagentRunsState(snapshots, [...finalizedParents]);
|
||||
for (const [parentToolCallId, run] of existing) merged.runs.set(parentToolCallId, run);
|
||||
state.main = { ...state.main, subagents: merged };
|
||||
} catch (err) {
|
||||
@@ -626,7 +693,13 @@ export class HeterogeneousPersistenceHandler {
|
||||
private buildSubagentSnapshot(
|
||||
parentToolCallId: string,
|
||||
threadId: string,
|
||||
messages: Array<{ id: string; parentId?: string | null; role: string; tool_call_id?: string }>,
|
||||
messages: Array<{
|
||||
id: string;
|
||||
metadata?: Record<string, any> | null;
|
||||
parentId?: string | null;
|
||||
role: string;
|
||||
tool_call_id?: string;
|
||||
}>,
|
||||
): SubagentRunSnapshot | undefined {
|
||||
const assistants = messages.filter((m) => m.role === 'assistant');
|
||||
const currentAssistant = assistants.at(-1);
|
||||
@@ -635,9 +708,16 @@ export class HeterogeneousPersistenceHandler {
|
||||
const toolRows = messages.filter((m) => m.role === 'tool' && m.tool_call_id);
|
||||
const childTools = toolRows.filter((m) => m.parentId === currentAssistant.id);
|
||||
const lastChainParentId = childTools.at(-1)?.id ?? currentAssistant.id;
|
||||
// Recover the in-flight turn's CC message.id so a continuation event is
|
||||
// recognized as the SAME turn (no spurious boundary → no fragmentation).
|
||||
const currentSubagentMessageId =
|
||||
typeof currentAssistant.metadata?.subagentMessageId === 'string'
|
||||
? currentAssistant.metadata.subagentMessageId
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
currentAssistantId: currentAssistant.id,
|
||||
currentSubagentMessageId,
|
||||
lastChainParentId,
|
||||
lifetimeToolCallIds: toolRows.map((m) => m.tool_call_id!),
|
||||
parentToolCallId,
|
||||
@@ -727,11 +807,17 @@ export class HeterogeneousPersistenceHandler {
|
||||
private async applyMainIntent(state: OperationState, intent: MainAgentIntent) {
|
||||
switch (intent.kind) {
|
||||
case 'createAssistant': {
|
||||
const createMetadata: Record<string, any> = {};
|
||||
if (intent.signal) createMetadata.signal = intent.signal;
|
||||
// Persist the turn's CC message.id so a cold replica can recover
|
||||
// `currentMainMessageId` (via refreshMainStateFromDb) and dedupe a
|
||||
// replayed `newStep` instead of forking a duplicate + empty shell.
|
||||
if (intent.mainMessageId) createMetadata.mainMessageId = intent.mainMessageId;
|
||||
await this.deps.messageModel.create(
|
||||
{
|
||||
agentId: intent.agentId ?? undefined,
|
||||
content: '',
|
||||
...(intent.signal ? { metadata: { signal: intent.signal } } : {}),
|
||||
...(Object.keys(createMetadata).length > 0 ? { metadata: createMetadata } : {}),
|
||||
model: intent.model,
|
||||
parentId: intent.parentId,
|
||||
provider: intent.provider,
|
||||
@@ -962,11 +1048,18 @@ export class HeterogeneousPersistenceHandler {
|
||||
{
|
||||
agentId: intent.agentId ?? undefined,
|
||||
content: intent.content,
|
||||
// Persist the turn's CC message.id so a cold replica can recover
|
||||
// `currentSubagentMessageId` (via buildSubagentSnapshot) and avoid
|
||||
// a spurious turn boundary that fragments one CC turn into multiple
|
||||
// in-thread assistant rows + empty shells.
|
||||
...(intent.subagentMessageId
|
||||
? { metadata: { subagentMessageId: intent.subagentMessageId } }
|
||||
: {}),
|
||||
parentId: intent.parentId,
|
||||
role: intent.role,
|
||||
threadId: intent.threadId,
|
||||
topicId: intent.topicId ?? state.topicId,
|
||||
},
|
||||
} as any,
|
||||
intent.messageId,
|
||||
);
|
||||
return;
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
// @vitest-environment node
|
||||
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetOperationStatesForTesting,
|
||||
HeterogeneousPersistenceHandler,
|
||||
} from '../HeterogeneousPersistenceHandler';
|
||||
|
||||
/**
|
||||
* Regression for the remote-device chain-fork (observed on tpc_3DKmFfAmx9YA):
|
||||
* several CONSECUTIVE, DISTINCT main-agent steps all parented onto the run's
|
||||
* FIRST tool message instead of chaining linearly.
|
||||
*
|
||||
* Root cause: `refreshMainStateFromDb` used to anchor `lastToolMsgIdEver` off
|
||||
* `getLastChildToolMessageId(currentAssistantId)`. On a non-sticky / cold
|
||||
* replica (a WS reconnect storm spreads one run's batches across replicas),
|
||||
* `currentAssistantId` regresses to the operation's seeded placeholder when the
|
||||
* `heteroCurrentMsgId` pointer is not yet visible. The anchor then collapses to
|
||||
* the SEED's first child tool, and every later `newStep` opens off that same
|
||||
* node → orphan sibling forks.
|
||||
*
|
||||
* The fix anchors the chain to the RUN's real latest main-thread tool, read
|
||||
* from the DB and ordered by createdAt, independent of `currentAssistantId`.
|
||||
*
|
||||
* This harness models the precondition deterministically: `updateMetadata`
|
||||
* never persists `heteroCurrentMsgId`, so every cold load regresses
|
||||
* `currentAssistantId` to the seed — exactly the cross-replica window.
|
||||
*/
|
||||
|
||||
interface FakeMessage {
|
||||
content: string;
|
||||
id: string;
|
||||
parentId?: string | null;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
seq: number;
|
||||
threadId?: string | null;
|
||||
tool_call_id?: string;
|
||||
tools?: any[];
|
||||
topicId: string | null;
|
||||
}
|
||||
|
||||
const SEED = 'asst-seed';
|
||||
const T1 = 'tool-1'; // the run's first-turn tool, a child of the seed assistant
|
||||
const OP = 'op-1';
|
||||
const TOPIC = 'topic-1';
|
||||
|
||||
const createHarness = () => {
|
||||
let seq = 0;
|
||||
const messages = new Map<string, FakeMessage>();
|
||||
|
||||
// No `heteroCurrentMsgId` — and updateMetadata below refuses to persist it —
|
||||
// so loadOrCreateState always falls back to runningOperation.assistantMessageId.
|
||||
let topicMetadata: Record<string, any> = {
|
||||
runningOperation: { assistantMessageId: SEED, operationId: OP },
|
||||
};
|
||||
|
||||
messages.set(SEED, {
|
||||
content: 'first turn answer',
|
||||
id: SEED,
|
||||
role: 'assistant',
|
||||
seq: seq++,
|
||||
topicId: TOPIC,
|
||||
});
|
||||
messages.set(T1, {
|
||||
content: '',
|
||||
id: T1,
|
||||
parentId: SEED,
|
||||
role: 'tool',
|
||||
seq: seq++,
|
||||
threadId: null,
|
||||
tool_call_id: 'tc-0',
|
||||
topicId: TOPIC,
|
||||
});
|
||||
|
||||
const messageModel = {
|
||||
create: vi.fn(async (input: Partial<FakeMessage>, id?: string) => {
|
||||
const msgId = id ?? `msg_${seq}`;
|
||||
const msg: FakeMessage = {
|
||||
content: input.content ?? '',
|
||||
id: msgId,
|
||||
parentId: input.parentId ?? null,
|
||||
role: input.role!,
|
||||
seq: seq++,
|
||||
threadId: input.threadId ?? null,
|
||||
tool_call_id: input.tool_call_id,
|
||||
tools: input.tools,
|
||||
topicId: input.topicId ?? null,
|
||||
};
|
||||
messages.set(msgId, msg);
|
||||
return msg;
|
||||
}),
|
||||
update: vi.fn(async (id: string, patch: Partial<FakeMessage>) => {
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
messages.set(id, { ...existing, ...patch });
|
||||
return { success: true };
|
||||
}),
|
||||
updateToolMessage: vi.fn(async () => ({ success: true })),
|
||||
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
|
||||
query: vi.fn(async (params: { threadId?: string; topicId?: string }) => {
|
||||
if (params?.threadId)
|
||||
return [...messages.values()].filter((m) => m.threadId === params.threadId);
|
||||
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
|
||||
}),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
const match = [...messages.values()]
|
||||
.filter((m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId)
|
||||
.sort((a, b) => b.seq - a.seq)[0];
|
||||
return match?.id;
|
||||
}),
|
||||
getLastMainThreadToolMessageIdSince: vi.fn(async (topicId: string, sinceMessageId: string) => {
|
||||
const seed = messages.get(sinceMessageId);
|
||||
if (!seed) return undefined;
|
||||
const match = [...messages.values()]
|
||||
.filter(
|
||||
(m) => m.topicId === topicId && m.role === 'tool' && !m.threadId && m.seq >= seed.seq,
|
||||
)
|
||||
.sort((a, b) => b.seq - a.seq)[0];
|
||||
return match?.id;
|
||||
}),
|
||||
listMessagePluginsByTopic: vi.fn(async () =>
|
||||
[...messages.values()]
|
||||
.filter((m) => m.role === 'tool' && m.tool_call_id)
|
||||
.map((m) => ({ id: m.id, toolCallId: m.tool_call_id! })),
|
||||
),
|
||||
};
|
||||
|
||||
const topicModel = {
|
||||
findById: vi.fn(async (id: string) =>
|
||||
id === TOPIC ? { agentId: null, id, metadata: topicMetadata } : null,
|
||||
),
|
||||
updateMetadata: vi.fn(async (_id: string, patch: Record<string, any>) => {
|
||||
// Drop heteroCurrentMsgId to model a cold replica that never sees the
|
||||
// current-assistant pointer written by a concurrent replica.
|
||||
const { heteroCurrentMsgId: _drop, ...rest } = patch;
|
||||
topicMetadata = { ...topicMetadata, ...rest };
|
||||
}),
|
||||
};
|
||||
|
||||
const threadModel = {
|
||||
create: vi.fn(async () => {}),
|
||||
findById: vi.fn(async () => null),
|
||||
queryByTopicId: vi.fn(async () => []),
|
||||
update: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const handler = new HeterogeneousPersistenceHandler({
|
||||
messageModel: messageModel as any,
|
||||
threadModel: threadModel as any,
|
||||
topicModel: topicModel as any,
|
||||
});
|
||||
|
||||
return { handler, messages };
|
||||
};
|
||||
|
||||
const buildEvent = (
|
||||
type: AgentStreamEvent['type'],
|
||||
stepIndex: number,
|
||||
data: Record<string, unknown>,
|
||||
): AgentStreamEvent => ({
|
||||
data,
|
||||
operationId: OP,
|
||||
stepIndex,
|
||||
timestamp: 1_700_000_000_000 + stepIndex,
|
||||
type,
|
||||
});
|
||||
|
||||
const stepBatch = (stepIndex: number, ccMsgId: string, toolCallId: string): AgentStreamEvent[] => [
|
||||
buildEvent('stream_start', stepIndex, {
|
||||
messageId: ccMsgId,
|
||||
newStep: true,
|
||||
provider: 'claude-code',
|
||||
}),
|
||||
buildEvent('stream_chunk', stepIndex, {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{ apiName: 'Bash', arguments: '{}', id: toolCallId, identifier: 'bash', type: 'default' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
describe('HeterogeneousPersistenceHandler — chain anchor survives a regressed currentAssistantId', () => {
|
||||
beforeEach(() => __resetOperationStatesForTesting());
|
||||
afterEach(() => __resetOperationStatesForTesting());
|
||||
|
||||
it('chains consecutive cold-replica steps off the run last tool, not the seed first tool', async () => {
|
||||
const h = createHarness();
|
||||
|
||||
// Step 1 on a cold replica (currentAssistantId regresses to SEED).
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: SEED,
|
||||
events: stepBatch(1, 'cc-A', 'tc-A'),
|
||||
operationId: OP,
|
||||
topicId: TOPIC,
|
||||
});
|
||||
__resetOperationStatesForTesting();
|
||||
// Step 2 on ANOTHER cold replica (currentAssistantId regresses to SEED again).
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: SEED,
|
||||
events: stepBatch(2, 'cc-B', 'tc-B'),
|
||||
operationId: OP,
|
||||
topicId: TOPIC,
|
||||
});
|
||||
|
||||
const assistants = [...h.messages.values()].filter(
|
||||
(m) => m.role === 'assistant' && m.id !== SEED,
|
||||
);
|
||||
expect(assistants).toHaveLength(2);
|
||||
|
||||
const [a1, a2] = assistants.sort((x, y) => x.seq - y.seq);
|
||||
const toolA = [...h.messages.values()].find((m) => m.tool_call_id === 'tc-A')!;
|
||||
|
||||
// First step still chains off the run's only existing tool (T1, seed's child).
|
||||
expect(a1.parentId).toBe(T1);
|
||||
// Second step must chain off step 1's tool — NOT collapse back onto T1.
|
||||
expect(a2.parentId).toBe(toolA.id);
|
||||
expect(a2.parentId).not.toBe(T1);
|
||||
|
||||
// No fork: T1 has exactly one assistant child across the whole run.
|
||||
const t1AssistantChildren = [...h.messages.values()].filter(
|
||||
(m) => m.role === 'assistant' && m.parentId === T1,
|
||||
);
|
||||
expect(t1AssistantChildren).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
// @vitest-environment node
|
||||
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetOperationStatesForTesting,
|
||||
HeterogeneousPersistenceHandler,
|
||||
} from '../HeterogeneousPersistenceHandler';
|
||||
|
||||
/**
|
||||
* Regression for the MAIN-chain analog of #15808 (which only fixed the SUBAGENT
|
||||
* coordinator).
|
||||
*
|
||||
* The main-agent reducer (`packages/heterogeneous-agents/src/mainAgentCoordinator`)
|
||||
* cuts a turn purely on the adapter's `stream_start { newStep: true }` signal —
|
||||
* it tracks NO CC `message.id` and `openTurn` mints a fresh random assistant id
|
||||
* via `ctx.newId('message')`. So unlike the subagent path (which now persists the
|
||||
* turn's CC message.id on `metadata.subagentMessageId` and dedupes a replayed
|
||||
* turn), the main chain has NO DB-homed idempotency key for a turn.
|
||||
*
|
||||
* The serverless failure mode:
|
||||
* - `processedKeys` (the per-event dedupe set) lives ONLY in the in-memory
|
||||
* `operationStates` map. On a cold replica it is empty.
|
||||
* - The ingest contract (see `ingest()` doc) is: a handler that throws leaves
|
||||
* its event unmarked, the throw bubbles to the producer, and the producer
|
||||
* re-sends the WHOLE batch. Already-applied events are skipped "via the
|
||||
* dedupe map" — but that map is in-memory, so on a cold replica retry every
|
||||
* event (including the `newStep`) is reprocessed.
|
||||
* - Reprocessing `newStep` re-runs `openTurn`, which mints a SECOND assistant.
|
||||
* The first one (created before the throw, already carrying the turn's usage
|
||||
* but no flushed content) is orphaned as an empty shell — content empty,
|
||||
* tools 0, usage present. Exactly the "空壳条" in the reported triad.
|
||||
*
|
||||
* This test simulates a mid-batch DB failure on replica A, then a cold replica
|
||||
* (`__resetOperationStatesForTesting()`) processing the producer's resend.
|
||||
*/
|
||||
|
||||
interface FakeMessage {
|
||||
agentId: string | null;
|
||||
content: string;
|
||||
id: string;
|
||||
metadata?: any;
|
||||
model?: string;
|
||||
parentId?: string | null;
|
||||
plugin?: any;
|
||||
reasoning?: any;
|
||||
role: 'user' | 'assistant' | 'tool' | 'task' | 'system';
|
||||
threadId?: string | null;
|
||||
tool_call_id?: string;
|
||||
tools?: any[];
|
||||
topicId: string | null;
|
||||
}
|
||||
|
||||
const SEED = 'asst-seed';
|
||||
const OP = 'op-1';
|
||||
const TOPIC = 'topic-1';
|
||||
|
||||
const createHarness = () => {
|
||||
let nextMsgIdSeq = 0;
|
||||
const messages = new Map<string, FakeMessage>();
|
||||
|
||||
// Faithful topic-metadata store: the real TopicModel.updateMetadata DEEP-MERGES
|
||||
// into the JSONB column. The main-chain cold-replica recovery reads
|
||||
// `heteroCurrentMsgId` from here, so a no-op mock (as in the subagent test)
|
||||
// would not exercise the path under test.
|
||||
let topicMetadata: Record<string, any> = {
|
||||
runningOperation: { assistantMessageId: SEED, operationId: OP },
|
||||
};
|
||||
|
||||
// Trip a single mid-batch DB failure: the Nth `messageModel.update` throws once.
|
||||
let updateCalls = 0;
|
||||
let failUpdateAtCall = -1;
|
||||
|
||||
// Seed the run's first-turn assistant (already has content, like a real run
|
||||
// where `newStep` opens the SECOND turn).
|
||||
messages.set(SEED, {
|
||||
agentId: null,
|
||||
content: 'first turn answer',
|
||||
id: SEED,
|
||||
role: 'assistant',
|
||||
topicId: TOPIC,
|
||||
});
|
||||
|
||||
const messageModel = {
|
||||
create: vi.fn(async (input: Partial<FakeMessage>, id?: string) => {
|
||||
nextMsgIdSeq += 1;
|
||||
const msgId = id ?? `msg_${nextMsgIdSeq}`;
|
||||
const msg = {
|
||||
agentId: input.agentId ?? null,
|
||||
content: input.content ?? '',
|
||||
id: msgId,
|
||||
metadata: input.metadata,
|
||||
model: input.model,
|
||||
parentId: input.parentId ?? null,
|
||||
plugin: input.plugin,
|
||||
reasoning: input.reasoning,
|
||||
role: input.role!,
|
||||
threadId: input.threadId ?? null,
|
||||
tool_call_id: input.tool_call_id,
|
||||
tools: input.tools,
|
||||
topicId: input.topicId ?? null,
|
||||
} as FakeMessage;
|
||||
messages.set(msgId, msg);
|
||||
return msg;
|
||||
}),
|
||||
update: vi.fn(async (id: string, patch: Partial<FakeMessage>) => {
|
||||
updateCalls += 1;
|
||||
if (updateCalls === failUpdateAtCall) {
|
||||
throw new Error('simulated mid-batch DB failure');
|
||||
}
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
const next = { ...existing, ...patch };
|
||||
if (patch.metadata && existing.metadata) {
|
||||
next.metadata = { ...existing.metadata, ...patch.metadata };
|
||||
}
|
||||
messages.set(id, next);
|
||||
return { success: true };
|
||||
}),
|
||||
updateToolMessage: vi.fn(async (id: string, patch: any) => {
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
messages.set(id, { ...existing, content: patch.content ?? existing.content });
|
||||
return { success: true };
|
||||
}),
|
||||
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
|
||||
query: vi.fn(async (params: { threadId?: string; topicId?: string }) => {
|
||||
if (params?.threadId)
|
||||
return [...messages.values()].filter((m) => m.threadId === params.threadId);
|
||||
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
|
||||
}),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
const match = [...messages.values()].findLast(
|
||||
(m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId,
|
||||
);
|
||||
return match?.id;
|
||||
}),
|
||||
listMessagePluginsByTopic: vi.fn(async () =>
|
||||
[...messages.values()]
|
||||
.filter((m) => m.role === 'tool' && m.tool_call_id)
|
||||
.map((m) => ({ id: m.id, toolCallId: m.tool_call_id! })),
|
||||
),
|
||||
};
|
||||
|
||||
const topicModel = {
|
||||
findById: vi.fn(async (id: string) => {
|
||||
if (id !== TOPIC) return null;
|
||||
return { agentId: null, id, metadata: topicMetadata };
|
||||
}),
|
||||
updateMetadata: vi.fn(async (_id: string, patch: Record<string, any>) => {
|
||||
// Deep-merge top-level keys, matching the real model.
|
||||
topicMetadata = { ...topicMetadata, ...patch };
|
||||
}),
|
||||
};
|
||||
|
||||
const threadModel = {
|
||||
create: vi.fn(async () => {}),
|
||||
findById: vi.fn(async () => null),
|
||||
queryByTopicId: vi.fn(async () => []),
|
||||
update: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const handler = new HeterogeneousPersistenceHandler({
|
||||
messageModel: messageModel as any,
|
||||
threadModel: threadModel as any,
|
||||
topicModel: topicModel as any,
|
||||
});
|
||||
|
||||
return {
|
||||
handler,
|
||||
messages,
|
||||
setFailUpdateAtCall: (n: number) => {
|
||||
failUpdateAtCall = n;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const buildEvent = (
|
||||
type: AgentStreamEvent['type'],
|
||||
stepIndex: number,
|
||||
data: Record<string, unknown>,
|
||||
): AgentStreamEvent => ({
|
||||
data,
|
||||
operationId: OP,
|
||||
stepIndex,
|
||||
timestamp: 1_700_000_000_000 + stepIndex,
|
||||
type,
|
||||
});
|
||||
|
||||
describe('HeterogeneousPersistenceHandler — main turn survives a cold-replica retry', () => {
|
||||
beforeEach(() => __resetOperationStatesForTesting());
|
||||
afterEach(() => __resetOperationStatesForTesting());
|
||||
|
||||
it('does NOT fork one main turn into a duplicate + empty shell when a batch is retried on a cold replica', async () => {
|
||||
const h = createHarness();
|
||||
|
||||
// The producer's batch for a turn boundary: open a new turn, record its
|
||||
// usage, then a tool batch. We trip the DB to fail on the tool-batch
|
||||
// Phase-1 update, AFTER the turn's usage has already been written to the
|
||||
// new assistant — so the orphan left behind is a true usage-bearing empty
|
||||
// shell. `update` call order on replica A: #1 = openTurn flush of the seed's
|
||||
// first-turn content, #2 = recordUsage on the new assistant, #3 = tools[]
|
||||
// Phase 1 (← throws).
|
||||
const batch = [
|
||||
buildEvent('stream_start', 1, {
|
||||
messageId: 'cc-msg-2',
|
||||
newStep: true,
|
||||
provider: 'claude-code',
|
||||
}),
|
||||
buildEvent('step_complete', 1, {
|
||||
phase: 'turn_metadata',
|
||||
usage: { totalInputTokens: 64_700, totalTokens: 64_700 },
|
||||
}),
|
||||
buildEvent('stream_chunk', 1, {
|
||||
chunkType: 'tools_calling',
|
||||
toolsCalling: [
|
||||
{ apiName: 'Bash', arguments: '{}', id: 'tc-1', identifier: 'bash', type: 'default' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
// ── Replica A: processes newStep (creates the turn assistant) + usage, then
|
||||
// THROWS on the tool-batch write. The batch is left un-acked. ──
|
||||
h.setFailUpdateAtCall(3);
|
||||
await expect(
|
||||
h.handler.ingest({
|
||||
assistantMessageId: SEED,
|
||||
events: batch,
|
||||
operationId: OP,
|
||||
topicId: TOPIC,
|
||||
}),
|
||||
).rejects.toThrow('simulated mid-batch DB failure');
|
||||
|
||||
// ── Cold replica: warm operation state (incl. processedKeys) is gone; the DB
|
||||
// persists. The producer re-sends the SAME batch. ──
|
||||
__resetOperationStatesForTesting();
|
||||
|
||||
// ── Replica B: full batch succeeds this time. ──
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: SEED,
|
||||
events: batch,
|
||||
operationId: OP,
|
||||
topicId: TOPIC,
|
||||
});
|
||||
|
||||
// One `newStep` must yield exactly ONE new turn assistant (besides the seed).
|
||||
const turnAssistants = [...h.messages.values()].filter(
|
||||
(m) => m.role === 'assistant' && m.id !== SEED,
|
||||
);
|
||||
|
||||
// Empty-shell detector: an assistant with usage but no content and no child tools.
|
||||
const childToolsOf = (asstId: string) =>
|
||||
[...h.messages.values()].filter((m) => m.role === 'tool' && m.parentId === asstId);
|
||||
const emptyShells = turnAssistants.filter(
|
||||
(m) => !m.content && childToolsOf(m.id).length === 0 && !!m.metadata?.usage,
|
||||
);
|
||||
|
||||
expect(emptyShells).toHaveLength(0);
|
||||
expect(turnAssistants).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+132
-1
@@ -101,7 +101,13 @@ const createHarness = (params: {
|
||||
update: vi.fn(async (id: string, patch: Partial<FakeMessage>) => {
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
messages.set(id, { ...existing, ...patch });
|
||||
// Mirror the real MessageModel.update: metadata is DEEP-MERGED, not
|
||||
// replaced — so e.g. a usage write doesn't clobber subagentMessageId.
|
||||
const next = { ...existing, ...patch };
|
||||
if (patch.metadata && existing.metadata) {
|
||||
next.metadata = { ...existing.metadata, ...patch.metadata };
|
||||
}
|
||||
messages.set(id, next);
|
||||
return { success: true };
|
||||
}),
|
||||
updateToolMessage: vi.fn(async (id: string, patch: any) => {
|
||||
@@ -268,6 +274,66 @@ describe('HeterogeneousPersistenceHandler — subagent run survives a cold repli
|
||||
expect([...h.threads.values()].some((t) => t.title === 'Subagent')).toBe(false);
|
||||
});
|
||||
|
||||
// The screenshot bug: a subagent that already FINISHED (its parent
|
||||
// tool_result landed → thread flipped Active) has its FIRST event replayed on
|
||||
// a cold replica (BatchIngester retry / re-delivery where the in-memory
|
||||
// `processedKeys` dedupe is gone). Because finalized threads aren't rehydrated
|
||||
// as live runs, the empty reducer used to hit `!existing` and fork a SECOND
|
||||
// thread with the identical title ("一模一样的两个 thread"). The fix records
|
||||
// the finalized parent's `sourceToolCallId` in `finalizedParents` from the DB
|
||||
// `Active` thread, so the replayed first-event is a stale no-op.
|
||||
it('does NOT re-create the thread when a FINISHED subagent replays its first event on a fresh replica', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
const PARENT = 'tc-spawn-1';
|
||||
|
||||
const firstChunk = buildEvent('stream_chunk', 0, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
spawnMetadata: {
|
||||
description: 'Map client runtime completion paths',
|
||||
prompt: 'investigate',
|
||||
subagentType: 'Explore',
|
||||
},
|
||||
subagentMessageId: 'sub-msg-1',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-1')],
|
||||
});
|
||||
|
||||
// ── Batch 1 (replica A): subagent runs, then its parent tool_result lands →
|
||||
// the run finalizes and the thread is flipped Active. ──
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [firstChunk, buildEvent('tool_result', 1, { content: 'done', toolCallId: PARENT })],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
expect(h.threads.size).toBe(1);
|
||||
const finishedThreadId = [...h.threads.keys()][0];
|
||||
expect(h.threads.get(finishedThreadId)!.status).toBe('active');
|
||||
|
||||
// ── Cold replica: warm state gone, DB persists. ──
|
||||
__resetOperationStatesForTesting();
|
||||
|
||||
// ── Replay of the SAME first event (processedKeys is empty on the fresh
|
||||
// replica, so it is NOT deduped away — it really re-enters the reducer). ──
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [firstChunk],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// Still exactly one thread — no duplicate, no second "Map client runtime…".
|
||||
expect(h.threads.size).toBe(1);
|
||||
expect([...h.threads.keys()]).toEqual([finishedThreadId]);
|
||||
});
|
||||
|
||||
// P1: a tools_calling batch reprocessed on a cold replica (BatchIngester
|
||||
// retry, or a turn split across a cold boundary so the cumulative array is
|
||||
// re-seen) must NOT mint a second tool message for an inner tool the run
|
||||
@@ -371,4 +437,69 @@ describe('HeterogeneousPersistenceHandler — subagent run survives a cold repli
|
||||
expect(h.threads.get('thd-stale')!.status).toBe('processing');
|
||||
expect(h.threadModel.update).not.toHaveBeenCalledWith('thd-stale', expect.anything());
|
||||
});
|
||||
|
||||
// The in-thread analog of the cold-replica bug: one CC subagent turn continued
|
||||
// on a fresh replica must NOT fork into a second in-thread assistant. The turn's
|
||||
// CC message.id is persisted on the assistant's metadata and recovered into
|
||||
// `currentSubagentMessageId`, so a continuation is recognized as the SAME turn.
|
||||
it('does NOT fragment one CC subagent turn across a cold replica (no split / empty shell)', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
const PARENT = 'tc-spawn-1';
|
||||
|
||||
// Batch 1: turn sub-1's first tool → lazy-create thread + user + in-thread
|
||||
// assistant (stamped subagentMessageId=sub-1) + tool t1.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
spawnMetadata: { prompt: 'go', subagentType: 'Explore' },
|
||||
subagentMessageId: 'sub-1',
|
||||
},
|
||||
toolsCalling: [innerTool('t1')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const threadId = [...h.threads.keys()][0];
|
||||
const assistantsOf = () =>
|
||||
[...h.messages.values()].filter((m) => m.role === 'assistant' && m.threadId === threadId);
|
||||
expect(assistantsOf()).toHaveLength(1);
|
||||
// The turn id was persisted so a cold replica can recover it.
|
||||
expect(assistantsOf()[0].metadata?.subagentMessageId).toBe('sub-1');
|
||||
|
||||
__resetOperationStatesForTesting(); // cold replica
|
||||
|
||||
// Batch 2 (fresh replica): SAME turn sub-1 continues (cumulative [t1, t2]).
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 1, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: { parentToolCallId: PARENT, subagentMessageId: 'sub-1' },
|
||||
toolsCalling: [innerTool('t1'), innerTool('t2')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// Still exactly ONE in-thread assistant — no fork, no empty shell.
|
||||
const assistants = assistantsOf();
|
||||
expect(assistants).toHaveLength(1);
|
||||
// Both tool rows hang off that same assistant (t1 not duplicated).
|
||||
const toolRows = [...h.messages.values()].filter(
|
||||
(m) => m.role === 'tool' && (m.tool_call_id === 't1' || m.tool_call_id === 't2'),
|
||||
);
|
||||
expect(toolRows).toHaveLength(2);
|
||||
expect(new Set(toolRows.map((m) => m.parentId))).toEqual(new Set([assistants[0].id]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { KlavisService } from './index';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
PluginModel: vi.fn(),
|
||||
pluginQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/plugin', () => ({
|
||||
PluginModel: mocks.PluginModel,
|
||||
}));
|
||||
|
||||
vi.mock('@/libs/klavis', () => ({
|
||||
getKlavisClient: vi.fn(),
|
||||
isKlavisClientAvailable: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('debug', () => ({
|
||||
default: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
describe('KlavisService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.PluginModel.mockImplementation(() => ({
|
||||
query: mocks.pluginQuery,
|
||||
}));
|
||||
});
|
||||
|
||||
describe('getKlavisManifests', () => {
|
||||
it('filters deprecated Klavis providers from server manifests', async () => {
|
||||
mocks.pluginQuery.mockResolvedValue([
|
||||
{
|
||||
customParams: { klavis: { isAuthenticated: true, serverName: 'Gmail' } },
|
||||
identifier: 'gmail',
|
||||
manifest: {
|
||||
api: [{ name: 'sendEmail', parameters: { type: 'object' } }],
|
||||
meta: { title: 'Gmail' },
|
||||
},
|
||||
},
|
||||
{
|
||||
customParams: { klavis: { isAuthenticated: true, serverName: 'Notion' } },
|
||||
identifier: 'notion',
|
||||
manifest: {
|
||||
api: [{ name: 'notion-search', parameters: { type: 'object' } }],
|
||||
meta: { title: 'Notion' },
|
||||
},
|
||||
},
|
||||
{
|
||||
customParams: { klavis: { isAuthenticated: false, serverName: 'Google Calendar' } },
|
||||
identifier: 'google-calendar',
|
||||
manifest: {
|
||||
api: [{ name: 'listEvents', parameters: { type: 'object' } }],
|
||||
meta: { title: 'Google Calendar' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new KlavisService({ db: {} as any, userId: 'user-1' });
|
||||
|
||||
const manifests = await service.getKlavisManifests();
|
||||
|
||||
expect(manifests.map((manifest) => manifest.identifier)).toEqual(['gmail']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,241 +0,0 @@
|
||||
import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
|
||||
import type { LobeToolManifest } from '@lobechat/context-engine';
|
||||
import type { LobeChatDatabase } from '@lobechat/database';
|
||||
import debug from 'debug';
|
||||
|
||||
import { PluginModel } from '@/database/models/plugin';
|
||||
import { getKlavisClient, isKlavisClientAvailable } from '@/libs/klavis';
|
||||
import { type ToolExecutionResult } from '@/server/services/toolExecution/types';
|
||||
|
||||
const log = debug('lobe-server:klavis-service');
|
||||
|
||||
const VALID_KLAVIS_IDENTIFIERS = new Set(KLAVIS_SERVER_TYPES.map((type) => type.identifier));
|
||||
|
||||
export interface KlavisToolExecuteParams {
|
||||
args: Record<string, any>;
|
||||
/** Tool identifier (same as Klavis server identifier, e.g., 'google-calendar') */
|
||||
identifier: string;
|
||||
toolName: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface KlavisServiceOptions {
|
||||
db?: LobeChatDatabase;
|
||||
userId?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Klavis Service
|
||||
*
|
||||
* Provides a unified interface to Klavis Client with business logic encapsulation.
|
||||
* This service wraps Klavis Client methods to execute tools and fetch manifests.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* // With database and userId (for manifest fetching)
|
||||
* const service = new KlavisService({ db, userId });
|
||||
* await service.executeKlavisTool({ identifier, toolName, args });
|
||||
*
|
||||
* // Without database (for tool execution only if you have serverUrl)
|
||||
* const service = new KlavisService();
|
||||
* ```
|
||||
*/
|
||||
export class KlavisService {
|
||||
private db?: LobeChatDatabase;
|
||||
private userId?: string;
|
||||
private pluginModel?: PluginModel;
|
||||
private workspaceId?: string;
|
||||
|
||||
constructor(options: KlavisServiceOptions = {}) {
|
||||
const { db, userId, workspaceId } = options;
|
||||
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
this.workspaceId = workspaceId;
|
||||
|
||||
if (db && userId) {
|
||||
this.pluginModel = new PluginModel(db, userId, workspaceId);
|
||||
}
|
||||
|
||||
log(
|
||||
'KlavisService initialized: hasDB=%s, hasUserId=%s, isClientAvailable=%s',
|
||||
!!db,
|
||||
!!userId,
|
||||
isKlavisClientAvailable(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Klavis tool
|
||||
* @param params - Tool execution parameters
|
||||
* @returns Tool execution result
|
||||
*/
|
||||
async executeKlavisTool(params: KlavisToolExecuteParams): Promise<ToolExecutionResult> {
|
||||
const { identifier, toolName, args, workspaceId } = params;
|
||||
|
||||
log('executeKlavisTool: %s/%s with args: %O', identifier, toolName, args);
|
||||
|
||||
// Check if Klavis client is available
|
||||
if (!isKlavisClientAvailable()) {
|
||||
return {
|
||||
content: 'Klavis service is not configured on server',
|
||||
error: { code: 'KLAVIS_NOT_CONFIGURED', message: 'Klavis API key not found' },
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Get serverUrl from plugin database
|
||||
if (!this.pluginModel) {
|
||||
return {
|
||||
content: 'Klavis service is not properly initialized',
|
||||
error: {
|
||||
code: 'KLAVIS_NOT_INITIALIZED',
|
||||
message: 'Database and userId are required for Klavis tool execution',
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get plugin from database to retrieve serverUrl
|
||||
const pluginModel =
|
||||
workspaceId && this.db && this.userId
|
||||
? new PluginModel(this.db, this.userId, workspaceId)
|
||||
: this.pluginModel;
|
||||
const plugin = await pluginModel.findById(identifier);
|
||||
if (!plugin) {
|
||||
return {
|
||||
content: `Klavis server "${identifier}" not found in database`,
|
||||
error: { code: 'KLAVIS_SERVER_NOT_FOUND', message: `Server ${identifier} not found` },
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const klavisParams = plugin.customParams?.klavis;
|
||||
if (!klavisParams || !klavisParams.serverUrl) {
|
||||
return {
|
||||
content: `Klavis configuration not found for server "${identifier}"`,
|
||||
error: {
|
||||
code: 'KLAVIS_CONFIG_NOT_FOUND',
|
||||
message: `Klavis configuration missing for ${identifier}`,
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { serverUrl } = klavisParams;
|
||||
|
||||
log('executeKlavisTool: calling Klavis API with serverUrl=%s', serverUrl);
|
||||
|
||||
// Call Klavis client
|
||||
const klavisClient = getKlavisClient();
|
||||
const response = await klavisClient.mcpServer.callTools({
|
||||
serverUrl,
|
||||
toolArgs: args,
|
||||
toolName,
|
||||
});
|
||||
|
||||
log('executeKlavisTool: response: %O', response);
|
||||
|
||||
// Handle error case
|
||||
if (!response.success || !response.result) {
|
||||
return {
|
||||
content: response.error || 'Unknown error',
|
||||
error: { code: 'KLAVIS_EXECUTION_ERROR', message: response.error || 'Unknown error' },
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Process the response
|
||||
const content = response.result.content || [];
|
||||
const isError = response.result.isError || false;
|
||||
|
||||
// Convert content array to string
|
||||
let resultContent = '';
|
||||
if (Array.isArray(content)) {
|
||||
resultContent = content
|
||||
.map((item: any) => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (item.type === 'text' && item.text) return item.text;
|
||||
return JSON.stringify(item);
|
||||
})
|
||||
.join('\n');
|
||||
} else if (typeof content === 'string') {
|
||||
resultContent = content;
|
||||
} else {
|
||||
resultContent = JSON.stringify(content);
|
||||
}
|
||||
|
||||
return {
|
||||
content: resultContent,
|
||||
success: !isError,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('KlavisService.executeKlavisTool error %s/%s: %O', identifier, toolName, err);
|
||||
|
||||
return {
|
||||
content: err.message,
|
||||
error: { code: 'KLAVIS_ERROR', message: err.message },
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Klavis tool manifests from database
|
||||
* Gets user's connected Klavis servers and builds tool manifests for agent execution
|
||||
*
|
||||
* @returns Array of tool manifests for connected Klavis servers
|
||||
*/
|
||||
async getKlavisManifests(): Promise<LobeToolManifest[]> {
|
||||
if (!this.pluginModel) {
|
||||
log('getKlavisManifests: pluginModel not available, returning empty array');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all plugins from database
|
||||
const allPlugins = await this.pluginModel.query();
|
||||
|
||||
// Filter plugins that have klavis customParams, are still supported, and are authenticated.
|
||||
const klavisPlugins = allPlugins.filter(
|
||||
(plugin) =>
|
||||
VALID_KLAVIS_IDENTIFIERS.has(plugin.identifier) &&
|
||||
plugin.customParams?.klavis?.isAuthenticated === true,
|
||||
);
|
||||
|
||||
log('getKlavisManifests: found %d authenticated Klavis plugins', klavisPlugins.length);
|
||||
|
||||
// Convert to LobeToolManifest format
|
||||
const manifests: LobeToolManifest[] = klavisPlugins
|
||||
.map((plugin) => {
|
||||
if (!plugin.manifest) return null;
|
||||
|
||||
return {
|
||||
api: plugin.manifest.api || [],
|
||||
author: 'Klavis',
|
||||
homepage: 'https://klavis.ai',
|
||||
identifier: plugin.identifier,
|
||||
meta: plugin.manifest.meta || {
|
||||
avatar: '☁️',
|
||||
description: `Klavis MCP Server: ${plugin.customParams?.klavis?.serverName}`,
|
||||
tags: ['klavis', 'mcp'],
|
||||
title: plugin.customParams?.klavis?.serverName || plugin.identifier,
|
||||
},
|
||||
type: 'builtin',
|
||||
version: '1.0.0',
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as LobeToolManifest[];
|
||||
|
||||
log('getKlavisManifests: returning %d manifests', manifests.length);
|
||||
|
||||
return manifests;
|
||||
} catch (error) {
|
||||
console.error('KlavisService.getKlavisManifests error: %O', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export class MCPService {
|
||||
|
||||
/**
|
||||
* Process MCP tool call result with content blocks processing
|
||||
* This is a common utility method that can be used by both internal MCP calls and external services (e.g., Klavis)
|
||||
* This is a common utility method that can be used by both internal MCP calls and external services (e.g., Composio)
|
||||
*/
|
||||
static async processToolCallResult(
|
||||
result: MCPToolCallRawResult,
|
||||
|
||||
@@ -281,19 +281,19 @@ describe('isTemplateSkillSourceEligible', () => {
|
||||
|
||||
it('drops templates whose source is not in enabledSkillSources', () => {
|
||||
const t = makeTemplate({ requiresSkills: [{ provider: 'notion', source: 'lobehub' }] });
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['klavis']))).toBe(false);
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['composio']))).toBe(false);
|
||||
});
|
||||
|
||||
it('requires every source for multi-skill templates', () => {
|
||||
const t = makeTemplate({
|
||||
requiresSkills: [
|
||||
{ provider: 'notion', source: 'lobehub' },
|
||||
{ provider: 'google-calendar', source: 'klavis' },
|
||||
{ provider: 'google-calendar', source: 'composio' },
|
||||
],
|
||||
});
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub']))).toBe(false);
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['klavis']))).toBe(false);
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub', 'klavis']))).toBe(true);
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['composio']))).toBe(false);
|
||||
expect(isTemplateSkillSourceEligible(t, new Set(['lobehub', 'composio']))).toBe(true);
|
||||
});
|
||||
|
||||
it('treats empty requiresSkills array same as undefined (always eligible)', () => {
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
taskTemplates,
|
||||
} from '@lobechat/const';
|
||||
|
||||
import { klavisEnv } from '@/config/klavis';
|
||||
import { composioEnv } from '@/config/composio';
|
||||
import { appEnv } from '@/envs/app';
|
||||
|
||||
export const ENABLED_SKILL_SOURCES: ReadonlySet<TaskTemplateSkillSource> = (() => {
|
||||
const sources = new Set<TaskTemplateSkillSource>();
|
||||
if (klavisEnv.KLAVIS_API_KEY) sources.add('klavis');
|
||||
if (composioEnv.COMPOSIO_API_KEY) sources.add('composio');
|
||||
if (appEnv.MARKET_TRUSTED_CLIENT_ID && appEnv.MARKET_TRUSTED_CLIENT_SECRET) {
|
||||
sources.add('lobehub');
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ vi.mock('../serverRuntimes', () => ({
|
||||
getServerRuntime: vi.fn(async () => ({ createDocument: mockApiHandler })),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/klavis', () => ({
|
||||
KlavisService: vi.fn().mockImplementation(() => ({})),
|
||||
vi.mock('@/server/services/composio', () => ({
|
||||
ComposioService: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
vi.mock('@/server/services/market', () => ({
|
||||
MarketService: vi.fn().mockImplementation(() => ({})),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type ChatToolPayload } from '@lobechat/types';
|
||||
import { detectTruncatedJSON, safeParseJSON } from '@lobechat/utils';
|
||||
import debug from 'debug';
|
||||
|
||||
import { KlavisService } from '@/server/services/klavis';
|
||||
import { ComposioService } from '@/server/services/composio';
|
||||
import { MarketService } from '@/server/services/market';
|
||||
|
||||
import { getServerRuntime, hasServerRuntime } from './serverRuntimes';
|
||||
@@ -13,11 +13,11 @@ const log = debug('lobe-server:builtin-tools-executor');
|
||||
|
||||
export class BuiltinToolsExecutor implements IToolExecutor {
|
||||
private marketService: MarketService;
|
||||
private klavisService: KlavisService;
|
||||
private composioService: ComposioService;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.marketService = new MarketService({ userInfo: { userId } });
|
||||
this.klavisService = new KlavisService({ db, userId });
|
||||
this.composioService = new ComposioService({ db, userId });
|
||||
}
|
||||
|
||||
async execute(
|
||||
@@ -78,13 +78,12 @@ export class BuiltinToolsExecutor implements IToolExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
// Route Klavis tools to KlavisService
|
||||
if (source === 'klavis') {
|
||||
return this.klavisService.executeKlavisTool({
|
||||
// Route Composio tools to ComposioService
|
||||
if (source === 'composio') {
|
||||
return this.composioService.executeComposioTool({
|
||||
args,
|
||||
identifier,
|
||||
toolName: apiName,
|
||||
workspaceId: context.workspaceId,
|
||||
toolSlug: apiName,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export class ToolExecutionService {
|
||||
|
||||
// ── Connector tool permission gate (covers ALL paths + qstash) ────────
|
||||
// Check before any execution so that disabled tools are blocked universally:
|
||||
// Lobehub market skills, Klavis, MCP connectors, and execAgent/qstash alike.
|
||||
// Lobehub market skills, Composio, MCP connectors, and execAgent/qstash alike.
|
||||
// needs_approval is handled via humanIntervention in the manifest; we only
|
||||
// hard-block 'disabled' here (and needs_approval in headless/qstash context
|
||||
// since the manifest's humanIntervention auto-rejects them there already).
|
||||
|
||||
+106
@@ -74,6 +74,112 @@ describe('agentManagementRuntime', () => {
|
||||
expect(PluginModel).toHaveBeenCalledWith(expect.anything(), 'user-1', 'workspace-1');
|
||||
});
|
||||
|
||||
describe('callAgent', () => {
|
||||
it('fails when the server sub-agent runner is unavailable', async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await runtime.callAgent(
|
||||
{
|
||||
agentId: 'agent-target',
|
||||
instruction: 'Do delegated work',
|
||||
runAsTask: true,
|
||||
},
|
||||
{ toolManifestMap: {} },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatchObject({ code: 'AGENT_CALL_UNAVAILABLE' });
|
||||
});
|
||||
|
||||
it('returns a deferred tool result and forks the target agent through the sub-agent runner', async () => {
|
||||
const run = vi.fn().mockResolvedValue({
|
||||
started: true,
|
||||
subOperationId: 'op-child',
|
||||
threadId: 'thread-child',
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await runtime.callAgent(
|
||||
{
|
||||
agentId: 'agent-target',
|
||||
instruction: 'Do delegated work',
|
||||
runAsTask: true,
|
||||
taskTitle: 'Delegated task',
|
||||
timeout: 1234,
|
||||
},
|
||||
{
|
||||
subAgent: { run },
|
||||
toolManifestMap: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(run).toHaveBeenCalledWith({
|
||||
agentId: 'agent-target',
|
||||
description: 'Delegated task',
|
||||
instruction: 'Do delegated work',
|
||||
timeout: 1234,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
content: '',
|
||||
deferred: true,
|
||||
success: true,
|
||||
});
|
||||
expect(result.state).toMatchObject({
|
||||
status: 'pending',
|
||||
subOperationId: 'op-child',
|
||||
targetAgentId: 'agent-target',
|
||||
threadId: 'thread-child',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a non-deferred failure when the target agent cannot start', async () => {
|
||||
const run = vi.fn().mockResolvedValue({
|
||||
started: false,
|
||||
threadId: '',
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await runtime.callAgent(
|
||||
{
|
||||
agentId: 'agent-target',
|
||||
instruction: 'Do delegated work',
|
||||
runAsTask: true,
|
||||
},
|
||||
{
|
||||
subAgent: { run },
|
||||
toolManifestMap: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatchObject({
|
||||
code: 'AGENT_CALL_START_FAILED',
|
||||
});
|
||||
expect(result.deferred).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects nested server callAgent execution', async () => {
|
||||
const run = vi.fn();
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await runtime.callAgent(
|
||||
{
|
||||
agentId: 'agent-target',
|
||||
instruction: 'Do delegated work',
|
||||
},
|
||||
{
|
||||
isSubAgent: true,
|
||||
subAgent: { run },
|
||||
toolManifestMap: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatchObject({ code: 'NESTED_AGENT_CALL_NOT_ALLOWED' });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchAgent', () => {
|
||||
it('reports the real total and a pagination hint when more agents exist', async () => {
|
||||
mockQueryAgents.mockResolvedValue(makeAgents(20));
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ToolExecutionContext } from '../../types';
|
||||
import { groupManagementRuntime } from '../groupManagement';
|
||||
|
||||
const run = vi.fn();
|
||||
|
||||
const makeCtx = (overrides?: Partial<ToolExecutionContext>): ToolExecutionContext =>
|
||||
({
|
||||
agentMember: { run },
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
...overrides,
|
||||
}) as ToolExecutionContext;
|
||||
|
||||
const runtime = () => groupManagementRuntime.factory(makeCtx()) as any;
|
||||
|
||||
describe('groupManagementRuntime', () => {
|
||||
beforeEach(() => {
|
||||
run.mockReset();
|
||||
run.mockResolvedValue({ started: true, startedCount: 1 });
|
||||
});
|
||||
|
||||
describe('speak', () => {
|
||||
it('forks one in-group member and resumes the supervisor', async () => {
|
||||
const result = await runtime().speak({ agentId: 'agent-a', instruction: 'hi' }, makeCtx());
|
||||
|
||||
expect(run).toHaveBeenCalledWith({
|
||||
members: [{ agentId: 'agent-a', instruction: 'hi' }],
|
||||
mode: 'in_group',
|
||||
onComplete: 'resume',
|
||||
});
|
||||
expect(result).toMatchObject({ deferred: true, success: true });
|
||||
expect(result.state).toMatchObject({ status: 'pending', type: 'speak' });
|
||||
});
|
||||
|
||||
it('finishes the supervisor when skipCallSupervisor is set', async () => {
|
||||
await runtime().speak({ agentId: 'agent-a', skipCallSupervisor: true }, makeCtx());
|
||||
expect(run).toHaveBeenCalledWith(expect.objectContaining({ onComplete: 'finish' }));
|
||||
});
|
||||
|
||||
it('errors without agentId', async () => {
|
||||
const result = await runtime().speak({} as any, makeCtx());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('errors when the agentMember runner is unavailable', async () => {
|
||||
const result = await runtime().speak(
|
||||
{ agentId: 'agent-a' },
|
||||
makeCtx({ agentMember: undefined }),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe('AGENT_MEMBER_UNAVAILABLE');
|
||||
});
|
||||
|
||||
it('surfaces an inline error when no member started', async () => {
|
||||
run.mockResolvedValue({ started: false, startedCount: 0 });
|
||||
const result = await runtime().speak({ agentId: 'agent-a' }, makeCtx());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe('AGENT_MEMBER_START_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast', () => {
|
||||
it('forks N in-group members with tools disabled', async () => {
|
||||
run.mockResolvedValue({ started: true, startedCount: 2 });
|
||||
const result = await runtime().broadcast(
|
||||
{ agentIds: ['a', 'b'], instruction: 'go' },
|
||||
makeCtx(),
|
||||
);
|
||||
|
||||
expect(run).toHaveBeenCalledWith({
|
||||
disableTools: true,
|
||||
members: [
|
||||
{ agentId: 'a', instruction: 'go' },
|
||||
{ agentId: 'b', instruction: 'go' },
|
||||
],
|
||||
mode: 'in_group',
|
||||
onComplete: 'resume',
|
||||
});
|
||||
expect(result).toMatchObject({ deferred: true, success: true });
|
||||
});
|
||||
|
||||
it('errors without agentIds', async () => {
|
||||
const result = await runtime().broadcast({ agentIds: [] } as any, makeCtx());
|
||||
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delegate', () => {
|
||||
it('hands off to a member and finishes (no resume)', async () => {
|
||||
await runtime().delegate({ agentId: 'agent-a', reason: 'you take it' }, makeCtx());
|
||||
expect(run).toHaveBeenCalledWith({
|
||||
members: [{ agentId: 'agent-a', instruction: 'you take it' }],
|
||||
mode: 'in_group',
|
||||
onComplete: 'finish',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeAgentTask', () => {
|
||||
it('runs an isolated-thread member and resumes', async () => {
|
||||
const result = await runtime().executeAgentTask(
|
||||
{ agentId: 'agent-a', instruction: 'do work', timeout: 60_000, title: 'work' },
|
||||
makeCtx(),
|
||||
);
|
||||
expect(run).toHaveBeenCalledWith({
|
||||
members: [{ agentId: 'agent-a', instruction: 'do work' }],
|
||||
mode: 'isolated',
|
||||
onComplete: 'resume',
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(result).toMatchObject({ deferred: true, success: true });
|
||||
});
|
||||
|
||||
it('errors without instruction', async () => {
|
||||
const result = await runtime().executeAgentTask({ agentId: 'agent-a' } as any, makeCtx());
|
||||
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeAgentTasks', () => {
|
||||
it('runs parallel isolated tasks and collapses timeout to the longest', async () => {
|
||||
run.mockResolvedValue({ started: true, startedCount: 2 });
|
||||
await runtime().executeAgentTasks(
|
||||
{
|
||||
tasks: [
|
||||
{ agentId: 'a', instruction: 'ta', timeout: 1000, title: 'A' },
|
||||
{ agentId: 'b', instruction: 'tb', timeout: 5000, title: 'B' },
|
||||
],
|
||||
},
|
||||
makeCtx(),
|
||||
);
|
||||
expect(run).toHaveBeenCalledWith({
|
||||
members: [
|
||||
{ agentId: 'a', instruction: 'ta' },
|
||||
{ agentId: 'b', instruction: 'tb' },
|
||||
],
|
||||
mode: 'isolated',
|
||||
onComplete: 'resume',
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('errors without tasks', async () => {
|
||||
const result = await runtime().executeAgentTasks({ tasks: [] } as any, makeCtx());
|
||||
expect(result.error?.code).toBe('INVALID_ARGUMENTS');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -152,7 +152,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
|
||||
params: UpdateAgentConfigParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const agentId = ctx.agentId;
|
||||
const agentId = ctx.editingAgentId ?? ctx.agentId;
|
||||
|
||||
if (!agentId) {
|
||||
return {
|
||||
@@ -240,7 +240,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
|
||||
params: UpdatePromptParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const agentId = ctx.agentId;
|
||||
const agentId = ctx.editingAgentId ?? ctx.agentId;
|
||||
|
||||
if (!agentId) {
|
||||
return {
|
||||
@@ -272,7 +272,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
|
||||
params: InstallPluginParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const agentId = ctx.agentId;
|
||||
const agentId = ctx.editingAgentId ?? ctx.agentId;
|
||||
|
||||
if (!agentId) {
|
||||
return {
|
||||
@@ -307,9 +307,9 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth-based tools (Klavis, LobehubSkill) cannot be installed in background context
|
||||
// OAuth-based tools (Composio, LobehubSkill) cannot be installed in background context
|
||||
return {
|
||||
content: `Installing official integrations that require OAuth (Klavis, LobehubSkill) is not supported in background execution. Please install "${identifier}" from the Agent Builder UI instead.`,
|
||||
content: `Installing official integrations that require OAuth (Composio, LobehubSkill) is not supported in background execution. Please install "${identifier}" from the Agent Builder UI instead.`,
|
||||
error: { message: 'OAuth not available in background context', type: 'NotSupported' },
|
||||
success: false,
|
||||
};
|
||||
|
||||
@@ -43,20 +43,60 @@ export const agentManagementRuntime: ServerRuntimeRegistration = {
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const { agentId, instruction, taskTitle, timeout } = params;
|
||||
|
||||
// Server runtime always uses the legacy async invocation path because
|
||||
// there is no client-side `registerAfterCompletion` callback available
|
||||
// to execute synchronous agent calls.
|
||||
return {
|
||||
content: `🚀 Triggered async task to call agent "${agentId}"${taskTitle ? `: ${taskTitle}` : ''}`,
|
||||
state: {
|
||||
parentMessageId: ctx.messageId,
|
||||
task: {
|
||||
description: taskTitle || `Call agent ${agentId}`,
|
||||
instruction,
|
||||
targetAgentId: agentId,
|
||||
timeout: timeout || 1_800_000,
|
||||
if (ctx.isSubAgent) {
|
||||
return {
|
||||
content: 'Agent calls cannot be triggered from within another sub-agent.',
|
||||
error: {
|
||||
code: 'NESTED_AGENT_CALL_NOT_ALLOWED',
|
||||
message: 'Agent calls cannot be triggered from within another sub-agent.',
|
||||
},
|
||||
type: 'execSubAgent',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!ctx.subAgent) {
|
||||
return {
|
||||
content: 'Agent execution is not available in this runtime.',
|
||||
error: { code: 'AGENT_CALL_UNAVAILABLE' },
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!instruction || typeof instruction !== 'string') {
|
||||
return {
|
||||
content: 'instruction is required.',
|
||||
error: { code: 'INVALID_ARGUMENTS', message: 'instruction is required.' },
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const description = taskTitle || `Call agent ${agentId}`;
|
||||
const { started, subOperationId, threadId } = await ctx.subAgent.run({
|
||||
agentId,
|
||||
description,
|
||||
instruction,
|
||||
timeout: timeout || 1_800_000,
|
||||
});
|
||||
|
||||
if (!started) {
|
||||
return {
|
||||
content: `Agent "${agentId}" failed to start.`,
|
||||
error: {
|
||||
code: 'AGENT_CALL_START_FAILED',
|
||||
message: `Agent "${agentId}" failed to start.`,
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: '',
|
||||
deferred: true,
|
||||
state: {
|
||||
status: 'pending',
|
||||
subOperationId,
|
||||
targetAgentId: agentId,
|
||||
threadId,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Group Management Server Runtime — server-side group orchestration.
|
||||
*
|
||||
* The supervisor agent runs as a normal durable QStash operation; its
|
||||
* `lobe-group-management` tool calls execute here as deferred tools. Each action
|
||||
* forks group member(s) via the injected `ctx.agentMember` runner and returns
|
||||
* `deferred: true`: the agent runtime parks the supervisor (`waiting_for_async_tool`),
|
||||
* and the group-action member completion bridge backfills + resumes/finishes it
|
||||
* once the K=N member barrier passes.
|
||||
*
|
||||
* - speak → one in-group member, resume (or finish on skipCallSupervisor)
|
||||
* - broadcast → N in-group members (tools disabled), resume/finish
|
||||
* - delegate → one in-group member, finish (supervisor hands off)
|
||||
* - executeAgentTask(s) → isolated thread member(s), resume/finish
|
||||
*
|
||||
* Mirrors the client GroupOrchestrationRuntime semantics, but the supervisor's
|
||||
* own operation IS the orchestration loop — no separate driver.
|
||||
*/
|
||||
import type {
|
||||
BroadcastParams,
|
||||
CreateWorkflowParams,
|
||||
DelegateParams,
|
||||
ExecuteTaskParams,
|
||||
ExecuteTasksParams,
|
||||
InterruptParams,
|
||||
SpeakParams,
|
||||
SummarizeParams,
|
||||
VoteParams,
|
||||
} from '@lobechat/builtin-tool-group-management';
|
||||
import { GroupManagementIdentifier } from '@lobechat/builtin-tool-group-management';
|
||||
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
|
||||
|
||||
import type { ToolExecutionContext } from '../types';
|
||||
import type { ServerRuntimeRegistration } from './types';
|
||||
|
||||
const buildError = (content: string, code: string): BuiltinServerRuntimeOutput => ({
|
||||
content,
|
||||
error: { code, message: content },
|
||||
success: false,
|
||||
});
|
||||
|
||||
const AGENT_MEMBER_UNAVAILABLE = buildError(
|
||||
'Group orchestration is not available in this runtime.',
|
||||
'AGENT_MEMBER_UNAVAILABLE',
|
||||
);
|
||||
|
||||
const START_FAILED = buildError('Agent member(s) failed to start.', 'AGENT_MEMBER_START_FAILED');
|
||||
|
||||
class GroupManagementExecutionRuntime {
|
||||
// ==================== Communication Coordination ====================
|
||||
|
||||
/** Let a single member speak in the shared group session (non-isolated). */
|
||||
speak = async (
|
||||
params: SpeakParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> => {
|
||||
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
|
||||
if (!params.agentId) return buildError('agentId is required.', 'INVALID_ARGUMENTS');
|
||||
|
||||
const { started } = await ctx.agentMember.run({
|
||||
members: [{ agentId: params.agentId, instruction: params.instruction }],
|
||||
mode: 'in_group',
|
||||
onComplete: params.skipCallSupervisor ? 'finish' : 'resume',
|
||||
});
|
||||
if (!started) return START_FAILED;
|
||||
|
||||
return {
|
||||
content: '',
|
||||
deferred: true,
|
||||
state: { agentId: params.agentId, status: 'pending', type: 'speak' },
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
/** Let multiple members respond in parallel (tools disabled — opinions only). */
|
||||
broadcast = async (
|
||||
params: BroadcastParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> => {
|
||||
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
|
||||
const agentIds = params.agentIds ?? [];
|
||||
if (agentIds.length === 0) return buildError('agentIds is required.', 'INVALID_ARGUMENTS');
|
||||
|
||||
const { started } = await ctx.agentMember.run({
|
||||
disableTools: true,
|
||||
members: agentIds.map((agentId) => ({ agentId, instruction: params.instruction })),
|
||||
mode: 'in_group',
|
||||
onComplete: params.skipCallSupervisor ? 'finish' : 'resume',
|
||||
});
|
||||
if (!started) return START_FAILED;
|
||||
|
||||
return {
|
||||
content: '',
|
||||
deferred: true,
|
||||
state: { agentIds, status: 'pending', type: 'broadcast' },
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
/** Delegate the conversation to a member; the supervisor exits afterwards. */
|
||||
delegate = async (
|
||||
params: DelegateParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> => {
|
||||
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
|
||||
if (!params.agentId) return buildError('agentId is required.', 'INVALID_ARGUMENTS');
|
||||
|
||||
const { started } = await ctx.agentMember.run({
|
||||
members: [{ agentId: params.agentId, instruction: params.reason }],
|
||||
mode: 'in_group',
|
||||
// Delegate hands control to the member — finish without another supervisor turn.
|
||||
onComplete: 'finish',
|
||||
});
|
||||
if (!started) return START_FAILED;
|
||||
|
||||
return {
|
||||
content: '',
|
||||
deferred: true,
|
||||
state: { agentId: params.agentId, status: 'pending', type: 'delegate' },
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== Task Execution (isolated threads) ====================
|
||||
|
||||
/**
|
||||
* Run a member as an isolated-thread task. `runInClient` only takes effect on
|
||||
* the desktop client (handled by the client orchestrator); on the cloud/web
|
||||
* server there is no local FS/shell, so the task always runs server-side.
|
||||
*/
|
||||
executeAgentTask = async (
|
||||
params: ExecuteTaskParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> => {
|
||||
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
|
||||
if (!params.agentId || !params.instruction) {
|
||||
return buildError('agentId and instruction are required.', 'INVALID_ARGUMENTS');
|
||||
}
|
||||
|
||||
const { started } = await ctx.agentMember.run({
|
||||
members: [{ agentId: params.agentId, instruction: params.instruction }],
|
||||
mode: 'isolated',
|
||||
onComplete: params.skipCallSupervisor ? 'finish' : 'resume',
|
||||
timeout: params.timeout,
|
||||
});
|
||||
if (!started) return START_FAILED;
|
||||
|
||||
return {
|
||||
content: '',
|
||||
deferred: true,
|
||||
state: { agentId: params.agentId, status: 'pending', type: 'executeAgentTask' },
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
/** Run multiple members as parallel isolated-thread tasks. */
|
||||
executeAgentTasks = async (
|
||||
params: ExecuteTasksParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<BuiltinServerRuntimeOutput> => {
|
||||
if (!ctx.agentMember) return AGENT_MEMBER_UNAVAILABLE;
|
||||
const tasks = params.tasks ?? [];
|
||||
if (tasks.length === 0) return buildError('tasks is required.', 'INVALID_ARGUMENTS');
|
||||
|
||||
const { started } = await ctx.agentMember.run({
|
||||
members: tasks.map((task) => ({ agentId: task.agentId, instruction: task.instruction })),
|
||||
mode: 'isolated',
|
||||
onComplete: params.skipCallSupervisor ? 'finish' : 'resume',
|
||||
// Per-task timeouts collapse to the longest; the barrier waits for all.
|
||||
timeout: tasks.reduce((max, task) => Math.max(max, task.timeout ?? 0), 0) || undefined,
|
||||
});
|
||||
if (!started) return START_FAILED;
|
||||
|
||||
return {
|
||||
content: '',
|
||||
deferred: true,
|
||||
state: { status: 'pending', tasks: tasks.map((t) => t.agentId), type: 'executeAgentTasks' },
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== Not yet implemented on the server ====================
|
||||
// Mirror the client stubs: return inline (non-deferred) results so the
|
||||
// supervisor LLM keeps orchestrating instead of parking.
|
||||
|
||||
interrupt = async (params: InterruptParams): Promise<BuiltinServerRuntimeOutput> => ({
|
||||
content: `Interrupt is not yet supported in server orchestration (task ${params.taskId}).`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
summarize = async (_params: SummarizeParams): Promise<BuiltinServerRuntimeOutput> => ({
|
||||
content: 'Summarize is not yet implemented in server orchestration.',
|
||||
success: true,
|
||||
});
|
||||
|
||||
createWorkflow = async (params: CreateWorkflowParams): Promise<BuiltinServerRuntimeOutput> => ({
|
||||
content: `Workflow creation is not yet implemented ("${params.name}").`,
|
||||
success: true,
|
||||
});
|
||||
|
||||
vote = async (params: VoteParams): Promise<BuiltinServerRuntimeOutput> => ({
|
||||
content: `Voting is not yet implemented (question: "${params.question}").`,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
const runtime = new GroupManagementExecutionRuntime();
|
||||
|
||||
export const groupManagementRuntime: ServerRuntimeRegistration = {
|
||||
factory: () => runtime,
|
||||
identifier: GroupManagementIdentifier,
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import { briefRuntime } from './brief';
|
||||
import { calculatorRuntime } from './calculator';
|
||||
import { cloudSandboxRuntime } from './cloudSandbox';
|
||||
import { credsRuntime } from './creds';
|
||||
import { groupManagementRuntime } from './groupManagement';
|
||||
import { knowledgeBaseRuntime } from './knowledgeBase';
|
||||
import { lobeAgentRuntime } from './lobeAgent';
|
||||
import { lobeDeliveryCheckerRuntime } from './lobeDeliveryChecker';
|
||||
@@ -76,6 +77,7 @@ registerRuntimes([
|
||||
topicReferenceRuntime,
|
||||
userInteractionRuntime,
|
||||
credsRuntime,
|
||||
groupManagementRuntime,
|
||||
knowledgeBaseRuntime,
|
||||
webOnboardingRuntime,
|
||||
lobeAgentRuntime,
|
||||
|
||||
@@ -53,13 +53,80 @@ export interface ServerSubAgentRunner {
|
||||
run: (params: ServerSubAgentRunParams) => Promise<ServerSubAgentRunResult>;
|
||||
}
|
||||
|
||||
export interface ServerAgentMemberRunItem {
|
||||
/** Target group member agent id. */
|
||||
agentId: string;
|
||||
/** Optional supervisor instruction to guide the member's response. */
|
||||
instruction?: string;
|
||||
}
|
||||
|
||||
export interface ServerAgentMemberRunParams {
|
||||
/** Disable tools for the members (used by broadcast — members only voice opinions). */
|
||||
disableTools?: boolean;
|
||||
/** Members to run under the current group-management tool call. */
|
||||
members: ServerAgentMemberRunItem[];
|
||||
/**
|
||||
* Execution mode:
|
||||
* - `in_group`: member runs in the shared group session (non-isolated); its
|
||||
* turns land directly in the group conversation. Used by speak/broadcast/delegate.
|
||||
* - `isolated`: member runs in its own isolation thread. Used by
|
||||
* executeAgentTask(s).
|
||||
*/
|
||||
mode: 'in_group' | 'isolated';
|
||||
/**
|
||||
* Whether, once all members complete, the parked supervisor op should
|
||||
* `resume` (re-enter the supervisor LLM) or `finish` (end the orchestration
|
||||
* without another supervisor turn — for `skipCallSupervisor` / delegate).
|
||||
*/
|
||||
onComplete: 'resume' | 'finish';
|
||||
/** Per-member execution timeout (ms), applied to isolated tasks. */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ServerAgentMemberRunResult {
|
||||
/**
|
||||
* Whether at least one member op was forked. `false` means every member
|
||||
* failed to start — no completion bridge will fire, so the caller must
|
||||
* surface an inline tool error instead of parking the parent.
|
||||
*/
|
||||
started: boolean;
|
||||
/** Number of member ops successfully forked. */
|
||||
startedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side "call agent member" runner injected per tool call by the agent
|
||||
* runtime for group orchestration. Distinct from {@link ServerSubAgentRunner}:
|
||||
* a sub-agent is an isolated child run, whereas a group member can run inside
|
||||
* the shared group session. The runner creates the per-member anchor messages
|
||||
* under the group tool call, forks the member op(s), and returns immediately;
|
||||
* the K=N member barrier backfills the group tool message and resumes/finishes
|
||||
* the parked supervisor once all members complete.
|
||||
*/
|
||||
export interface ServerAgentMemberRunner {
|
||||
run: (params: ServerAgentMemberRunParams) => Promise<ServerAgentMemberRunResult>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionContext {
|
||||
/** Target device ID for device proxy tool calls */
|
||||
activeDeviceId?: string;
|
||||
/** Agent ID executing the tool call */
|
||||
agentId?: string;
|
||||
/**
|
||||
* Server-side "call agent member" runner, injected per tool call by the agent
|
||||
* runtime for group orchestration. The `lobe-group-management` server tool
|
||||
* calls `agentMember.run(...)` to fork member op(s) and returns a `deferred`
|
||||
* result; the member barrier backfills + resumes/finishes the parked supervisor.
|
||||
*/
|
||||
agentMember?: ServerAgentMemberRunner;
|
||||
/** Current page document ID for page-scoped conversations */
|
||||
documentId?: string | null;
|
||||
/**
|
||||
* When scope is 'agent_builder', the ID of the agent being edited. Kept
|
||||
* separate from agentId so message ownership and queryUiMessages remain
|
||||
* bound to the builder builtin; only AgentBuilder tool methods read this.
|
||||
*/
|
||||
editingAgentId?: string;
|
||||
/**
|
||||
* Legacy agent invocation callback forwarded from RuntimeExecutorContext.
|
||||
* Kept for tool runtimes that still dispatch through exec_sub_agent style
|
||||
|
||||
@@ -474,5 +474,6 @@
|
||||
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
|
||||
"https://file.rene.wang/task.png": "/blog/assets4aa1732a45832afc780600e6e329860c.webp",
|
||||
"https://file.rene.wang/Platform Agent.png": "/blog/assets10cadd434aeb36bd1beb3c7b3d371fbd.webp",
|
||||
"https://file.rene.wang/clipboard-1780888016983-b47fcdab831b1.png": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp"
|
||||
"https://file.rene.wang/clipboard-1780888016983-b47fcdab831b1.png": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp",
|
||||
"https://file.rene.wang/device.png": "/blog/assets6aebb8b5341dc37202ffecb363b9b906.webp"
|
||||
}
|
||||
@@ -11,18 +11,16 @@ tags:
|
||||
|
||||
# Image & Video Generation Redesign
|
||||
|
||||
This update refines the visual creation flow, so moving between image and video work feels quicker and more natural.
|
||||
## Features
|
||||
|
||||
## What’s New
|
||||
- **Redesigned image and video generation.** Switch between image and video creation directly, instead of moving between separate flows.
|
||||
- **Clear all memory in one action.** When you need a clean context, clear every memory entry at once.
|
||||
|
||||
The generation interface has been redesigned for faster switching between image and video creation. Instead of jumping between separate flows, you can now move between both media modes more directly.
|
||||
## Improvements
|
||||
|
||||
Memory reset is also simpler now. When you need a clean context, you can clear all memory entries in one action.
|
||||
- Better mobile menu behavior.
|
||||
|
||||
Under the hood, the agent architecture was refactored to improve reliability and leave more room for future extensions.
|
||||
## Fixes
|
||||
|
||||
## Experience Improvements
|
||||
|
||||
- Fixed visual glitches in compression view.
|
||||
- Improved mobile menu behavior.
|
||||
- Corrected message count display accuracy.
|
||||
- Fixed visual glitches in the compression view.
|
||||
- Corrected message count accuracy.
|
||||
|
||||
@@ -9,18 +9,16 @@ tags:
|
||||
|
||||
# 图片与视频生成重设计
|
||||
|
||||
这次更新聚焦于优化视觉创作流程,让图片与视频生成之间的切换更自然、操作更高效。
|
||||
## 新功能
|
||||
|
||||
## 更新内容
|
||||
|
||||
这次首先重做了生成流程。图片与视频创作不再像两条割裂的路径,新界面让你在两种模式之间切换更直接。
|
||||
|
||||
记忆管理也变得更干净利落。需要重置上下文时,你现在可以一键清空全部记忆条目。
|
||||
|
||||
此外,Agent 内部架构也做了重构,重点是提升稳定性,并为后续能力扩展留出空间。
|
||||
- **重新设计的图片与视频生成。** 图片与视频创作不再是两条割裂的路径,可以更直接地在两种模式之间切换。
|
||||
- **一键清空全部记忆。** 需要重置上下文时,可一次性清空所有记忆条目。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 修复压缩视图显示异常。
|
||||
- 优化移动端菜单交互。
|
||||
- 修正消息计数显示准确性。
|
||||
- 移动端菜单交互更顺畅。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复压缩视图的显示异常。
|
||||
- 修正消息计数的准确性。
|
||||
|
||||
@@ -12,15 +12,20 @@ tags:
|
||||
|
||||
# Agent Tasks and Agent Management
|
||||
|
||||
This update improves how teams adopt and run Agents day to day, especially when coordinating bots across multiple workflows.
|
||||
## Features
|
||||
|
||||
LobeHub now includes in-app notifications, so important updates and alerts appear directly in the product. Agent management is also more flexible, with stronger rendering support and richer content handling in bot experiences.
|
||||
- **In-app notifications.** Important updates and alerts now appear inside the product.
|
||||
- **Guided onboarding.** A new onboarding path helps teams get started faster.
|
||||
- **Slash command icons.** Skill-specific icons make slash commands easier to find.
|
||||
|
||||
Getting started is smoother as well. A new guided onboarding path helps teams ramp up faster, and slash command discoverability improves with skill-specific icons. GitHub Copilot compatibility is also improved, including better vision-related behavior.
|
||||
## Improvements
|
||||
|
||||
## Experience Improvements
|
||||
- Agent management is more flexible, with richer rendering and content handling in bots.
|
||||
- Better GitHub Copilot compatibility, including vision.
|
||||
- The Marketplace entry moved below Resources in the sidebar for a cleaner layout.
|
||||
- A visual cue now shows when AI generation is interrupted.
|
||||
- A more user-friendly fallback state when errors occur.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Moved the Marketplace entry below Resources in the sidebar for a cleaner layout.
|
||||
- Added a visual cue when AI generation is interrupted.
|
||||
- Fixed display issues when switching between topics.
|
||||
- Improved error handling with a more user-friendly fallback state.
|
||||
|
||||
@@ -10,15 +10,20 @@ tags:
|
||||
|
||||
# 智能体任务系统与 Agent 管理
|
||||
|
||||
这次更新重点优化了 Agent 的上手和日常运营体验,尤其适合在多场景协作中使用 Bot 的团队。
|
||||
## 新功能
|
||||
|
||||
LobeHub 现在支持应用内通知,重要更新和提醒可以直接在产品内收到。Agent 管理能力也进一步增强,在内容渲染和展示上更灵活,能覆盖更丰富的 Bot 使用场景。
|
||||
|
||||
上手流程也更顺了。新版引导可以帮助团队更快进入 Agent 工作流,斜杠菜单加入技能专属图标后,常用命令更容易定位。与此同时,GitHub Copilot 兼容性也进一步提升,包含视觉相关能力的改进。
|
||||
- **应用内通知。** 重要更新和提醒现在会直接在产品内出现。
|
||||
- **引导式上手。** 新版引导帮助团队更快进入工作流。
|
||||
- **斜杠命令图标。** 技能专属图标让斜杠命令更易定位。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 将市场入口移至侧边栏「资源」下方,整体布局更清晰。
|
||||
- 在 AI 生成被中断时增加可视化提示。
|
||||
- 修复话题切换时的显示异常。
|
||||
- 改进错误处理,提供更友好的降级界面。
|
||||
- Agent 管理更灵活,在 Bot 场景中的渲染与内容处理更丰富。
|
||||
- GitHub Copilot 兼容性提升,包含视觉能力。
|
||||
- 市场入口移至侧边栏「资源」下方,布局更清晰。
|
||||
- AI 生成被中断时会显示可视化提示。
|
||||
- 出错时提供更友好的降级界面。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复了话题切换时的显示异常。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: AI Auto-Completion & Real-Time Gateway
|
||||
description: >-
|
||||
Added AI-powered input auto-completion, WebSocket-based real-time messaging
|
||||
gateway, expanded bot platform support, and improved context injection.
|
||||
Added AI-powered input auto-completion, a real-time messaging gateway,
|
||||
expanded bot platform support, and improved context injection.
|
||||
tags:
|
||||
- Auto-Completion
|
||||
- WebSocket Gateway
|
||||
@@ -12,17 +12,21 @@ tags:
|
||||
|
||||
# AI Auto-Completion & Real-Time Gateway
|
||||
|
||||
This release focuses on removing small points of friction in writing and real-time collaboration.
|
||||
## Features
|
||||
|
||||
The editor now supports AI auto-completion while you type, so drafting messages is faster and requires less context switching. On the delivery side, the new WebSocket-based Agent Gateway streams responses with lower latency, making live conversations feel more immediate.
|
||||
- **AI auto-completion.** The editor suggests completions as you type, so drafting messages is faster.
|
||||
- **Real-time Agent Gateway.** The new Agent Gateway streams responses with lower latency.
|
||||
- **More bot platforms.** Feishu/Lark, Slack, and QQ now support a more reliable connection mode.
|
||||
- **Skills and tools via `@`.** Trigger skills and tools with `@` mentions and direct context injection, replacing the old slash-command flow.
|
||||
- **Dedicated Skills tab.** The Skill Store now has its own Skills tab.
|
||||
- **Auto-created topics.** New topics are created every four hours to keep conversations organized.
|
||||
|
||||
Cross-platform bot connectivity is also broader. Feishu/Lark, Slack, and QQ now support WebSocket connection mode for more reliable message delivery. Context invocation is also simpler: skills and tools can be triggered with `@` mentions and direct context injection, replacing the older slash-command-heavy flow.
|
||||
## Improvements
|
||||
|
||||
To keep navigation cleaner over time, the Skill Store now has a dedicated Skills tab, and topics are automatically created every four hours to keep conversations organized.
|
||||
- Agent documents load progressively, showing content as it becomes available instead of blocking the page.
|
||||
- Pasting large clipboard content no longer freezes the chat input.
|
||||
|
||||
## Experience Improvements
|
||||
## Fixes
|
||||
|
||||
- Agent documents now load progressively, showing content as it becomes available instead of blocking the full page
|
||||
- Fixed the image generation button incorrectly defaulting to a wrong model
|
||||
- Improved paste performance by preventing the chat input from freezing on large clipboard content
|
||||
- Strengthened security by sanitizing HTML artifacts and removing an auth bypass vector
|
||||
- The image generation button no longer defaults to the wrong model.
|
||||
- Closed an authentication bypass and sanitized HTML artifacts.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: AI 自动补全与实时消息网关
|
||||
description: 新增 AI 输入自动补全、基于 WebSocket 的实时消息网关、扩展 Bot 平台支持,以及改进的上下文注入机制。
|
||||
description: 新增 AI 输入自动补全、实时消息网关、扩展 Bot 平台支持,以及改进的上下文注入机制。
|
||||
tags:
|
||||
- 自动补全
|
||||
- WebSocket 网关
|
||||
@@ -10,17 +10,21 @@ tags:
|
||||
|
||||
# AI 自动补全与实时消息网关
|
||||
|
||||
这版更新聚焦在减少写作和实时协作中的细碎阻力,让整体体验更连贯。
|
||||
## 新功能
|
||||
|
||||
编辑器现在支持 AI 自动补全,你在输入时就能收到建议,消息撰写更快、上下文切换更少。消息链路方面,全新的 WebSocket Agent 网关支持实时推送响应,整体对话延迟更低。
|
||||
|
||||
Bot 连接能力也扩展到了更多平台。飞书、Slack 和 QQ 已支持 WebSocket 连接模式,消息传递更稳定。与此同时,上下文调用也更直接:通过 `@` 提及即可触发技能和工具,并完成直接上下文注入,逐步替代以斜杠命令为主的旧方式。
|
||||
|
||||
为了让长期使用时的导航更清晰,技能商店新增了专属「技能」标签页,系统也会每 4 小时自动创建新话题,帮助你持续整理会话上下文。
|
||||
- **AI 自动补全。** 编辑器会在你输入时给出补全建议,撰写消息更快。
|
||||
- **实时 Agent 网关。** 全新的 Agent 网关以更低延迟流式返回响应。
|
||||
- **更多 Bot 平台。** 飞书、Slack 和 QQ 现已支持更稳定的连接模式。
|
||||
- **通过 `@` 调用技能与工具。** 用 `@` 提及即可触发技能和工具并直接注入上下文,取代以斜杠命令为主的旧方式。
|
||||
- **独立的「技能」标签页。** 技能商店新增专属的「技能」标签页。
|
||||
- **自动创建话题。** 系统每 4 小时自动创建新话题,帮助整理会话。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 助理文档现在支持渐进式加载,在内容就绪时即时展示,不再阻塞整个页面
|
||||
- 修复了图片生成按钮错误默认选择模型的问题
|
||||
- 优化了粘贴性能,防止在粘贴大量剪贴板内容时聊天输入框卡顿
|
||||
- 加强了安全性,清理了 HTML 工件并修复了一个认证绕过漏洞
|
||||
- 助理文档支持渐进式加载,内容就绪即展示,不再阻塞整个页面。
|
||||
- 粘贴大量剪贴板内容时,聊天输入框不再卡顿。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 图片生成按钮不再默认选到错误的模型。
|
||||
- 修复了一个认证绕过漏洞,并清理了 HTML 工件。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user