mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-16 12:36:07 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fa6f47fc9 |
@@ -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,
|
||||
Redis, migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
|
||||
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 Postgres/Redis flow requires Docker Desktop.
|
||||
# From repo root. Managed DB 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 `REDIS_URL`, then skip `setup-db`:
|
||||
`DATABASE_URL` and skip `setup-db`:
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
For backend-only checks, `dev-next` is available, but Web smoke needs the
|
||||
@@ -170,9 +170,6 @@ 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
|
||||
@@ -180,7 +177,6 @@ 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,15 +48,14 @@ curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
|
||||
```bash
|
||||
# Start backend only.
|
||||
# With root .env: use the existing local config.
|
||||
# Agent runtime queue mode is required to mirror production async execution.
|
||||
AGENT_RUNTIME_MODE=queue pnpm run dev:next
|
||||
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:
|
||||
AGENT_RUNTIME_MODE=queue bun run dev
|
||||
bun run dev
|
||||
|
||||
# Without root .env:
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
@@ -92,8 +91,6 @@ 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/Redis and run migrations
|
||||
# init-dev-env.sh setup-db # start local Postgres 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/Redis containers
|
||||
# init-dev-env.sh clean-db # remove the managed Postgres container
|
||||
#
|
||||
# Overrides:
|
||||
# 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
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -32,9 +32,6 @@ 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}"
|
||||
@@ -57,7 +54,6 @@ 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}"
|
||||
@@ -73,7 +69,6 @@ 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}"
|
||||
@@ -83,7 +78,6 @@ apply_env() {
|
||||
env_keys() {
|
||||
printf '%s\n' \
|
||||
APP_URL \
|
||||
AGENT_RUNTIME_MODE \
|
||||
AUTH_EMAIL_VERIFICATION \
|
||||
AUTH_SECRET \
|
||||
DATABASE_DRIVER \
|
||||
@@ -98,7 +92,6 @@ env_keys() {
|
||||
QSTASH_NEXT_SIGNING_KEY \
|
||||
QSTASH_TOKEN \
|
||||
QSTASH_URL \
|
||||
REDIS_URL \
|
||||
S3_ACCESS_KEY_ID \
|
||||
S3_BUCKET \
|
||||
S3_ENDPOINT \
|
||||
@@ -144,15 +137,6 @@ 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
|
||||
|
||||
@@ -173,25 +157,6 @@ 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"
|
||||
@@ -362,11 +327,9 @@ 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
|
||||
@@ -374,11 +337,6 @@ 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
|
||||
@@ -415,15 +373,6 @@ 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() {
|
||||
@@ -442,7 +391,6 @@ case "$COMMAND" in
|
||||
write) shift; write_env "${1:-}" ;;
|
||||
setup-db)
|
||||
start_db
|
||||
start_redis
|
||||
migrate_db
|
||||
;;
|
||||
migrate) migrate_db ;;
|
||||
|
||||
+64
-171
@@ -10,32 +10,35 @@ 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
|
||||
## Design values (设计价值观)
|
||||
|
||||
LobeHub follows four product design values — **Natural・Meaningful・Certainty・
|
||||
Growth**. Read them before designing:
|
||||
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
|
||||
## 1. Flow & momentum (操作链路)・自然・意义感
|
||||
|
||||
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.
|
||||
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. _(意义感)_
|
||||
- [ ] **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". _(意义感・自然)_
|
||||
- [ ] **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. _(确定性)_
|
||||
- [ ] **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. _(确定性・意义感)_
|
||||
|
||||
## 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
|
||||
## 2. States: empty /loading/error (状态设计)・意义感・确定性
|
||||
|
||||
Every data surface has **four** states — design all of them, not just "has data".
|
||||
|
||||
@@ -43,118 +46,64 @@ Every data surface has **four** states — design all of them, not just "has dat
|
||||
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)_
|
||||
over skeleton rows or a blank body. _(意义感)_
|
||||
- [ ] **Distinguish the empty variants** — "no data yet" (onboarding CTA) vs
|
||||
"no match for filters" (clear-filters affordance) are different screens. _(Certainty)_
|
||||
"no match for filters" (clear-filters affordance) are different screens. _(确定性)_
|
||||
- [ ] **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)_
|
||||
blank or layout shift. _(自然)_
|
||||
- [ ] **Error state** designed — surface the reason and a retry/back path. _(意义感)_
|
||||
|
||||
### 1.2 Lists at scale・Certainty・Natural
|
||||
## 3. Buttons & focus (按钮与焦点)・确定性
|
||||
|
||||
- [ ] **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. _(确定性)_
|
||||
|
||||
## 4. Lists at scale (列表与规模)・确定性・自然
|
||||
|
||||
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)_
|
||||
add batch-select / bulk actions once counts get large. _(确定性)_
|
||||
- [ ] **Co-design empty / loading / error with the data state** (see §2). A list
|
||||
isn't done until all four render well. _(自然)_
|
||||
|
||||
### 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
|
||||
## 5. Option visibility (选项可见性)・确定性・意义感
|
||||
|
||||
- [ ] **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)_
|
||||
"genuinely none", never "we filtered out the only option". _(意义感)_
|
||||
|
||||
---
|
||||
## 6. Loading visuals (Loading 视觉)・自然
|
||||
|
||||
## 2. Edit — entering & changing content
|
||||
**Never use antd `Spin`** — it doesn't match the product's loading visual. Use a
|
||||
project loader:
|
||||
|
||||
Any surface where the user **types or edits**. Input is expensive effort; the
|
||||
overriding rule is **never lose it**.
|
||||
| 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`) |
|
||||
|
||||
### 2.1 Protect in-progress edits・Certainty・Meaningful
|
||||
When in doubt, reach for `NeuralNetworkLoading` — it's the default in-flight
|
||||
indicator (e.g. modal "in progress" states).
|
||||
|
||||
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.
|
||||
## 7. Discoverability & growth (可发现性与生长)・生长性
|
||||
|
||||
- [ ] **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)_
|
||||
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. _(生长性・自然)_
|
||||
- [ ] **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. _(生长性・意义感)_
|
||||
|
||||
## 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
|
||||
## 8. Entity lifecycle completeness (实体生命周期完整性)・意义感・确定性
|
||||
|
||||
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
|
||||
@@ -173,38 +122,16 @@ it explicitly _before_ building. Worked example, the tools/connectors list:
|
||||
| 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)_
|
||||
lifecycle ops and build the ones that apply. _(意义感)_
|
||||
- [ ] **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)_
|
||||
must be editable **and** deletable. _(确定性)_
|
||||
- [ ] **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)_
|
||||
page), and there's a clear entry point to add/create where applicable. _(自然)_
|
||||
- [ ] **An intentionally-absent op is a documented decision, not an oversight**
|
||||
(e.g. official tools can't be deleted — by design). _(Certainty)_
|
||||
(e.g. official tools can't be deleted — by design). _(确定性)_
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
## 9. 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
|
||||
@@ -228,53 +155,19 @@ depends on a capability the current config may lack, the product owes a
|
||||
- [ ] **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.
|
||||
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA.
|
||||
- [ ] 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**
|
||||
|
||||
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
|
||||
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
|
||||
- [ ] 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.
|
||||
- [ ] Listed entities have their full lifecycle (not display-only); ops match source (built-in / installed / custom).
|
||||
- [ ] Capability-gated feature warns (soft, reactive, load-gated) when the model can't deliver it; copy gives the remedy.
|
||||
|
||||
## Related skills
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
GitWorkingTreeFiles,
|
||||
GitWorkingTreePatches,
|
||||
GitWorkingTreeStatus,
|
||||
GitWorktreeListItem,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
import {
|
||||
checkoutGitBranch as runCheckoutGitBranch,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
gitInfo as computeGitInfo,
|
||||
listGitBranches as computeListGitBranches,
|
||||
listGitRemoteBranches as computeListGitRemoteBranches,
|
||||
listGitWorktrees as computeListGitWorktrees,
|
||||
pullGitBranch as runPullGitBranch,
|
||||
pushGitBranch as runPushGitBranch,
|
||||
renameGitBranch as runRenameGitBranch,
|
||||
@@ -85,11 +83,6 @@ 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,14 +438,12 @@ 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,
|
||||
});
|
||||
@@ -464,14 +462,12 @@ 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,7 +226,6 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -263,7 +262,6 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -272,29 +270,6 @@ 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', () => {
|
||||
@@ -312,7 +287,6 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -355,7 +329,6 @@ describe('LocalFileCtr', () => {
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -368,35 +341,6 @@ 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,7 +21,6 @@ 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);
|
||||
@@ -60,7 +59,10 @@ 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);
|
||||
@@ -82,8 +84,6 @@ const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileA
|
||||
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,12 +229,10 @@ export class LocalFileProtocolManager {
|
||||
|
||||
async createPreviewUrl({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
accept?: PreviewFileAccept;
|
||||
allowExternalFile?: boolean;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<string | null> {
|
||||
@@ -245,12 +243,11 @@ export class LocalFileProtocolManager {
|
||||
? (
|
||||
await this.readPreviewFile({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
})
|
||||
)?.realPath
|
||||
: await this.resolveApprovedPreviewPath({ allowExternalFile, filePath, workspaceRoot });
|
||||
: await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
if (!realFilePath) return null;
|
||||
|
||||
this.cleanupExpiredTokens();
|
||||
@@ -266,21 +263,14 @@ 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({
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
persistExternalApproval: false,
|
||||
workspaceRoot,
|
||||
});
|
||||
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
if (!realFilePath) return null;
|
||||
|
||||
const fileStat = await stat(realFilePath);
|
||||
@@ -290,10 +280,6 @@ export class LocalFileProtocolManager {
|
||||
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
|
||||
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
|
||||
|
||||
if (allowExternalFile) {
|
||||
this.grantExternalPreviewApproval(realFilePath);
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentType,
|
||||
@@ -341,14 +327,10 @@ 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);
|
||||
@@ -363,44 +345,15 @@ export class LocalFileProtocolManager {
|
||||
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
|
||||
|
||||
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
|
||||
const workspaceRootApproved =
|
||||
this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) ||
|
||||
this.indexedProjectRoots.has(normalizedRealWorkspaceRoot);
|
||||
if (
|
||||
workspaceRootApproved &&
|
||||
isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)
|
||||
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
|
||||
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
|
||||
) {
|
||||
return normalizedRealFilePath;
|
||||
return null;
|
||||
}
|
||||
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
|
||||
|
||||
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);
|
||||
return normalizedRealFilePath;
|
||||
}
|
||||
|
||||
private cleanupExpiredTokens() {
|
||||
@@ -412,15 +365,6 @@ 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;
|
||||
@@ -439,16 +383,4 @@ 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,31 +263,6 @@ 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');
|
||||
@@ -351,26 +326,6 @@ 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');
|
||||
|
||||
@@ -38,12 +38,7 @@ import {
|
||||
ToolResolver,
|
||||
} from '@lobechat/context-engine';
|
||||
import { parse } from '@lobechat/conversation-flow';
|
||||
import {
|
||||
applyModelExtendParams,
|
||||
type ChatStreamPayload,
|
||||
consumeStreamUntilDone,
|
||||
type ModelExtendParams,
|
||||
} from '@lobechat/model-runtime';
|
||||
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
|
||||
import {
|
||||
context as otelContext,
|
||||
SpanKind,
|
||||
@@ -72,7 +67,6 @@ import {
|
||||
} from '@lobechat/types';
|
||||
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
|
||||
import debug from 'debug';
|
||||
import type { ExtendParamsType } from 'model-bank';
|
||||
|
||||
import { composioEnv } from '@/config/composio';
|
||||
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
|
||||
@@ -86,10 +80,6 @@ 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,
|
||||
@@ -99,7 +89,6 @@ 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,
|
||||
@@ -416,147 +405,6 @@ 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;
|
||||
|
||||
@@ -674,12 +522,6 @@ 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
|
||||
@@ -879,7 +721,6 @@ 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)
|
||||
@@ -895,36 +736,19 @@ 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),
|
||||
);
|
||||
|
||||
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 modelExtendParams =
|
||||
modelCard &&
|
||||
'settings' in modelCard &&
|
||||
modelCard.settings &&
|
||||
typeof modelCard.settings === 'object' &&
|
||||
'extendParams' in modelCard.settings
|
||||
? (modelCard.settings as { extendParams?: string[] }).extendParams
|
||||
: undefined;
|
||||
|
||||
const modelSupportsPreserveThinkingFromCard =
|
||||
Array.isArray(modelExtendParams) && modelExtendParams.includes('preserveThinking');
|
||||
@@ -939,19 +763,6 @@ 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[]);
|
||||
@@ -1393,9 +1204,6 @@ 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,
|
||||
}),
|
||||
@@ -2654,14 +2462,7 @@ 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,
|
||||
@@ -3243,12 +3044,6 @@ 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,9 +58,6 @@ 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
|
||||
@@ -128,7 +125,6 @@ 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' }),
|
||||
@@ -4854,9 +4850,10 @@ describe('RuntimeExecutors', () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('call_tool preserves stop:true for legacy execSubAgent state', async () => {
|
||||
it('call_tool sets stop:true in tool_result payload when tool returns execSubAgent state', async () => {
|
||||
// Simulate agentManagement.callAgent returning execSubAgent state
|
||||
mockToolExecutionService.executeTool.mockResolvedValue({
|
||||
content: 'Legacy async task result',
|
||||
content: '🚀 Triggered async task to call agent "target-agent"',
|
||||
executionTime: 10,
|
||||
state: {
|
||||
parentMessageId: 'tool-msg-id',
|
||||
@@ -4897,112 +4894,13 @@ describe('RuntimeExecutors', () => {
|
||||
expect((result.nextContext?.payload as any).stop).toBe(true);
|
||||
});
|
||||
|
||||
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,
|
||||
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
|
||||
const mockExecSubAgentTask = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
|
||||
const ctxWithCallback = {
|
||||
...ctx,
|
||||
execSubAgent: mockExecSubAgent,
|
||||
execSubAgent: mockExecSubAgentTask,
|
||||
topicId: 'topic-123',
|
||||
};
|
||||
|
||||
@@ -5028,9 +4926,6 @@ 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',
|
||||
@@ -5038,7 +4933,7 @@ describe('RuntimeExecutors', () => {
|
||||
);
|
||||
|
||||
// execSubAgent callback fired with targetAgentId
|
||||
expect(mockExecSubAgent).toHaveBeenCalledWith(
|
||||
expect(mockExecSubAgentTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'target-agent-id',
|
||||
instruction: 'Do something useful',
|
||||
@@ -5052,10 +4947,10 @@ describe('RuntimeExecutors', () => {
|
||||
});
|
||||
|
||||
it('exec_sub_agent blocks nested dispatch when current state is already a sub-agent', async () => {
|
||||
const mockExecSubAgent = vi.fn();
|
||||
const mockExecSubAgentTask = vi.fn();
|
||||
const ctxWithCallback = {
|
||||
...ctx,
|
||||
execSubAgent: mockExecSubAgent,
|
||||
execSubAgentTask: mockExecSubAgentTask,
|
||||
topicId: 'topic-123',
|
||||
};
|
||||
|
||||
@@ -5088,7 +4983,7 @@ describe('RuntimeExecutors', () => {
|
||||
success: false,
|
||||
});
|
||||
expect(mockMessageModel.create).not.toHaveBeenCalled();
|
||||
expect(mockExecSubAgent).not.toHaveBeenCalled();
|
||||
expect(mockExecSubAgentTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exec_sub_agent gracefully skips dispatch when execSubAgent not injected', async () => {
|
||||
|
||||
@@ -659,59 +659,6 @@ 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
|
||||
|
||||
@@ -231,20 +231,12 @@ 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, 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.
|
||||
// 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.
|
||||
[RemoteDeviceManifest.identifier]:
|
||||
deviceCapable &&
|
||||
hasDeviceProxy &&
|
||||
!deviceContext?.autoActivated &&
|
||||
!deviceContext?.boundDeviceId,
|
||||
deviceCapable && hasDeviceProxy && !deviceContext?.autoActivated,
|
||||
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
|
||||
[WebBrowsingManifest.identifier]: isSearchEnabled,
|
||||
};
|
||||
|
||||
-284
@@ -1,284 +0,0 @@
|
||||
// @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: ReturnType<typeof vi.spyOn>;
|
||||
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;
|
||||
|
||||
mockResponsesCreate = vi.spyOn(OpenAI.Responses.prototype, 'create');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -139,8 +139,6 @@ 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({
|
||||
|
||||
@@ -115,33 +115,6 @@ 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
|
||||
|
||||
@@ -119,22 +119,6 @@ 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
|
||||
|
||||
@@ -98,8 +98,6 @@ 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(),
|
||||
@@ -284,19 +282,21 @@ describe('AgentDocumentsService', () => {
|
||||
|
||||
describe('listDocuments', () => {
|
||||
it('should return a list of documents with documentId, filename, id, and title', async () => {
|
||||
mockModel.listByAgent.mockResolvedValue([
|
||||
mockModel.findByAgent.mockResolvedValue([
|
||||
{
|
||||
content: 'c1',
|
||||
documentId: 'documents-1',
|
||||
filename: 'a.md',
|
||||
id: 'doc-1',
|
||||
loadPosition: undefined,
|
||||
policy: null,
|
||||
title: 'A',
|
||||
},
|
||||
{
|
||||
content: 'c2',
|
||||
documentId: 'documents-2',
|
||||
filename: 'b.md',
|
||||
id: 'doc-2',
|
||||
loadPosition: undefined,
|
||||
policy: null,
|
||||
title: 'B',
|
||||
},
|
||||
]);
|
||||
@@ -304,8 +304,7 @@ describe('AgentDocumentsService', () => {
|
||||
const service = new AgentDocumentsService(db, userId);
|
||||
const result = await service.listDocuments('agent-1');
|
||||
|
||||
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1');
|
||||
expect(mockModel.findByAgent).not.toHaveBeenCalled();
|
||||
expect(mockModel.findByAgent).toHaveBeenCalledWith('agent-1');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
documentId: 'documents-1',
|
||||
@@ -323,16 +322,6 @@ 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', () => {
|
||||
@@ -341,19 +330,19 @@ describe('AgentDocumentsService', () => {
|
||||
{ id: 'documents-2', title: 'B' },
|
||||
{ id: 'documents-1', title: 'A' },
|
||||
]);
|
||||
mockModel.listByDocumentIds.mockResolvedValue([
|
||||
mockModel.findByDocumentIds.mockResolvedValue([
|
||||
{
|
||||
documentId: 'documents-1',
|
||||
filename: 'a.md',
|
||||
id: 'agent-doc-1',
|
||||
loadPosition: undefined,
|
||||
policy: null,
|
||||
title: 'A',
|
||||
},
|
||||
{
|
||||
documentId: 'documents-2',
|
||||
filename: 'b.md',
|
||||
id: 'agent-doc-2',
|
||||
loadPosition: undefined,
|
||||
policy: null,
|
||||
title: 'B',
|
||||
},
|
||||
]);
|
||||
@@ -362,11 +351,10 @@ describe('AgentDocumentsService', () => {
|
||||
const result = await service.listDocumentsForTopic('agent-1', 'topic-1');
|
||||
|
||||
expect(mockTopicDocumentModel.findByTopicId).toHaveBeenCalledWith('topic-1');
|
||||
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', [
|
||||
expect(mockModel.findByDocumentIds).toHaveBeenCalledWith('agent-1', [
|
||||
'documents-2',
|
||||
'documents-1',
|
||||
]);
|
||||
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
documentId: 'documents-2',
|
||||
@@ -384,19 +372,6 @@ 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,8 +13,6 @@ import type {
|
||||
AgentDocument,
|
||||
AgentDocumentContextPayload,
|
||||
AgentDocumentContextRow,
|
||||
AgentDocumentListItem,
|
||||
AgentDocumentListSourceType,
|
||||
AgentDocumentWithRules,
|
||||
ToolUpdateLoadRule,
|
||||
} from '@/database/models/agentDocuments';
|
||||
@@ -613,27 +611,54 @@ export class AgentDocumentsService {
|
||||
}
|
||||
}
|
||||
|
||||
async listDocuments(agentId: string, sourceType?: AgentDocumentListSourceType) {
|
||||
if (!sourceType) return this.agentDocumentModel.listByAgent(agentId);
|
||||
|
||||
return this.agentDocumentModel.listByAgent(agentId, { sourceType });
|
||||
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 listDocumentsForTopic(
|
||||
agentId: string,
|
||||
topicId: string,
|
||||
sourceType?: AgentDocumentListSourceType,
|
||||
sourceType?: 'all' | 'file' | 'web',
|
||||
) {
|
||||
const topicDocs = await this.topicDocumentModel.findByTopicId(topicId);
|
||||
const documentIds = topicDocs.map((doc) => doc.id);
|
||||
const docs = sourceType
|
||||
? await this.agentDocumentModel.listByDocumentIds(agentId, documentIds, { sourceType })
|
||||
: await this.agentDocumentModel.listByDocumentIds(agentId, documentIds);
|
||||
const docs = await this.agentDocumentModel.findByDocumentIds(agentId, documentIds);
|
||||
const docsByDocumentId = new Map(docs.map((doc) => [doc.documentId, doc]));
|
||||
|
||||
return topicDocs
|
||||
.map((topicDoc) => docsByDocumentId.get(topicDoc.id))
|
||||
.filter((doc): doc is AgentDocumentListItem => Boolean(doc));
|
||||
.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,
|
||||
}));
|
||||
}
|
||||
|
||||
async getDocumentByFilename(agentId: string, filename: string) {
|
||||
|
||||
@@ -17,9 +17,6 @@ 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
|
||||
@@ -1856,186 +1853,6 @@ 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', () => {
|
||||
@@ -2140,93 +1957,4 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,11 +67,6 @@ 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,
|
||||
@@ -104,7 +99,7 @@ const ASYNC_TOOL_VERIFY_DELAY_MS = 15_000;
|
||||
* 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.
|
||||
* 5 attempts span ~15s → ~7.75min total before giving up. See LOBE-10385.
|
||||
*/
|
||||
const ASYNC_TOOL_VERIFY_MAX_ATTEMPTS = 5;
|
||||
|
||||
@@ -161,13 +156,6 @@ 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
|
||||
@@ -625,27 +613,18 @@ 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.
|
||||
// a single shot — the core LOBE-10385 fix.
|
||||
if (verifyAsyncToolBarrier) {
|
||||
const attempt = asyncToolVerifyAttempt ?? 1;
|
||||
log(
|
||||
@@ -908,29 +887,6 @@ 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) {
|
||||
@@ -948,11 +904,9 @@ export class AgentRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Execute step (skipped when force-finishing a parked supervisor op).
|
||||
// Execute step
|
||||
const startAt = Date.now();
|
||||
const stepResult = forcedFinishState
|
||||
? { events: [], newState: forcedFinishState, nextContext: undefined }
|
||||
: await runtime.step(currentState, currentContext);
|
||||
const stepResult = 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
|
||||
@@ -1719,11 +1673,6 @@ export class AgentRuntimeService {
|
||||
* 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;
|
||||
@@ -1775,15 +1724,6 @@ export class AgentRuntimeService {
|
||||
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,
|
||||
@@ -1796,12 +1736,7 @@ export class AgentRuntimeService {
|
||||
|
||||
asyncToolResumeCounter.add(1, { outcome: 'resumed' });
|
||||
|
||||
log(
|
||||
'[%s] won async-tool resume CAS, scheduling step %d (onComplete: %s)',
|
||||
parentOperationId,
|
||||
state.stepCount,
|
||||
onComplete,
|
||||
);
|
||||
log('[%s] won async-tool resume CAS, scheduling step %d', parentOperationId, state.stepCount);
|
||||
|
||||
if (this.queueService) {
|
||||
await this.queueService.scheduleMessage({
|
||||
@@ -1809,8 +1744,7 @@ export class AgentRuntimeService {
|
||||
delay: 100,
|
||||
endpoint: `${this.baseURL}/run`,
|
||||
operationId: parentOperationId,
|
||||
payload:
|
||||
onComplete === 'finish' ? { finishAfterAsyncTool: true } : { resumeAsyncTool: true },
|
||||
payload: { resumeAsyncTool: true },
|
||||
priority: 'high',
|
||||
stepIndex: state.stepCount,
|
||||
});
|
||||
@@ -1832,7 +1766,7 @@ export class AgentRuntimeService {
|
||||
* 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.
|
||||
* orphan is observable. See LOBE-10385.
|
||||
*/
|
||||
private async maybeScheduleAsyncToolVerify(
|
||||
parentOperationId: string,
|
||||
@@ -2001,252 +1935,6 @@ 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
|
||||
@@ -2380,7 +2068,6 @@ 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,
|
||||
|
||||
@@ -129,22 +129,6 @@ export interface AgentExecutionParams {
|
||||
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;
|
||||
/**
|
||||
@@ -205,106 +189,6 @@ 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;
|
||||
|
||||
-33
@@ -54,17 +54,6 @@ 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 ?? [];
|
||||
@@ -83,19 +72,6 @@ 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);
|
||||
};
|
||||
@@ -281,15 +257,6 @@ 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,4 +1,3 @@
|
||||
import { LayersEnum } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
|
||||
@@ -70,42 +69,6 @@ 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,
|
||||
|
||||
+2
-19
@@ -1,6 +1,5 @@
|
||||
// @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';
|
||||
@@ -150,18 +149,7 @@ describe('S2 completion loop (policy → handler → projection → persist)', (
|
||||
mutations: [
|
||||
{
|
||||
apiName: 'writeMemory',
|
||||
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',
|
||||
},
|
||||
},
|
||||
data: { resourceId: 'mem_1', status: 'applied', summary: 'Saved tone preference' },
|
||||
kind: 'mutation',
|
||||
},
|
||||
],
|
||||
@@ -181,12 +169,7 @@ 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: 'pref_1',
|
||||
memoryId: 'mem_1',
|
||||
memoryLayer: LayersEnum.Preference,
|
||||
type: 'memory',
|
||||
});
|
||||
expect(memory.target).toMatchObject({ id: 'mem_1', type: 'memory' });
|
||||
});
|
||||
|
||||
it('no-ops when the completion carries no self-iteration payload (no marker stamped)', async () => {
|
||||
|
||||
+1
-72
@@ -1,5 +1,4 @@
|
||||
import { MemoryApiName, MemoryIdentifier } from '@lobechat/builtin-tool-memory';
|
||||
import { LayersEnum } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractSelfIterationCompletionPayload } from '../extractCompletionPayload';
|
||||
@@ -58,7 +57,7 @@ describe('extractSelfIterationCompletionPayload', () => {
|
||||
expect(result?.artifacts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('synthesizes a writeMemory mutation with a preference target for a memory-kind run', () => {
|
||||
it('synthesizes a writeMemory mutation for a memory-kind run from finalState usage', () => {
|
||||
const result = extractSelfIterationCompletionPayload(
|
||||
buildState(
|
||||
{
|
||||
@@ -67,34 +66,6 @@ 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: {
|
||||
@@ -116,48 +87,6 @@ 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', () => {
|
||||
|
||||
+3
-16
@@ -1,5 +1,3 @@
|
||||
import { LayersEnum } from '@lobechat/types';
|
||||
|
||||
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
|
||||
|
||||
import type { AgentSignalReceipt } from '../../receiptService';
|
||||
@@ -55,9 +53,6 @@ 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). */
|
||||
@@ -156,15 +151,9 @@ 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 targetTitle = str(target?.title);
|
||||
const title =
|
||||
targetTitle ?? summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
|
||||
const resourceId = str(data.resourceId);
|
||||
const title = summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -183,9 +172,7 @@ export const buildSelfIterationReceipts = (
|
||||
? {}
|
||||
: {
|
||||
target: {
|
||||
...(targetId ? { id: targetId } : {}),
|
||||
...(memoryId ? { memoryId } : {}),
|
||||
...(memoryLayer ? { memoryLayer } : {}),
|
||||
...(resourceId ? { id: resourceId } : {}),
|
||||
...(summaryText ? { summary: summaryText } : {}),
|
||||
title,
|
||||
type: kind,
|
||||
|
||||
+1
-2
@@ -49,10 +49,9 @@ 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 ?? result.target?.summary,
|
||||
summary: result.detail,
|
||||
},
|
||||
kind: 'mutation',
|
||||
},
|
||||
|
||||
@@ -87,14 +87,7 @@ import { AgentRuntimeService } from '@/server/services/agentRuntime';
|
||||
import { getAbortError, isAbortError, throwIfAborted } from '@/server/services/agentRuntime/abort';
|
||||
import { hookDispatcher } from '@/server/services/agentRuntime/hooks';
|
||||
import type { AgentHook } from '@/server/services/agentRuntime/hooks/types';
|
||||
import type {
|
||||
ExecGroupMemberParams,
|
||||
ExecGroupMemberResult,
|
||||
GroupActionMemberBridgeParams,
|
||||
GroupActionMemberMode,
|
||||
GroupActionOnComplete,
|
||||
StepLifecycleCallbacks,
|
||||
} from '@/server/services/agentRuntime/types';
|
||||
import type { StepLifecycleCallbacks } from '@/server/services/agentRuntime/types';
|
||||
import { enqueueAgentSignalSourceEvent } from '@/server/services/agentSignal';
|
||||
import {
|
||||
isAgentSignalEnabledForUser,
|
||||
@@ -194,14 +187,6 @@ interface InternalExecAgentParams extends ExecAgentParams {
|
||||
disableTools?: boolean;
|
||||
/** Discord context for injecting channel/guild info into agent system message */
|
||||
discordContext?: any;
|
||||
/**
|
||||
* Inject a user-role message into the LLM context for this turn WITHOUT
|
||||
* persisting it (no DB row, no Agent Signal). Used for ephemeral orchestration
|
||||
* instructions — e.g. a group supervisor's `<speaker>` instruction to a member —
|
||||
* so it drives the member's response without polluting the group conversation.
|
||||
* Requires `suppressUserMessage` (the turn runs off existing history).
|
||||
*/
|
||||
ephemeralUserMessage?: string;
|
||||
/** Eval context for injecting environment prompts into system message */
|
||||
evalContext?: EvalContext;
|
||||
/** External files to upload to S3 and attach to the user message */
|
||||
@@ -335,7 +320,6 @@ export class AiAgentService {
|
||||
delegate: {
|
||||
execSubAgent: this.execSubAgent,
|
||||
execVirtualSubAgent: this.execVirtualSubAgent,
|
||||
execGroupMember: this.execGroupMember,
|
||||
},
|
||||
workspaceId: wsId,
|
||||
});
|
||||
@@ -617,16 +601,6 @@ export class AiAgentService {
|
||||
return { fileIds, fileList, imageList, videoList, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Group-action member completion bridge entry point — driven by the QStash
|
||||
* `group-member-callback` webhook (queue mode). Forwards to the workspace-scoped
|
||||
* runtime so the member-anchor backfill + K=N barrier + resume/finish read the
|
||||
* same workspace rows. See `AgentRuntimeService.completeGroupActionMember`.
|
||||
*/
|
||||
completeGroupActionMember(params: GroupActionMemberBridgeParams): Promise<boolean> {
|
||||
return this.agentRuntimeService.completeGroupActionMember(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute agent with just a prompt
|
||||
*
|
||||
@@ -678,7 +652,6 @@ export class AiAgentService {
|
||||
resume,
|
||||
resumeApproval,
|
||||
suppressUserMessage,
|
||||
ephemeralUserMessage,
|
||||
} = params;
|
||||
|
||||
// Validate that either agentId or slug is provided
|
||||
@@ -2418,7 +2391,7 @@ export class AiAgentService {
|
||||
// - videoList: video-capable models render these as video parts
|
||||
// - fileList: MessageContentProcessor injects content via filesPrompts() XML
|
||||
const userMessage = {
|
||||
content: ephemeralUserMessage ?? prompt,
|
||||
content: prompt,
|
||||
fileList: runAttachments.fileList,
|
||||
id: userMessageRecord?.id,
|
||||
imageList: runAttachments.imageList,
|
||||
@@ -2426,11 +2399,8 @@ export class AiAgentService {
|
||||
videoList: runAttachments.videoList,
|
||||
};
|
||||
|
||||
// Combine history messages with the user message. An ephemeral message is
|
||||
// injected into the LLM context even under runFromHistory (suppressUserMessage)
|
||||
// — it drives this turn but was never persisted (id is undefined).
|
||||
const allMessages =
|
||||
runFromHistory && !ephemeralUserMessage ? historyMessages : [...historyMessages, userMessage];
|
||||
// Combine history messages with user message
|
||||
const allMessages = runFromHistory ? historyMessages : [...historyMessages, userMessage];
|
||||
|
||||
log('execAgent: prepared evalContext for executor');
|
||||
|
||||
@@ -2446,10 +2416,7 @@ export class AiAgentService {
|
||||
// Pass assistant message ID so agent runtime knows which message to update
|
||||
assistantMessageId: assistantMessageRecord.id,
|
||||
isFirstMessage: true,
|
||||
message:
|
||||
runFromHistory && !ephemeralUserMessage
|
||||
? [{ content: '' }]
|
||||
: [{ content: ephemeralUserMessage ?? prompt }],
|
||||
message: runFromHistory ? [{ content: '' }] : [{ content: prompt }],
|
||||
// Pass user message ID as parentMessageId for reference
|
||||
parentMessageId: parentMessageId ?? userMessageRecord?.id ?? '',
|
||||
// Include tools for initial LLM call
|
||||
@@ -2713,13 +2680,6 @@ export class AiAgentService {
|
||||
// context (state.metadata.agentId) targets the reviewed agent; ordinary
|
||||
// runs (no marker) fall back to the resolved executing agent.
|
||||
agentId: appContext?.agentSignal?.agentId ?? resolvedAgentId,
|
||||
// When scope === 'agent_builder', agentId stays as the builder builtin so
|
||||
// message ownership and queryUiMessages remain correct. editingAgentId
|
||||
// carries the actual editing target separately; only the AgentBuilder server
|
||||
// runtime reads it, keeping the rest of the pipeline unaffected.
|
||||
...(appContext?.scope === 'agent_builder' && appContext?.editingAgentId
|
||||
? { editingAgentId: appContext.editingAgentId }
|
||||
: {}),
|
||||
// Run-scoped Agent Signal marker for background self-iteration / memory
|
||||
// runs — lands in state.metadata.agentSignal so the completion path can
|
||||
// project receipts/briefs. Undefined for ordinary chat runs.
|
||||
@@ -2943,195 +2903,9 @@ export class AiAgentService {
|
||||
resumeParentOnComplete: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Fork a single group member ("call agent member") under a `lobe-group-management`
|
||||
* tool call. Dispatches to the in-group (non-isolated, shared group session)
|
||||
* or isolated (own thread) path, installing the group-action member completion
|
||||
* bridge. Invoked once per member by the runtime's `agentMember` runner.
|
||||
*
|
||||
* Arrow field (not a method) so it stays bound when handed to the runtime
|
||||
* delegate.
|
||||
*/
|
||||
execGroupMember = async (params: ExecGroupMemberParams): Promise<ExecGroupMemberResult> => {
|
||||
if (params.mode === 'isolated') {
|
||||
// Isolated members reuse the sub-agent isolation-thread machinery, swapping
|
||||
// in the group-action member bridge (K=N barrier + resume/finish).
|
||||
const result = await this.execAgentThreadRun(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
groupId: params.groupId,
|
||||
instruction: params.instruction ?? 'Please complete the assigned task.',
|
||||
parentMessageId: params.anchorMessageId,
|
||||
parentOperationId: params.parentOperationId,
|
||||
timeout: params.timeout,
|
||||
title: params.instruction?.slice(0, 50),
|
||||
topicId: params.topicId,
|
||||
},
|
||||
{
|
||||
bridgeHookFactory: (threadId) =>
|
||||
this.createGroupActionMemberBridgeHook({
|
||||
anchorMessageId: params.anchorMessageId,
|
||||
expectedMembers: params.expectedMembers,
|
||||
groupToolMessageId: params.groupToolMessageId,
|
||||
mode: 'isolated',
|
||||
onComplete: params.onComplete,
|
||||
parentOperationId: params.parentOperationId,
|
||||
threadId,
|
||||
}),
|
||||
isSubAgent: true,
|
||||
logScope: 'execVirtualSubAgent',
|
||||
resumeParentOnComplete: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Enforce the requested timeout: if the member op is still running when the
|
||||
// deadline passes, the watchdog interrupts it and bridges a `timeout`
|
||||
// completion so the supervisor doesn't stay parked indefinitely.
|
||||
if (result.success && result.operationId && params.timeout && params.timeout > 0) {
|
||||
await this.agentRuntimeService.scheduleGroupMemberTimeout(
|
||||
{
|
||||
anchorMessageId: params.anchorMessageId,
|
||||
expectedMembers: params.expectedMembers,
|
||||
groupToolMessageId: params.groupToolMessageId,
|
||||
memberOperationId: result.operationId,
|
||||
mode: 'isolated',
|
||||
onComplete: params.onComplete,
|
||||
parentOperationId: params.parentOperationId,
|
||||
},
|
||||
params.timeout,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
error: result.error,
|
||||
operationId: result.operationId,
|
||||
started: result.success ?? false,
|
||||
threadId: result.threadId,
|
||||
};
|
||||
}
|
||||
|
||||
return this.execAgentMember(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a group member in the shared group session (non-isolated). The member's
|
||||
* turns land directly in the group conversation; the supervisor's instruction
|
||||
* is injected as a `<speaker name="Supervisor" />`-tagged prompt. Registers the
|
||||
* group-action member bridge that backfills the member anchor and
|
||||
* resumes/finishes the parked supervisor once the K=N member barrier passes.
|
||||
*/
|
||||
private async execAgentMember(params: ExecGroupMemberParams): Promise<ExecGroupMemberResult> {
|
||||
const {
|
||||
agentId,
|
||||
anchorMessageId,
|
||||
disableTools,
|
||||
expectedMembers,
|
||||
groupId,
|
||||
groupToolMessageId,
|
||||
instruction,
|
||||
onComplete,
|
||||
parentOperationId,
|
||||
topicId,
|
||||
} = params;
|
||||
|
||||
log(
|
||||
'execAgentMember: agentId=%s, groupId=%s, topicId=%s, instruction=%s',
|
||||
agentId,
|
||||
groupId,
|
||||
topicId,
|
||||
(instruction ?? '').slice(0, 50),
|
||||
);
|
||||
|
||||
// Dispatch beforeCallAgent hook on the supervisor operation.
|
||||
hookDispatcher
|
||||
.dispatch(parentOperationId, 'beforeCallAgent', {
|
||||
agentId,
|
||||
instruction: (instruction ?? '').slice(0, 200),
|
||||
operationId: parentOperationId,
|
||||
userId: this.userId,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Inherit the supervisor op's trigger so member rows stay attributable.
|
||||
let inheritedTrigger: string | undefined;
|
||||
try {
|
||||
const parentOp = await new AgentOperationModel(
|
||||
this.db,
|
||||
this.userId,
|
||||
this.workspaceId,
|
||||
).findById(parentOperationId);
|
||||
inheritedTrigger = parentOp?.trigger ?? undefined;
|
||||
} catch (error) {
|
||||
log('execAgentMember: failed to read parent operation trigger: %O', error);
|
||||
}
|
||||
|
||||
const speakerInstruction = instruction
|
||||
? `<speaker name="Supervisor" />\n${instruction}`
|
||||
: 'Please respond to the group conversation based on the current context.';
|
||||
|
||||
const appContext: NonNullable<InternalExecAgentParams['appContext']> = {
|
||||
groupId,
|
||||
scope: 'group',
|
||||
topicId,
|
||||
};
|
||||
|
||||
// The member runs as a child op of the supervisor and lands its turns in the
|
||||
// shared group conversation (no isolation thread). The bridge backfills the
|
||||
// member anchor (a short receipt) and resumes/finishes the supervisor.
|
||||
//
|
||||
// The supervisor instruction is injected as an EPHEMERAL user message
|
||||
// (`suppressUserMessage` + `ephemeralUserMessage`): it drives the member's
|
||||
// response but is NOT persisted as a `role: 'user'` row, mirroring the
|
||||
// client orchestration where the supervisor instruction is virtual. Without
|
||||
// this, every server-side speak/broadcast/delegate would leak the
|
||||
// orchestration prompt into the group conversation as a real message.
|
||||
const result = await this.execAgent({
|
||||
agentId,
|
||||
appContext,
|
||||
autoStart: true,
|
||||
disableTools,
|
||||
ephemeralUserMessage: speakerInstruction,
|
||||
hooks: [
|
||||
this.createGroupActionMemberBridgeHook({
|
||||
anchorMessageId,
|
||||
expectedMembers,
|
||||
groupToolMessageId,
|
||||
mode: 'in_group',
|
||||
onComplete,
|
||||
parentOperationId,
|
||||
}),
|
||||
],
|
||||
parentMessageId: anchorMessageId,
|
||||
parentOperationId,
|
||||
prompt: speakerInstruction,
|
||||
suppressUserMessage: true,
|
||||
trigger: inheritedTrigger,
|
||||
userInterventionConfig: { approvalMode: 'headless' },
|
||||
});
|
||||
|
||||
log(
|
||||
'execAgentMember: delegated to execAgent, operationId=%s, success=%s',
|
||||
result.operationId,
|
||||
result.success,
|
||||
);
|
||||
|
||||
return {
|
||||
error: result.error,
|
||||
operationId: result.operationId,
|
||||
started: result.success ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
private async execAgentThreadRun(
|
||||
params: ExecSubAgentParams | ExecVirtualSubAgentParams,
|
||||
options: {
|
||||
/**
|
||||
* Override the default sub-agent completion bridge with a custom hook
|
||||
* (e.g. the group-action member bridge for isolated executeAgentTask(s)).
|
||||
* Receives the freshly-created isolation thread id. Only used when
|
||||
* `resumeParentOnComplete` is set.
|
||||
*/
|
||||
bridgeHookFactory?: (threadId: string) => AgentHook;
|
||||
isSubAgent: boolean;
|
||||
logScope: 'execSubAgent' | 'execVirtualSubAgent';
|
||||
resumeParentOnComplete?: boolean;
|
||||
@@ -3199,9 +2973,7 @@ export class AiAgentService {
|
||||
options.resumeParentOnComplete && parentOperationId
|
||||
? [
|
||||
...threadHooks,
|
||||
options.bridgeHookFactory
|
||||
? options.bridgeHookFactory(thread.id)
|
||||
: this.createSubAgentBridgeHook(parentOperationId, parentMessageId, thread.id),
|
||||
this.createSubAgentBridgeHook(parentOperationId, parentMessageId, thread.id),
|
||||
]
|
||||
: threadHooks;
|
||||
|
||||
@@ -3616,76 +3388,6 @@ export class AiAgentService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion bridge for the group orchestration "call agent member" path.
|
||||
*
|
||||
* Fires on a member op's completion and delegates to
|
||||
* `AgentRuntimeService.completeGroupActionMember`: backfill the member anchor,
|
||||
* enforce the K=N member barrier, then resume/finish the parked supervisor.
|
||||
* Transport mirrors {@link createSubAgentBridgeHook} — in-process in local
|
||||
* mode, QStash → `/api/agent/webhooks/group-member-callback` in queue mode.
|
||||
*/
|
||||
private createGroupActionMemberBridgeHook(params: {
|
||||
anchorMessageId: string;
|
||||
expectedMembers: number;
|
||||
groupToolMessageId: string;
|
||||
mode: GroupActionMemberMode;
|
||||
onComplete: GroupActionOnComplete;
|
||||
parentOperationId: string;
|
||||
threadId?: string;
|
||||
}): AgentHook {
|
||||
const {
|
||||
anchorMessageId,
|
||||
expectedMembers,
|
||||
groupToolMessageId,
|
||||
mode,
|
||||
onComplete,
|
||||
parentOperationId,
|
||||
threadId,
|
||||
} = params;
|
||||
return {
|
||||
handler: async (event) => {
|
||||
try {
|
||||
await this.agentRuntimeService.completeGroupActionMember({
|
||||
anchorMessageId,
|
||||
expectedMembers,
|
||||
finalState: event.finalState,
|
||||
groupToolMessageId,
|
||||
mode,
|
||||
onComplete,
|
||||
operationId: event.operationId,
|
||||
parentOperationId,
|
||||
reason: event.reason ?? 'done',
|
||||
threadId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Group-member bridge: failed to complete bridge for parent %s: %O',
|
||||
parentOperationId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
id: 'group-member-bridge',
|
||||
type: 'onComplete' as const,
|
||||
webhook: {
|
||||
body: {
|
||||
anchorMessageId,
|
||||
expectedMembers,
|
||||
groupToolMessageId,
|
||||
mode,
|
||||
onComplete,
|
||||
parentOperationId,
|
||||
threadId,
|
||||
},
|
||||
delivery: 'qstash' as const,
|
||||
eventFields: ['operationId', 'reason', 'status'],
|
||||
fallback: 'none' as const,
|
||||
url: '/api/agent/webhooks/group-member-callback',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total tokens from AgentState usage object
|
||||
* AgentState.usage is of type Usage from @lobechat/agent-runtime
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
DeviceGitWorkingTreeFiles,
|
||||
DeviceGitWorkingTreePatches,
|
||||
DeviceGitWorkingTreeStatus,
|
||||
DeviceGitWorktreeListItem,
|
||||
DeviceListProjectSkillsResult,
|
||||
DeviceLocalFilePreviewResult,
|
||||
DeviceProjectFileIndexResult,
|
||||
@@ -208,13 +207,6 @@ 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
|
||||
|
||||
@@ -115,14 +115,6 @@ 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;
|
||||
@@ -404,7 +396,6 @@ export class HeterogeneousPersistenceHandler {
|
||||
main: createMainAgentRunState(currentAssistantMessageId),
|
||||
operationId,
|
||||
processedKeys: new Set(),
|
||||
seedAssistantMessageId: baseAssistantMessageId,
|
||||
toolMsgIdByCallId: new Map(),
|
||||
topicId,
|
||||
};
|
||||
@@ -553,23 +544,11 @@ export class HeterogeneousPersistenceHandler {
|
||||
if (snapshot.model) state.main.turnModel = snapshot.model;
|
||||
if (snapshot.provider) state.main.turnProvider = snapshot.provider;
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const currentTurnToolId =
|
||||
(await this.getLastChildToolMessageId(state.main.currentAssistantId)) ??
|
||||
this.getLastSnapshotToolMessageId(snapshot, state.toolMsgIdByCallId);
|
||||
@@ -584,20 +563,6 @@ 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.
|
||||
*
|
||||
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
-106
@@ -74,112 +74,6 @@ 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
@@ -1,153 +0,0 @@
|
||||
// @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.editingAgentId ?? ctx.agentId;
|
||||
const agentId = ctx.agentId;
|
||||
|
||||
if (!agentId) {
|
||||
return {
|
||||
@@ -240,7 +240,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
|
||||
params: UpdatePromptParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const agentId = ctx.editingAgentId ?? ctx.agentId;
|
||||
const agentId = ctx.agentId;
|
||||
|
||||
if (!agentId) {
|
||||
return {
|
||||
@@ -272,7 +272,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
|
||||
params: InstallPluginParams,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const agentId = ctx.editingAgentId ?? ctx.agentId;
|
||||
const agentId = ctx.agentId;
|
||||
|
||||
if (!agentId) {
|
||||
return {
|
||||
|
||||
@@ -43,60 +43,20 @@ export const agentManagementRuntime: ServerRuntimeRegistration = {
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const { agentId, instruction, taskTitle, timeout } = params;
|
||||
|
||||
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.',
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// 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: '',
|
||||
deferred: true,
|
||||
content: `🚀 Triggered async task to call agent "${agentId}"${taskTitle ? `: ${taskTitle}` : ''}`,
|
||||
state: {
|
||||
status: 'pending',
|
||||
subOperationId,
|
||||
targetAgentId: agentId,
|
||||
threadId,
|
||||
parentMessageId: ctx.messageId,
|
||||
task: {
|
||||
description: taskTitle || `Call agent ${agentId}`,
|
||||
instruction,
|
||||
targetAgentId: agentId,
|
||||
timeout: timeout || 1_800_000,
|
||||
},
|
||||
type: 'execSubAgent',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* 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,7 +19,6 @@ 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';
|
||||
@@ -77,7 +76,6 @@ registerRuntimes([
|
||||
topicReferenceRuntime,
|
||||
userInteractionRuntime,
|
||||
credsRuntime,
|
||||
groupManagementRuntime,
|
||||
knowledgeBaseRuntime,
|
||||
webOnboardingRuntime,
|
||||
lobeAgentRuntime,
|
||||
|
||||
@@ -53,80 +53,13 @@ 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,6 +474,5 @@
|
||||
"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/device.png": "/blog/assets6aebb8b5341dc37202ffecb363b9b906.webp"
|
||||
"https://file.rene.wang/clipboard-1780888016983-b47fcdab831b1.png": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp"
|
||||
}
|
||||
@@ -11,16 +11,18 @@ tags:
|
||||
|
||||
# Image & Video Generation Redesign
|
||||
|
||||
## Features
|
||||
This update refines the visual creation flow, so moving between image and video work feels quicker and more natural.
|
||||
|
||||
- **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.
|
||||
## What’s New
|
||||
|
||||
## Improvements
|
||||
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.
|
||||
|
||||
- Better mobile menu behavior.
|
||||
Memory reset is also simpler now. When you need a clean context, you can clear all memory entries in one action.
|
||||
|
||||
## Fixes
|
||||
Under the hood, the agent architecture was refactored to improve reliability and leave more room for future extensions.
|
||||
|
||||
- Fixed visual glitches in the compression view.
|
||||
- Corrected message count accuracy.
|
||||
## Experience Improvements
|
||||
|
||||
- Fixed visual glitches in compression view.
|
||||
- Improved mobile menu behavior.
|
||||
- Corrected message count display accuracy.
|
||||
|
||||
@@ -9,16 +9,18 @@ tags:
|
||||
|
||||
# 图片与视频生成重设计
|
||||
|
||||
## 新功能
|
||||
这次更新聚焦于优化视觉创作流程,让图片与视频生成之间的切换更自然、操作更高效。
|
||||
|
||||
- **重新设计的图片与视频生成。** 图片与视频创作不再是两条割裂的路径,可以更直接地在两种模式之间切换。
|
||||
- **一键清空全部记忆。** 需要重置上下文时,可一次性清空所有记忆条目。
|
||||
## 更新内容
|
||||
|
||||
这次首先重做了生成流程。图片与视频创作不再像两条割裂的路径,新界面让你在两种模式之间切换更直接。
|
||||
|
||||
记忆管理也变得更干净利落。需要重置上下文时,你现在可以一键清空全部记忆条目。
|
||||
|
||||
此外,Agent 内部架构也做了重构,重点是提升稳定性,并为后续能力扩展留出空间。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 移动端菜单交互更顺畅。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复压缩视图的显示异常。
|
||||
- 修正消息计数的准确性。
|
||||
- 修复压缩视图显示异常。
|
||||
- 优化移动端菜单交互。
|
||||
- 修正消息计数显示准确性。
|
||||
|
||||
@@ -12,20 +12,15 @@ tags:
|
||||
|
||||
# Agent Tasks and Agent Management
|
||||
|
||||
## Features
|
||||
This update improves how teams adopt and run Agents day to day, especially when coordinating bots across multiple workflows.
|
||||
|
||||
- **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.
|
||||
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.
|
||||
|
||||
## Improvements
|
||||
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.
|
||||
|
||||
- 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
|
||||
## Experience Improvements
|
||||
|
||||
- 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,20 +10,15 @@ tags:
|
||||
|
||||
# 智能体任务系统与 Agent 管理
|
||||
|
||||
## 新功能
|
||||
这次更新重点优化了 Agent 的上手和日常运营体验,尤其适合在多场景协作中使用 Bot 的团队。
|
||||
|
||||
- **应用内通知。** 重要更新和提醒现在会直接在产品内出现。
|
||||
- **引导式上手。** 新版引导帮助团队更快进入工作流。
|
||||
- **斜杠命令图标。** 技能专属图标让斜杠命令更易定位。
|
||||
LobeHub 现在支持应用内通知,重要更新和提醒可以直接在产品内收到。Agent 管理能力也进一步增强,在内容渲染和展示上更灵活,能覆盖更丰富的 Bot 使用场景。
|
||||
|
||||
上手流程也更顺了。新版引导可以帮助团队更快进入 Agent 工作流,斜杠菜单加入技能专属图标后,常用命令更容易定位。与此同时,GitHub Copilot 兼容性也进一步提升,包含视觉相关能力的改进。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- Agent 管理更灵活,在 Bot 场景中的渲染与内容处理更丰富。
|
||||
- GitHub Copilot 兼容性提升,包含视觉能力。
|
||||
- 市场入口移至侧边栏「资源」下方,布局更清晰。
|
||||
- AI 生成被中断时会显示可视化提示。
|
||||
- 出错时提供更友好的降级界面。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复了话题切换时的显示异常。
|
||||
- 将市场入口移至侧边栏「资源」下方,整体布局更清晰。
|
||||
- 在 AI 生成被中断时增加可视化提示。
|
||||
- 修复话题切换时的显示异常。
|
||||
- 改进错误处理,提供更友好的降级界面。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: AI Auto-Completion & Real-Time Gateway
|
||||
description: >-
|
||||
Added AI-powered input auto-completion, a real-time messaging gateway,
|
||||
expanded bot platform support, and improved context injection.
|
||||
Added AI-powered input auto-completion, WebSocket-based real-time messaging
|
||||
gateway, expanded bot platform support, and improved context injection.
|
||||
tags:
|
||||
- Auto-Completion
|
||||
- WebSocket Gateway
|
||||
@@ -12,21 +12,17 @@ tags:
|
||||
|
||||
# AI Auto-Completion & Real-Time Gateway
|
||||
|
||||
## Features
|
||||
This release focuses on removing small points of friction in writing and real-time collaboration.
|
||||
|
||||
- **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.
|
||||
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.
|
||||
|
||||
## Improvements
|
||||
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.
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
## Fixes
|
||||
## Experience Improvements
|
||||
|
||||
- The image generation button no longer defaults to the wrong model.
|
||||
- Closed an authentication bypass and sanitized HTML artifacts.
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: AI 自动补全与实时消息网关
|
||||
description: 新增 AI 输入自动补全、实时消息网关、扩展 Bot 平台支持,以及改进的上下文注入机制。
|
||||
description: 新增 AI 输入自动补全、基于 WebSocket 的实时消息网关、扩展 Bot 平台支持,以及改进的上下文注入机制。
|
||||
tags:
|
||||
- 自动补全
|
||||
- WebSocket 网关
|
||||
@@ -10,21 +10,17 @@ tags:
|
||||
|
||||
# AI 自动补全与实时消息网关
|
||||
|
||||
## 新功能
|
||||
这版更新聚焦在减少写作和实时协作中的细碎阻力,让整体体验更连贯。
|
||||
|
||||
- **AI 自动补全。** 编辑器会在你输入时给出补全建议,撰写消息更快。
|
||||
- **实时 Agent 网关。** 全新的 Agent 网关以更低延迟流式返回响应。
|
||||
- **更多 Bot 平台。** 飞书、Slack 和 QQ 现已支持更稳定的连接模式。
|
||||
- **通过 `@` 调用技能与工具。** 用 `@` 提及即可触发技能和工具并直接注入上下文,取代以斜杠命令为主的旧方式。
|
||||
- **独立的「技能」标签页。** 技能商店新增专属的「技能」标签页。
|
||||
- **自动创建话题。** 系统每 4 小时自动创建新话题,帮助整理会话。
|
||||
编辑器现在支持 AI 自动补全,你在输入时就能收到建议,消息撰写更快、上下文切换更少。消息链路方面,全新的 WebSocket Agent 网关支持实时推送响应,整体对话延迟更低。
|
||||
|
||||
Bot 连接能力也扩展到了更多平台。飞书、Slack 和 QQ 已支持 WebSocket 连接模式,消息传递更稳定。与此同时,上下文调用也更直接:通过 `@` 提及即可触发技能和工具,并完成直接上下文注入,逐步替代以斜杠命令为主的旧方式。
|
||||
|
||||
为了让长期使用时的导航更清晰,技能商店新增了专属「技能」标签页,系统也会每 4 小时自动创建新话题,帮助你持续整理会话上下文。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 助理文档支持渐进式加载,内容就绪即展示,不再阻塞整个页面。
|
||||
- 粘贴大量剪贴板内容时,聊天输入框不再卡顿。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 图片生成按钮不再默认选到错误的模型。
|
||||
- 修复了一个认证绕过漏洞,并清理了 HTML 工件。
|
||||
- 助理文档现在支持渐进式加载,在内容就绪时即时展示,不再阻塞整个页面
|
||||
- 修复了图片生成按钮错误默认选择模型的问题
|
||||
- 优化了粘贴性能,防止在粘贴大量剪贴板内容时聊天输入框卡顿
|
||||
- 加强了安全性,清理了 HTML 工件并修复了一个认证绕过漏洞
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: Agent Gateway & Customizable Sidebar
|
||||
description: >-
|
||||
Run agents through Gateway mode, customize the sidebar layout, manage
|
||||
documents in the agent workspace, and use new models.
|
||||
Server-side agent execution via Gateway mode, customizable sidebar layout,
|
||||
agent workspace with document management, and new model support.
|
||||
tags:
|
||||
- Gateway
|
||||
- Sidebar
|
||||
@@ -12,23 +12,22 @@ tags:
|
||||
|
||||
# Agent Gateway & Customizable Sidebar
|
||||
|
||||
## Features
|
||||
This release focuses on making everyday Agent work more stable and easier to manage.
|
||||
|
||||
- **Agent Gateway mode.** Agents can run on the server and stream results back. When you switch topics or hit a brief disconnect, sessions reconnect and resume, so long runs are less likely to break.
|
||||
- **Customizable sidebar.** Choose which items appear in the sidebar and reorder them in a dedicated modal. Recents now has search, rename, and quick actions.
|
||||
- **Agent workspace.** A right-side workspace to browse, rename, delete, and review history for agent documents. Running tasks move into a separate task manager with their own topic state, so your main conversations stay focused.
|
||||
- **In-chat prompt editing.** Rewrite or translate a prompt in the chat input before sending.
|
||||
- **Screen capture.** Grab screen content with an overlay picker and attach it in one step.
|
||||
- **CLI on desktop.** Install the LobeHub CLI to your system `PATH` with one click.
|
||||
- **New models.** Zhipu GLM-5.1, Seedance 2.0 video generation, and the StreamLake provider.
|
||||
Agents can now run on the server through Gateway mode and stream results over WebSocket. When you switch topics or hit a short disconnect, sessions reconnect and resume more smoothly, so long-running execution is less likely to break.
|
||||
|
||||
## Improvements
|
||||
Navigation is now easier to shape around your own habits. You can choose which items appear in the sidebar, reorder them in a dedicated customization modal, and use a stronger Recents experience with search, rename, and quick actions.
|
||||
|
||||
- Remote requests from the desktop app are more reliable.
|
||||
- Loading states reduce flicker while the assistant is thinking.
|
||||
- Clearer error messages for insufficient balance and deactivated accounts.
|
||||
Document and task workflows are now more centralized. A dedicated right-side workspace gives you one place to browse, rename, delete, and review history for Agent documents. Running tasks move into an isolated task manager view with independent topic state, so your main conversations stay focused.
|
||||
|
||||
## Fixes
|
||||
This update also improves several high-frequency input and tooling actions. You can rewrite or translate prompts directly in chat input before sending, capture screen content with an overlay picker and attach it in one step, and use LobeHub CLI from desktop with one-click install to your system `PATH`.
|
||||
|
||||
- Agent detail pages load correctly after a refresh instead of getting stuck on a spinner.
|
||||
- Fixed a crash when injecting certain document content into context.
|
||||
On model coverage, this release adds Zhipu GLM-5.1, Seedance 2.0 video generation, and the StreamLake provider.
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- Desktop now uses Electron native `fetch` for more reliable remote requests.
|
||||
- Optimistic loading states reduce streaming flicker while the assistant is thinking.
|
||||
- Agent detail pages now load correctly after refresh instead of staying in a spinner state.
|
||||
- Error classification now gives clearer messages for insufficient balance and deactivated accounts.
|
||||
- Fixed a context engine crash path caused by non-string content in document injection.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Agent 网关与可自定义侧边栏
|
||||
description: 通过网关模式运行服务端智能体、可自定义侧边栏布局、带文档管理的智能体工作区,以及新模型支持。
|
||||
description: 通过网关模式实现服务端智能体执行、可自定义侧边栏布局、带文档管理的智能体工作区,以及新模型支持。
|
||||
tags:
|
||||
- 网关
|
||||
- 侧边栏
|
||||
@@ -10,23 +10,22 @@ tags:
|
||||
|
||||
# Agent 网关与可自定义侧边栏
|
||||
|
||||
## 新功能
|
||||
这次更新聚焦在两件事:让日常 Agent 协作更稳定,也让操作路径更集中。
|
||||
|
||||
- **Agent 网关模式。** 智能体可以在服务端运行并流式返回结果。切换话题或短暂断线后,会话会自动重连并恢复,长流程执行不再那么容易中断。
|
||||
- **可自定义侧边栏。** 你可以在专用弹窗里选择侧边栏的显示项并调整顺序;「最近」也补齐了搜索、重命名和快捷操作。
|
||||
- **智能体工作区。** 右侧工作区可在一处完成智能体文档的浏览、重命名、删除和历史查看。运行中的任务进入独立的任务管理器,使用独立的话题状态,不再打断主对话。
|
||||
- **聊天内改写提示词。** 提示词可在发送前直接在输入框中改写或翻译。
|
||||
- **屏幕截图。** 通过覆盖层选择屏幕内容,并一步附加到对话。
|
||||
- **桌面端命令行。** 一键把 LobeHub CLI 安装到系统 `PATH`。
|
||||
- **新模型。** 智谱 GLM-5.1、Seedance 2.0 视频生成,以及 StreamLake 提供商。
|
||||
Agent 现在可以通过网关模式在服务端运行,并通过 WebSocket 流式返回结果。切换话题或短暂断线后,会更顺畅地自动重连并恢复会话,长流程执行不再那么容易中断。
|
||||
|
||||
## 体验优化
|
||||
导航也更贴合个人习惯。你可以在专用弹窗里选择侧边栏显示项并调整顺序;「最近」板块也补齐了搜索、重命名和快捷操作,日常切换会更快。
|
||||
|
||||
- 桌面端发起的远程请求更稳定。
|
||||
- 加载状态减少了助手思考阶段的界面闪烁。
|
||||
- 余额不足与账户停用场景的错误提示更清晰。
|
||||
文档与任务这类高频操作也更集中。新增的右侧工作区可以在一处完成 Agent 文档的浏览、重命名、删除和历史查看。运行中的任务则进入独立任务视图,并使用独立话题状态,不再打断主对话。
|
||||
|
||||
## 问题修复
|
||||
另外,这版也优化了几项高频输入与工具操作。提示词可在发送前直接改写或翻译;屏幕截图支持覆盖层选择并一步附加到对话;LobeHub CLI 已内嵌到桌面应用,并可在设置中一键安装到系统 `PATH`。
|
||||
|
||||
- 智能体详情页刷新后可正常加载,不再卡在加载动画。
|
||||
- 修复了注入某些文档内容到上下文时触发的崩溃。
|
||||
模型覆盖方面,本次新增智谱 GLM-5.1、Seedance 2.0 视频生成能力,以及 StreamLake 提供商。
|
||||
|
||||
## 体验优化与修复
|
||||
|
||||
- 桌面端现使用 Electron 原生 `fetch` 发起远程请求,连接更稳定。
|
||||
- 乐观更新的加载状态减少了助手思考阶段的界面闪烁。
|
||||
- Agent 详情页刷新后可正常加载,不再长期停留在加载动画。
|
||||
- 余额不足与账户停用场景的错误分类更准确,提示信息更清晰。
|
||||
- 修复了非字符串内容进入文档注入链路时触发的上下文引擎崩溃。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: 'Daily Brief, Document History & Approval Flow'
|
||||
description: >-
|
||||
A new Daily Brief on the homepage, Notion-style document history, human
|
||||
approval for agent actions, and Discord DM support.
|
||||
A new Daily Brief on the homepage, Notion-style document history, server-side
|
||||
human approval for agent actions, and Discord DM support.
|
||||
tags:
|
||||
- Daily Brief
|
||||
- Document History
|
||||
@@ -12,25 +12,23 @@ tags:
|
||||
|
||||
# Daily Brief, Document History & Approval Flow
|
||||
|
||||
## Features
|
||||
A personalized Daily Brief on the homepage, Notion-style document history, server-side human approval for agent actions, and expanded Discord support.
|
||||
|
||||
- **Daily Brief.** A homepage module that surfaces a personalized daily summary across your agents.
|
||||
- **Page history.** A Notion-style timeline for browsing previous versions of agent documents, with per-source retention limits.
|
||||
- **Human approval.** Agents can pause and ask for your approval before running sensitive actions, with one approve/reject UI and headless support in the CLI.
|
||||
- **Agent working panel.** A collapsible panel for managing agent documents and in-progress work next to the conversation.
|
||||
- **Discord slash commands and DMs.** Discord bots now support slash commands and direct messages.
|
||||
- **Client tools in Gateway mode.** Desktop can run client-side tools while the agent runs in Gateway mode.
|
||||
- **Pinnable sidebar items.** Lock important items so they stay put while you customize the sidebar.
|
||||
- **Task filters.** Filter tasks by multiple values with pagination, and see participant avatars on task cards.
|
||||
## Key Updates
|
||||
|
||||
## Improvements
|
||||
- Daily Brief: a new module on the homepage surfaces a personalized daily summary across your agents
|
||||
- Page history: a Notion-style timeline lets you browse previous versions of agent documents, with per-source retention limits
|
||||
- Human approval flow: agents can now pause and request your approval before running sensitive actions, with a unified approve/reject UI and support for headless mode in the CLI
|
||||
- Agent working panel: a new collapsible panel for managing agent documents and in-progress work alongside the conversation
|
||||
- Discord upgrades: Discord bots now support slash commands and direct messages for smoother team workflows
|
||||
- Client tools in Gateway mode: desktop / Electron can now execute client-side tools while the agent runs in Gateway mode
|
||||
- Pinnable sidebar items: lock important items in the sidebar so they stay in place during customization
|
||||
- Task filters: filter tasks by multiple values with pagination, and see participant avatars directly on task cards
|
||||
|
||||
- Multi-step command runs are split into separate assistant messages, so each step is easier to follow.
|
||||
- The settings save bar stays pinned to the bottom with an instant toggle.
|
||||
- Nav panel transitions feel snappier after removing the content-switch animation.
|
||||
- Restored the knowledge-base file listing and detail tools.
|
||||
## Experience Improvements
|
||||
|
||||
## Fixes
|
||||
|
||||
- The recent-items delete action now takes effect.
|
||||
- The Gateway-mode stop button, approve/reject routing, and paused-operation cleanup are more reliable.
|
||||
- Multi-step command executions are now split into separate assistant messages, making each step easier to follow
|
||||
- Settings SaveBar stays pinned to the bottom with an instant toggle, so changes are one click away
|
||||
- Nav panel transitions feel snappier after removing the content-switch animation
|
||||
- Restored the lobe-kb file listing and detail tools, and fixed a case where the recent-items delete action wasn't taking effect
|
||||
- Gateway-mode stop button, approve/reject routing, and paused-operation cleanup are all more reliable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 每日简报、文档历史与审批流程
|
||||
description: 首页新增每日简报模块、Notion 风格的文档历史、智能体操作的人工审批流程,以及 Discord 私信支持。
|
||||
description: 首页新增每日简报模块、Notion 风格的文档历史、智能体操作的服务端人工审批流程,以及 Discord 私信支持。
|
||||
tags:
|
||||
- 每日简报
|
||||
- 文档历史
|
||||
@@ -10,25 +10,23 @@ tags:
|
||||
|
||||
# 每日简报、文档历史与审批流程
|
||||
|
||||
## 新功能
|
||||
首页新增个性化每日简报、Notion 风格的文档历史、智能体操作的服务端人工审批流程,以及更完善的 Discord 支持。
|
||||
|
||||
- **每日简报。** 首页新增模块,呈现跨智能体的个性化每日摘要。
|
||||
- **文档历史。** Notion 风格的时间线,可浏览智能体文档的历史版本,并支持按来源设置保留上限。
|
||||
- **人工审批。** 智能体可在执行敏感操作前暂停并请求你的审批,提供统一的同意 / 拒绝界面,CLI 也支持无头审批。
|
||||
- **智能体工作面板。** 新增可折叠面板,在对话旁管理智能体文档与进行中的工作。
|
||||
- **Discord 斜杠命令与私信。** Discord 机器人现已支持斜杠命令和私信。
|
||||
- **网关模式下的客户端工具。** 智能体运行于网关模式时,桌面端可执行客户端工具。
|
||||
- **可固定侧边栏项目。** 自定义侧边栏时锁定重要项目,保持位置不变。
|
||||
- **任务筛选。** 支持多值筛选与分页,任务卡片上直接显示参与者头像。
|
||||
## 重要更新
|
||||
|
||||
- 每日简报:首页新增模块,呈现跨智能体的个性化每日摘要
|
||||
- 文档历史:Notion 风格的时间线让你浏览智能体文档的历史版本,并支持按来源设置保留上限
|
||||
- 人工审批流程:智能体现可在执行敏感操作前暂停并请求你的审批,统一的同意 / 拒绝交互,同时支持 CLI 的无头审批模式
|
||||
- 智能体工作面板:新增可折叠面板,在对话旁管理智能体文档与进行中的工作
|
||||
- Discord 升级:Discord 机器人现已支持斜杠命令和私信,团队协作更顺畅
|
||||
- 网关模式下的客户端工具:桌面 / Electron 可在智能体运行于网关模式时执行客户端工具
|
||||
- 可固定侧边栏项目:在自定义时锁定重要项目,保持位置不变
|
||||
- 任务筛选:支持多值筛选与分页,任务卡片上直接显示参与者头像
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 多步命令执行会拆分为独立的助手消息,每一步更易跟踪。
|
||||
- 设置中的保存栏固定在底部并支持即时切换。
|
||||
- 移除导航面板的内容切换动画后,切换更流畅。
|
||||
- 恢复了知识库的文件列表与详情工具。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 最近项目的删除操作现在会生效。
|
||||
- 网关模式的停止按钮、同意 / 拒绝路由,以及暂停操作的清理更加稳定。
|
||||
- 多步命令执行现在会拆分为独立的助手消息,每一步更易跟踪
|
||||
- 设置中的 SaveBar 固定在底部并支持即时切换,修改一键可达
|
||||
- 移除导航面板内容切换动画后,切换更加流畅
|
||||
- 恢复了 lobe-kb 文件列表和详情工具,修复了最近项目删除操作未生效的问题
|
||||
- 网关模式的停止按钮、审批 / 拒绝路由,以及暂停操作的清理更加稳定可靠
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 'Coding Agent: Claude Code & Codex on Desktop'
|
||||
title: Coding Agent — Claude Code & Codex on Desktop
|
||||
description: >-
|
||||
Claude Code and Codex graduate to first-class desktop runtimes, alongside a
|
||||
new Agent Signal runtime and a wave of flagship models.
|
||||
@@ -13,20 +13,17 @@ tags:
|
||||
|
||||
## Features
|
||||
|
||||
- **Delegate Claude Code and Codex.** Run third-party coding agents like Claude Code and Codex from the desktop app.
|
||||
- **Quick Chat on desktop.** Capture your screen and ask LobeHub about it.
|
||||
- **New models.** GPT-5.5, DeepSeek V4 Flash and Pro with a reasoning-effort slider, LobeHub-hosted gpt-image-2, Kimi K2.6, and MiMo-V2.5 and Pro.
|
||||
- **New providers.** OpenCode Zen and OpenCode Go.
|
||||
- Topic remembers its own scroll position
|
||||
- User message stays pinned to the viewport top with long messages folded, the last user message can be edited and resent inline, and follow-up sends queue cleanly during a concurrent turn.
|
||||
- Delegating 3rd party coding agents such as Claude Code and Codex
|
||||
- Quick chat and capture your screen and ask LobeHun with desktop app
|
||||
- New models: GPT-5.5, DeepSeek V4 Flash and Pro with a reasoning-effort slider, LobeHub-hosted gpt-image-2, Kimi K2.6, MiMo-V2.5 and Pro
|
||||
- New providers: OpenCode Zen and OpenCode Go.
|
||||
|
||||
## Improvements
|
||||
## Improvements and fixes
|
||||
|
||||
- Topics remember their scroll position.
|
||||
- Long user messages fold and stay pinned to the top of the view, the last user message can be edited and resent inline, and follow-up sends queue cleanly during a running turn.
|
||||
- The file editor has more consistent focus and keyboard behavior.
|
||||
|
||||
## Fixes
|
||||
|
||||
- The first assistant block no longer shifts layout while streaming.
|
||||
- The conversation no longer jumps back to the bottom after you scroll up.
|
||||
- Tool inspectors render correctly for Codex and follow-up turns.
|
||||
- Long-running tasks stay alive instead of stalling.
|
||||
- Disabled markdown streaming on the first assistant block to avoid mid-stream layout shifts.
|
||||
- Conversation no longer repins to the bottom after a manual scroll.
|
||||
- Tool inspectors render correctly for Codex and heterogeneous-agent follow-ups.
|
||||
- FileEditor migrated from antd Modal to base-ui Modal for consistent focus and keyboard behavior.
|
||||
- QStash heartbeat self-reschedules to keep long-running tasks alive.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 编程 Agent:Claude Code 与 Codex 进入桌面端
|
||||
title: 编程 Agent —— Claude Code 与 Codex 进入桌面端
|
||||
description: Claude Code 与 Codex 成为桌面端的一等运行时,全新 Agent Signal 运行时上线,并迎来一批旗舰模型。
|
||||
tags:
|
||||
- 异构 Agent
|
||||
@@ -11,20 +11,17 @@ tags:
|
||||
|
||||
## 新功能
|
||||
|
||||
- **接入 Claude Code 与 Codex。** 可在桌面端运行 Claude Code、Codex 等第三方编程 Agent。
|
||||
- **桌面端 Quick Chat。** 截取屏幕内容并就此向 LobeHub 提问。
|
||||
- **新模型。** GPT-5.5、DeepSeek V4 Flash 与 Pro(带思考强度滑块)、LobeHub 托管的 gpt-image-2、Kimi K2.6,以及 MiMo-V2.5 与 Pro。
|
||||
- **新提供商。** OpenCode Zen 与 OpenCode Go。
|
||||
- 话题级别记忆滚动位置
|
||||
- 用户消息固定在视口顶部,过长内容自动折叠;最后一条用户消息可直接编辑并重发;并发对话期间的后续发送会顺序排队
|
||||
- 接入 Claude Code、Codex 等第三方编程 Agent
|
||||
- 在桌面端通过 Quick Chat 与屏幕截图直接向 LobeHub 提问
|
||||
- 新模型:GPT-5.5、DeepSeek V4 Flash / Pro(带思考强度滑块)、LobeHub 托管的 gpt-image-2、Kimi K2.6、MiMo-V2.5 与 Pro
|
||||
- 新提供商:OpenCode Zen 与 OpenCode Go
|
||||
|
||||
## 体验优化
|
||||
## 体验优化与修复
|
||||
|
||||
- 话题会记住各自的滚动位置。
|
||||
- 过长的用户消息会折叠并固定在视口顶部,最后一条用户消息可直接编辑并重发,运行期间的后续发送也会顺序排队。
|
||||
- 文件编辑器的焦点与键盘行为更一致。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 第一条助手消息在流式渲染时不再发生布局抖动。
|
||||
- 手动向上滚动后,对话不再跳回底部。
|
||||
- Codex 与后续轮次的工具检查器渲染正常。
|
||||
- 长任务能持续保活,不再中途停滞。
|
||||
- 第一条助手消息不再启用 Markdown 流式渲染,避免渲染过程中的布局抖动。
|
||||
- 手动滚动后不再重新自动钉住对话底部。
|
||||
- 修复了 Codex 与异构 Agent 后续轮次中工具检查器渲染异常的问题。
|
||||
- FileEditor 从 antd Modal 迁移到 base-ui Modal,焦点与键盘行为更一致。
|
||||
- QStash 心跳支持自我重调度,长任务运行更稳定。
|
||||
|
||||
@@ -13,26 +13,24 @@ tags:
|
||||
|
||||
# Delegate Claude Code and Codex
|
||||
|
||||
Now you can control coding agents in LobeHub. Simply click `Create Agent` and choose your coding agent. This feature is only available on desktop app.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Delegate Claude Code and Codex.** Control coding agents inside LobeHub: click Create Agent and choose your coding agent. Desktop only.
|
||||
- **Group topics by agent.** Switch the topic list to group by agent, with a friendlier empty state.
|
||||
- **Review tab.** A new tab that gathers bulk Git diffs across a tree, about 9× faster on large repos.
|
||||
- **Local file snapshots.** Drag a file into chat and a snapshot is captured for the model to read.
|
||||
- **Visual understanding tool.** A built-in tool for image analysis and visual reasoning.
|
||||
- **Line bot support.** Connect a Line channel as an agent endpoint.
|
||||
- **New models.** `grok-4.3`, the DeepSeek Anthropic runtime, plus `gpt-image-2` and Grok 4.20 in the model library.
|
||||
- Agent-specific topic grouping: switch the topic list to group by agent, with a friendlier empty state
|
||||
- Review tab: a new tab that aggregates bulk git diffs across a tree, \~9× faster on large repos
|
||||
- Local file mention snapshots: drag a file into chat and a snapshot is captured for the model to reason over
|
||||
- Visual understanding tool: a new built-in tool for image analysis and visual reasoning
|
||||
- Line bot support: connect a Line channel as an agent endpoint
|
||||
- New models: `grok-4.3`, DeepSeek Anthropic runtime, plus `gpt-image-2` and Grok 4.20 in the model library
|
||||
|
||||
## Improvements
|
||||
## Improvements and fixes
|
||||
|
||||
- DeepSeek shows pricing in the model card and respects model defaults.
|
||||
- The document modal shows a skeleton while the title loads, and surfaces the document's update time in the space.
|
||||
- Agent documents can be exposed as a file system for tools to read.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Sessions are revoked after a password reset, and list pagination now enforces a maximum page size.
|
||||
- Skill OAuth no longer breaks the desktop app.
|
||||
- CAPTCHA retries during sign-in are handled cleanly instead of failing.
|
||||
- DeepSeek now shows pricing in the model card and respects model defaults.
|
||||
- Document modal shows a skeleton while the title loads and surfaces the document update time in space.
|
||||
- Agent documents can be exposed as a virtual file system with fs-compatible output.
|
||||
- Sessions are revoked after a password reset, and tRPC pagination now enforces a max limit.
|
||||
- Skill OAuth no longer breaks the desktop app by skipping `redirectUri` on Electron.
|
||||
- CAPTCHA retries during sign-in are handled cleanly instead of failing the flow.
|
||||
|
||||
@@ -13,26 +13,25 @@ tags:
|
||||
|
||||
# 在 LobeHub 中调度 Claude Code 与 Codex
|
||||
|
||||
现在你可以在 LobeHub 内使用 Coding Agents。新建助手时选择你最喜欢的 Coding Agent 即可。此功能仅在桌面端可用。
|
||||
|
||||

|
||||
|
||||
## 新功能
|
||||
|
||||
- **调度 Claude Code 与 Codex。** 在 LobeHub 内直接控制编程 Agent:新建助手时选择你的编程 Agent 即可。仅在桌面端可用。
|
||||
- **按 Agent 分组话题。** 可将话题列表切换为按 Agent 分组,并带有更友好的空状态。
|
||||
- **Review 标签页。** 新增 Review 标签页,可聚合树级别的批量 git diff,大型仓库下速度提升约 9 倍。
|
||||
- **本地文件快照。** 将文件拖入聊天即可生成快照,供模型读取。
|
||||
- **视觉理解工具。** 内置的图像分析与视觉推理工具。
|
||||
- **Line Bot 接入。** 可将 Line 频道作为 Agent 接入端。
|
||||
- **新模型。** `grok-4.3`、DeepSeek Anthropic 运行时,以及模型库新增的 `gpt-image-2` 和 Grok 4.20。
|
||||
- 新增:在 LobeHub 中调度 Claude Code 与 Codex
|
||||
- 按 Agent 分组话题:可将话题列表切换为按 Agent 分组,并带有更友好的空状态
|
||||
- Review 标签页:新增 Review 标签页,可聚合树级别的批量 git diff,大型仓库下速度提升约 9 倍
|
||||
- 本地文件提及快照:将文件拖入聊天即可生成快照供模型理解
|
||||
- 视觉理解工具:内置的图像分析与视觉推理工具
|
||||
- Line Bot 接入:可将 Line 频道作为 Agent 接入端
|
||||
- 新模型:`grok-4.3`、DeepSeek Anthropic 运行时,以及模型库新增的 `gpt-image-2` 和 Grok 4.20
|
||||
|
||||
## 体验优化
|
||||
## 体验优化与修复
|
||||
|
||||
- DeepSeek 模型卡片展示价格并遵循模型默认配置。
|
||||
- 文档弹窗在标题加载时显示骨架,并在空间中展示文档更新时间。
|
||||
- Agent 文档可作为文件系统暴露,供工具读取。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 重置密码后会立即吊销已有会话,列表分页接口新增最大条数限制。
|
||||
- Skill OAuth 不再导致桌面应用进入异常状态。
|
||||
- DeepSeek 模型卡片展示价格并尊重模型默认配置。
|
||||
- 文档弹窗在标题加载时显示骨架,并在 Space 中展示文档更新时间。
|
||||
- Agent 文档可作为虚拟文件系统暴露,输出兼容 fs 接口。
|
||||
- 重置密码后会立即吊销已有会话,tRPC 分页接口新增最大条数限制。
|
||||
- 在桌面端跳过 Skill OAuth 的 `redirectUri`,避免应用进入异常状态。
|
||||
- 登录流程中的 CAPTCHA 重试可正常处理,不再直接失败。
|
||||
|
||||
@@ -13,23 +13,31 @@ tags:
|
||||
|
||||
# Agent Tasks GA & Cloud Heterogeneous Agent
|
||||
|
||||
## Tasks
|
||||
|
||||
Think of Agent Tasks like Linear, but with agents as your teammates. Create tasks the same way you'd file an issue — title, description, optional template — and assign them to an agent instead of a person. The agent picks up the task, executes the work, posts updates in comments, and moves the status forward (todo → in progress → done) as it makes progress.
|
||||
|
||||
Tasks can have subtasks with explicit dependencies, so a parent task can fan out work and the agent will run subtasks in dependency order. Recurring tasks can be wired to a cron schedule, parent assignments can be reshuffled at any time, and every task has its own thread of comments where you and the agent can coordinate.
|
||||
|
||||
Learn more in the [Task guide](/docs/usage/getting-started/task).
|
||||
|
||||
## Features
|
||||
|
||||
- **Agent Tasks (GA).** Create a task the way you'd file an issue (title, description, optional template) and assign it to an agent instead of a person. The agent picks up the task, does the work, posts updates in comments, and moves the status from todo to in progress to done. Tasks can have subtasks with dependencies, so a parent task fans out work and runs subtasks in order. Recurring tasks can run on a schedule, parent assignments can change at any time, and each task has its own comment thread. [Task guide](/docs/usage/getting-started/task)
|
||||
- **Nightly self-review.** Agents run an automatic self-review each night and add what they find to your briefs.
|
||||
- **Cloud heterogeneous agents.** Claude Code and Codex now run in the cloud, with sessions that survive restarts.
|
||||
- **`lh hetero exec` command.** Run a standalone coding agent from the terminal, with multimodal input on desktop and CLI.
|
||||
- **Mid-run questions.** Claude Code can pause and ask you a question while it works.
|
||||
- **Inline agents in chat.** The `lobeAgents` Markdown tag renders agent cards, and a newly created agent shows up as a clickable card.
|
||||
- **More bot platforms.** Messenger, Line, and Telegram, with direct-message pairing and per-sender tool controls.
|
||||
- **New models.** Gemini 3.1 Flash-Lite, SiliconCloud model sync, and DeepSeek V4 Pro as the new open-source default.
|
||||
- Agent Tasks goes GA: the full task platform with templates, scheduled cron, comment tools, parent reassignment, and dependency-ordered batch subtask runs
|
||||
- Nightly self-review: Agent Signal pipeline runs automatic self-review with skill-aware policies and pushes activity into briefs
|
||||
- Cloud heterogeneous agents: Claude Code and Codex now execute server-side with persistent sessions that survive Vercel replica restarts
|
||||
- `lh hetero exec` CLI: run a standalone heterogeneous agent from the terminal, with multimodal input support across desktop / CLI
|
||||
- Claude Code can now pause and ask you a question mid-execution
|
||||
- Inline agents in chat: `lobeAgents` markdown tag renders agent profile cards, and a newly created agent shows up as a clickable card
|
||||
- Bot platforms expand: Messenger, Line, and Telegram integrations with DM pair policy and per-sender device tool gating
|
||||
- New models: Gemini 3.1 Flash-Lite, SiliconCloud model sync, and DeepSeek V4 Pro as the new OSS default
|
||||
|
||||
## Improvements
|
||||
## Improvements and fixes
|
||||
|
||||
- Knowledge-base answers can cite specific passages from your documents.
|
||||
- The Daily Brief was redesigned with a linkable welcome card and a paired input hint, and resolved briefs show a mute icon.
|
||||
- Long tool-call parameters wrap instead of truncating, and tool run time shows as `Xmin Ys`.
|
||||
- A divider between queued messages makes it clear which sends are still pending.
|
||||
- Copy session ID is now in the topic dropdown menu.
|
||||
- The home sidebar remembers its collapsed state across reloads.
|
||||
- The desktop tray icon's visibility is now a setting.
|
||||
- Inline document grounding in the KB tool via BM25 search and `docs_*` reads.
|
||||
- Daily Brief redesigned with linkable welcome card and a paired input hint; resolved briefs now show a mute icon.
|
||||
- Long tool-call parameters now wrap instead of truncating; tool execution time formatted as `Xmin Ys`.
|
||||
- Visible divider between queued messages so it's clear which sends are pending.
|
||||
- Copy session ID added to the topic dropdown menu.
|
||||
- Home sidebar collapse state persists across reloads.
|
||||
- Desktop app tray visibility is now a setting.
|
||||
|
||||
@@ -12,23 +12,31 @@ tags:
|
||||
|
||||
# Agent 任务系统 GA 与云端异构 Agent
|
||||
|
||||
## Agent 任务系统
|
||||
|
||||
Agent 任务系统的体感类似 Linear,但「队友」是 Agent。你像建 Issue 一样创建任务 —— 标题、描述、可选模板 —— 把它分配给 Agent 而不是某个人。Agent 接到任务后会执行工作、在评论中同步进展,并随着推进更新状态(待办 → 进行中 → 已完成)。
|
||||
|
||||
任务支持带显式依赖的子任务,父任务可以拆分工作,Agent 会按依赖顺序运行子任务。周期性任务可以挂接 Cron 计划;父任务的指派可以随时重新调整;每个任务都有自己的评论线,方便你和 Agent 协作沟通。
|
||||
|
||||
详见 [任务使用指南](/docs/usage/getting-started/task)。
|
||||
|
||||
## 新功能
|
||||
|
||||
- **Agent 任务系统(GA)。** 你可以像建 Issue 一样创建任务(标题、描述、可选模板),并把它分配给 Agent 而不是某个人。Agent 接到任务后会执行工作、在评论中同步进展,并把状态从「待办」推进到「进行中」「已完成」。任务支持带依赖的子任务,父任务可以拆分工作并按依赖顺序运行子任务;周期性任务可以挂接计划,父任务的指派可随时调整,每个任务都有自己的评论线。[任务使用指南](/docs/usage/getting-started/task)
|
||||
- **夜间自审。** Agent 每晚自动复盘,并把发现写进你的简报。
|
||||
- **云端异构 Agent。** Claude Code 与 Codex 现在在云端运行,会话可在重启后恢复。
|
||||
- **`lh hetero exec` 命令。** 在终端独立运行一个编程 Agent,桌面端与命令行均支持多模态输入。
|
||||
- **执行中提问。** Claude Code 可在工作过程中暂停并向你提问。
|
||||
- **聊天内联 Agent。** `lobeAgents` Markdown 标签会渲染 Agent 卡片,新建的 Agent 也会以可点击卡片形式出现。
|
||||
- **更多 Bot 平台。** 新增 Messenger、Line、Telegram,支持私信配对与按发送者的工具权限控制。
|
||||
- **新模型。** Gemini 3.1 Flash-Lite、SiliconCloud 模型同步,DeepSeek V4 Pro 成为开源版新默认模型。
|
||||
- Agent 任务系统 GA:完整的任务平台,支持模板、Cron 定时、评论工具、父任务重指派,以及按依赖顺序的批量子任务运行
|
||||
- 夜间自审:Agent Signal 流水线自动运行自审,结合技能感知策略并将活动推送到简报
|
||||
- 云端异构 Agent:Claude Code 与 Codex 在服务端运行,会话持久化可跨 Vercel 副本恢复
|
||||
- `lh hetero exec` CLI:在终端独立运行异构 Agent,桌面端 / CLI 支持多模态输入
|
||||
- AskUserQuestion 工具:Claude Code 可在执行过程中暂停并向你提问
|
||||
- 聊天内联 Agent:`lobeAgents` Markdown 标签渲染 Agent 卡片,新建的 Agent 会以可点击卡片形式出现
|
||||
- Bot 平台扩展:新增 Messenger、Line、Telegram 接入,支持 DM 配对策略与按发送者识别的设备工具网关
|
||||
- 新模型:Gemini 3.1 Flash-Lite、SiliconCloud 模型同步,DeepSeek V4 Pro 成为开源版默认模型
|
||||
|
||||
## 体验优化
|
||||
## 体验优化与修复
|
||||
|
||||
- 知识库回答可以基于你文档中的具体段落作答。
|
||||
- 每日简报改版:欢迎卡片可链接、输入提示成对出现,已处理的简报会显示静音图标。
|
||||
- 工具调用参数过长时自动换行,不再截断;工具执行时间显示为 `Xmin Ys`。
|
||||
- 排队消息之间新增分隔线,方便辨认仍待发送的内容。
|
||||
- 话题下拉菜单新增「复制会话 ID」。
|
||||
- 首页侧边栏在刷新后会记住折叠状态。
|
||||
- 桌面端托盘图标的可见性现已纳入设置。
|
||||
- 知识库工具支持通过 BM25 搜索与 `docs_*` 读取实现内联文档落地。
|
||||
- 每日简报改版:欢迎卡片可链接、输入提示成对出现;已处理的简报展示静音图标。
|
||||
- 工具调用参数过长时自动换行,不再截断;工具执行时间格式化为 `Xmin Ys`。
|
||||
- 排队消息之间新增可见分隔线,方便辨认待发送的内容。
|
||||
- 话题下拉菜单新增「复制会话 ID」操作。
|
||||
- 首页侧边栏的折叠状态在刷新后会保留。
|
||||
- 桌面应用托盘可见性现已纳入设置。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: 'Introducing CAO: Your Chief Agent Operator'
|
||||
title: Introducing CAO — Your Chief Agent Operator
|
||||
description: >-
|
||||
CAO is an agent that reviews its own work, brings in sub-agents when it needs
|
||||
help, and only stops to ask you when a decision matters.
|
||||
Meet CAO: agents that review their own work, recruit teammates when they need
|
||||
help, and only stop to ask you when it really matters.
|
||||
tags:
|
||||
- CAO
|
||||
- Agent Teams
|
||||
@@ -10,27 +10,31 @@ tags:
|
||||
- Models
|
||||
---
|
||||
|
||||
# Introducing CAO: Your Chief Agent Operator
|
||||
# Introducing CAO — Your Chief Agent Operator
|
||||
|
||||
## Features
|
||||
## Meet CAO
|
||||
|
||||
- **CAO (Chief Agent Operator).** Your agent can now keep going on its own. It reviews its own work, decides the next step, and continues without waiting for you to say "next." When a decision genuinely needs you, it pauses, tells you what it tried, and asks a clear question. For larger tasks, it can put together a small team of sub-agents and hand off parts of the work while the main agent stays in charge of the plan. You can open any sub-agent's conversation to see its progress. [Learn more about CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
|
||||
- **Project skills in the working sidebar**, with a built-in Markdown preview.
|
||||
- **Per-suggestion models.** Pick a different model for each kind of follow-up suggestion.
|
||||
- **New model:** Baidu Ernie 5.1.
|
||||
CAO (Chief Agent Operator) turns short back-and-forth chats into agents that just keep going. Instead of waiting for you to say "ok, next step", your agent now checks its own work, decides what to do next, and gets on with it. When something genuinely needs your call, it pauses, tells you what it tried, and asks a clear question — no more guessing in the dark.
|
||||
|
||||
## Improvements
|
||||
CAO can also bring in help. If a task gets big, your agent can put together a small team of sub-agents and hand pieces off to them. You can peek into any of their conversations to see what they're up to, while your main agent stays in charge of the overall plan.
|
||||
|
||||
- Chat input actions show as icon and label, and only the latest reply animates while streaming.
|
||||
[Learn more about CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
|
||||
|
||||
## What else is new
|
||||
|
||||
- Recurring tasks are more reliable, with a cap on how many times a task can run and protection against schedules that fire too often
|
||||
- A cleaner Agent Documents view — easier to find what you put there, and no more clutter from old web crawls
|
||||
- Project skills now show up in the working sidebar, with a built-in markdown preview
|
||||
- You can pick a different model for each kind of follow-up suggestion
|
||||
- New model: Baidu Ernie 5.1
|
||||
- DeepSeek pricing is back to official rates
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- Desktop no longer signs you out from background token refreshes, and remembers which page you were on after an app update.
|
||||
- Chat input actions show as icon + label, and only the latest reply animates as it streams.
|
||||
- Cleaner tab bar styling, and task schedule labels no longer wrap awkwardly.
|
||||
- Recurring tasks are more reliable, with a run-count cap and protection against schedules that fire too often.
|
||||
- A cleaner Agent Documents view: easier to find what you saved, without old web-crawl clutter.
|
||||
- DeepSeek is priced at official rates again.
|
||||
|
||||
## Fixes
|
||||
|
||||
- Desktop no longer signs you out during background token refreshes, and it remembers which page you were on after an update.
|
||||
- Local-file search handles hidden files that start with `.`, and grep parameters pass through correctly.
|
||||
- Unsupported documents now fail with a clear message instead of a confusing error.
|
||||
- Gemini tool calls handle thinking signatures and enums correctly, and per-tool timeouts now hold.
|
||||
- Task pages keep the right agent context, and memory updates stay out of your main thread.
|
||||
- Local-file search handles hidden files starting with `.`, and grep parameters now flow through correctly.
|
||||
- Documents fail clearly when they aren't supported, instead of producing confusing errors.
|
||||
- Gemini tool calls handle thinking signatures and enums correctly, and per-tool timeouts now stick.
|
||||
- Task pages keep the right agent context; memory updates stay out of your main thread.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'CAO:你的首席智能体运营官'
|
||||
description: CAO 是一种会自己复盘、需要时能组建子智能体、只在需要你拍板时才停下来的智能体。
|
||||
title: 隆重推出 CAO —— 你的首席智能体运营官
|
||||
description: 认识 CAO:会自己复盘、能临时组队,只在真正需要时才停下来问你的智能体。
|
||||
tags:
|
||||
- CAO
|
||||
- 智能体团队
|
||||
@@ -8,26 +8,30 @@ tags:
|
||||
- 模型
|
||||
---
|
||||
|
||||
# CAO:你的首席智能体运营官
|
||||
# 隆重推出 CAO —— 你的首席智能体运营官
|
||||
|
||||
## 新功能
|
||||
## 认识 CAO
|
||||
|
||||
- **CAO(首席智能体运营官)。** 智能体现在可以自己往前推进:它会复盘已完成的部分,决定下一步,并继续执行,不必每一步都等你说「下一步」。遇到真正需要你拍板的事,它会停下来,告诉你已经尝试了什么,并提出一个清晰的问题。任务较大时,它可以组建一支子智能体小队并分派工作,主智能体始终掌控整体节奏。你可以打开任意子智能体的对话查看进展。[了解更多 CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
|
||||
- **项目技能进入工作侧栏**,并自带 Markdown 预览。
|
||||
- **按建议类型选择模型。** 不同类型的后续建议可以分别选择不同的模型。
|
||||
- **新模型:** 百度文心一言 5.1。
|
||||
CAO(首席智能体运营官)让原本一问一答的对话,变成一个能自己往前推进的智能体。不必再每一步都告诉它「好,下一步」—— 它会自己复盘已完成的部分,决定接下来要做什么,然后继续推进。当遇到真正需要你来拍板的事,它会停下来,告诉你已经尝试了什么,并提出一个清晰的问题,而不是凭感觉硬猜。
|
||||
|
||||
## 体验优化
|
||||
CAO 也会找帮手。任务变大时,它可以临时组建一支子智能体小队,把不同部分交给队友处理。你随时可以看任意一个子智能体的对话进展,而主智能体始终掌控整体节奏。
|
||||
|
||||
- 聊天输入框的动作按钮调整为「图标 + 标签」样式,只有最新一条回复会有打字动画。
|
||||
- 标签栏样式更清爽,任务计划标签不再换行错位。
|
||||
- 周期性任务更稳定:可限制总执行次数,并阻止设置得过于频繁的计划。
|
||||
- 智能体文档视图更清爽:更容易找到自己放进去的东西,也不再混着旧的网页抓取记录。
|
||||
- DeepSeek 已恢复官方定价。
|
||||
[了解更多 CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
|
||||
|
||||
## 问题修复
|
||||
## 还有这些新东西
|
||||
|
||||
- 周期性任务更稳了:可以限制总执行次数,也会阻止设置得过于频繁的计划
|
||||
- 智能体文档视图更清爽 —— 更容易找到自己放进去的东西,也不再混着旧的网页抓取记录
|
||||
- 项目技能现在会出现在工作侧栏,并自带 Markdown 预览
|
||||
- 不同类型的后续建议可以分别选择不同的模型
|
||||
- 新增模型:百度文心一言 5.1
|
||||
- DeepSeek 价格已恢复官方定价
|
||||
|
||||
## 体验优化与修复
|
||||
|
||||
- 桌面端不再因为后台刷新 token 而把你踢出登录,应用更新后也能回到你之前所在的页面。
|
||||
- 聊天输入框的动作按钮调整为「图标 + 标签」样式,只有最新一条回复会有打字动画。
|
||||
- 标签栏样式更清爽,任务计划标签不再换行错位。
|
||||
- 本地文件搜索可以正确处理以 `.` 开头的隐藏文件,搜索参数也能完整传递。
|
||||
- 不支持的文档现在会明确报错,而不是抛出令人困惑的提示。
|
||||
- Gemini 工具调用对思考签名和枚举类型的处理已修复,每个工具的超时设置也确实生效。
|
||||
|
||||
@@ -11,30 +11,39 @@ tags:
|
||||
|
||||
# Platform Agents & Drag-Drop Skills
|
||||
|
||||
## Features
|
||||
## Platform agents, local or remote (beta)
|
||||
|
||||
- **Platform agents, local or remote (beta).** Create platform agents like OpenClaw and Hermes and choose, right from the composer, whether they run on your own machine or a remote device. A device switcher in the chat input swaps targets without leaving the conversation, and registered devices are remembered. On desktop, the recent-directories list can be reordered by drag-and-drop, and devices auto-register with a stable ID. Platform agents are in beta; turn them on under Settings → Advanced → Labs.
|
||||
- **Drag-and-drop skills and folders.** Drag a skill from the right panel into the message box and it becomes an action tag. Type `/` mid-sentence to open a menu of every installed skill, from built-ins to Skill Market skills to your own agents'. On desktop, drag a whole folder into chat and it appears as a `@localFile` reference instead of uploading every file inside.
|
||||
- **Bots that send files.** Discord, Telegram, Slack, Feishu, WeChat, LINE, and QQ can now exchange images, video, voice, and files, not just text.
|
||||
- **Page Agent sharing.** Share an agent's working pages with one click.
|
||||
- **Export an agent.** Download any agent's profile as a Markdown file.
|
||||
- **New models.** Claude Opus 4.8, DeepSeek V4 Flash/Pro, Gemini 3.5 Flash, Qwen 3.7 Max, intern-s2-preview, and step-3.7-flash.
|
||||
You can now create platform agents like **OpenClaw** and **Hermes** and choose, right from the composer, whether they run on your own machine or on a remote device. A new device switcher in the chat input lets you swap targets without leaving the conversation, and your registered devices are remembered so you can pick up where you left off.
|
||||
|
||||
## Improvements
|
||||
On desktop, the recent-directories list can be reordered by drag-and-drop, and devices auto-register with a stable ID — set it once, use it everywhere.
|
||||
|
||||
- Non-Markdown documents render as read-only highlighted code, and you can open a thread chat inside the document preview.
|
||||
- Drop files and images directly into a task.
|
||||
- Cost estimates appear alongside replies.
|
||||
- Guided agent creation, and new topics aren't created until you send your first message.
|
||||
- Multi-select delete in the agent documents explorer.
|
||||
- Follow-up suggestions now appear in general chats, not just agent ones.
|
||||
> Platform agents are in beta. Head to **Settings → Advanced → Labs** and turn on the platform-agent flag to try them.
|
||||
|
||||
## Fixes
|
||||
## Drag-and-drop skills & folders
|
||||
|
||||
The chat input got more direct. Drag a skill from the right panel into the message box and it becomes an action tag — no menu hunting. Typing `/` mid-sentence pops up a slash menu of every skill you have installed, from built-ins to ones from the Skill Market or your own agents.
|
||||
|
||||
On desktop, drag a whole folder into chat and it shows up as a `@localFile` reference instead of trying to upload every file inside it.
|
||||
|
||||
## Other improvements
|
||||
|
||||
- **Bots that send files**: Discord, Telegram, Slack, Feishu, WeChat, LINE, and QQ can now exchange images, videos, voice, and files — not just text
|
||||
- **Page Agent sharing**: share an agent's working pages with one click
|
||||
- **Document highlights**: non-markdown documents render as read-only highlighted code; you can open a thread chat inside the document preview
|
||||
- **Tasks with attachments**: drop files and images directly into a task
|
||||
- **Export an agent**: download any agent's profile as a Markdown file
|
||||
- **New models**: Claude Opus 4.8, DeepSeek V4 Flash/Pro, Gemini 3.5 Flash, Qwen 3.7 Max, intern-s2-preview, step-3.7-flash
|
||||
- **Chat cost estimates** shown alongside replies
|
||||
- **Smoother first run**: guided agent creation, and new topics aren't created until you send your first message
|
||||
- **Multi-select delete** in the agent documents explorer
|
||||
- **Follow-up suggestions** in general chats, not just agent ones
|
||||
|
||||
## Fixes & polish
|
||||
|
||||
- Input drafts persist when you switch tabs.
|
||||
- The action bar stays open while you hover the next message.
|
||||
- Action bar stays open while you hover the next message.
|
||||
- Copying a user message no longer leaves escaped Markdown.
|
||||
- On desktop, Cmd +/−/0 shows a zoom indicator, and `~` paths expand correctly.
|
||||
- Empty replies retry instead of finishing silently, and market re-login shows the right modal when a session expires.
|
||||
- Cmd +/−/0 shows a zoom HUD on desktop, and `~` paths expand correctly.
|
||||
- Empty replies retry instead of silently finishing; market OAuth re-login pops the right modal when a session expires.
|
||||
- Topic list pagination is preserved after creating, deleting, or moving topics.
|
||||
- File preview now covers `.cjs`, `.mjs`, and extension-less text files. Bedrock structured output and Gemini diagnostics fixes also landed.
|
||||
- File preview now covers `.cjs`, `.mjs`, and extension-less text files; Bedrock structured output and Gemini diagnostics fixes also landed.
|
||||
|
||||
@@ -9,30 +9,39 @@ tags:
|
||||
|
||||
# 平台智能体与拖拽即用的技能
|
||||
|
||||
## 新功能
|
||||
## 平台智能体:本地或远程(Beta)
|
||||
|
||||
- **平台智能体:本地或远程(Beta)。** 你可以创建 OpenClaw、Hermes 这类平台智能体,并直接在输入框选择它们运行在本机还是某台远程设备上。聊天输入区的执行设备切换器无需离开会话即可切换目标,注册过的设备会被记住。桌面端的「最近目录」支持拖拽重新排序,设备会基于稳定的机器 ID 自动注册。平台智能体目前为 Beta,可在 设置 → 高级 → Labs 中开启。
|
||||
- **拖拽即用的技能与文件夹。** 从右侧面板把技能拖进消息框,它会变成一枚动作标签。在句子中间输入 `/` 会弹出包含全部已安装技能的菜单,涵盖内置、技能市场以及你自己助理提供的技能。桌面端可以把整个文件夹拖进聊天,它会以 `@localFile` 引用形式出现,而不是把里面的每个文件都上传一遍。
|
||||
- **会发文件的 Bot。** Discord、Telegram、Slack、飞书、微信、LINE、QQ 现在都能收发图片、视频、语音和文件,不再仅限于文字。
|
||||
- **Page Agent 共享。** 一键分享智能体的工作页面。
|
||||
- **导出智能体。** 把任意智能体导出成 Markdown 文件。
|
||||
- **新模型。** Claude Opus 4.8、DeepSeek V4 Flash / Pro、Gemini 3.5 Flash、Qwen 3.7 Max、intern-s2-preview、step-3.7-flash。
|
||||
你现在可以创建 **OpenClaw**、**Hermes** 这类平台智能体,并直接在输入框选择它们运行在你的本机还是某台远程设备上。聊天输入区新增了执行设备切换器,无需离开会话就能切换目标;注册过的设备会被记住,下次直接接着用。
|
||||
|
||||
## 体验优化
|
||||
桌面端的「最近目录」支持拖拽重新排序;设备会基于稳定的机器 ID 自动注册——只设置一次,到哪都能用。
|
||||
|
||||
- 非 Markdown 文档以只读的代码高亮形式呈现,并可在文档预览中直接开启线程对话。
|
||||
- 图片和文件可以直接拖进任务。
|
||||
- 回复旁会显示费用预估。
|
||||
- 新建智能体有引导界面,发送第一条消息后才会真正创建话题。
|
||||
- 智能体文档浏览器支持多选删除。
|
||||
- 后续建议现在也会出现在普通对话里,不再仅限于智能体对话。
|
||||
> 平台智能体目前为 Beta。前往 **设置 → 高级 → Labs** 开启对应开关后即可体验。
|
||||
|
||||
## 问题修复
|
||||
## 拖拽即用的技能与文件夹
|
||||
|
||||
聊天输入更直接了。从右侧面板把技能拖进消息框,它会变成一枚动作标签——不用再翻菜单。在句子中间输入 `/` 也会弹出包含全部已安装技能的菜单,无论是内置的、来自技能市场的,还是你自己 Agent 提供的。
|
||||
|
||||
桌面端可以把整个文件夹拖进聊天,它会以 `@localFile` 引用形式出现,而不是把里面的每个文件都上传一遍。
|
||||
|
||||
## 其他改进
|
||||
|
||||
- **会发文件的 Bot**:Discord、Telegram、Slack、飞书、微信、LINE、QQ 现在都能收发图片、视频、语音和文件,不再仅限于文字
|
||||
- **Page Agent 共享**:一键分享智能体的工作页面
|
||||
- **文档高亮**:非 Markdown 文档以只读的代码高亮形式呈现,并可在文档预览中直接开启线程对话
|
||||
- **任务支持附件**:图片和文件可以直接挂到任务上
|
||||
- **导出智能体**:把任意智能体导出成 Markdown 文件
|
||||
- **新模型**:Claude Opus 4.8、DeepSeek V4 Flash / Pro、Gemini 3.5 Flash、Qwen 3.7 Max、intern-s2-preview、step-3.7-flash
|
||||
- **回复旁显示费用预估**
|
||||
- **更顺手的初次体验**:新建智能体有引导界面,发送第一条消息后才会真正创建话题
|
||||
- 智能体文档浏览器支持**多选删除**
|
||||
- **后续建议**现在也会出现在普通对话里,不再仅限于 Agent 对话
|
||||
|
||||
## 修复与打磨
|
||||
|
||||
- 切换标签页后,输入草稿仍会保留。
|
||||
- 鼠标悬停到下一条消息时,操作栏保持展开。
|
||||
- 鼠标悬停到下一条消息时,操作栏不会闪退。
|
||||
- 复制用户消息时不再带出转义的 Markdown 字符。
|
||||
- 桌面端 Cmd +/−/0 会显示缩放提示,`~` 路径也会被正确展开。
|
||||
- 桌面端 Cmd +/−/0 显示缩放 HUD;`~` 路径会被正确展开。
|
||||
- 空回复会自动重试,而不是悄悄结束;技能市场会话过期时会弹出正确的重新登录窗口。
|
||||
- 创建、删除、移动话题后,话题列表的分页状态会被保留。
|
||||
- 桌面端现在可预览 `.cjs`、`.mjs` 和无后缀文本文件;同时修复了 Bedrock 结构化输出与 Gemini 诊断相关问题。
|
||||
|
||||
@@ -12,28 +12,32 @@ tags:
|
||||
|
||||
# Connectors & Connect Agents
|
||||
|
||||
## Features
|
||||
## Connectors: one place to govern every tool
|
||||
|
||||
- **Connectors.** All of an agent's tools (MCP servers, Skill Market skills, built-in tools, and third-party integrations) now sit under one permission layer. For each tool you choose whether it runs freely, pauses for your approval, or stays off. Read-only actions like fetching or listing are detected automatically, so they aren't blocked by mistake.
|
||||
- **Connect Agents.** What you used to create as a "Platform Agent" is now a Connect Agent: a third-party agent that runs on your own device rather than on LobeHub. The execution-device switcher appears for every agent, so you can point any conversation at a specific machine. Agents can call MCP tools through your device with results shown inline, and server-run agents scan the project folder you bind them to, picking up `.agents/skills`, `.claude/skills`, and your `AGENTS.md` / `CLAUDE.md`.
|
||||
- **Token usage in the heatmap.** The activity heatmap added a token-usage mode, so you can switch from how often you chatted to how much each day cost without leaving the page.
|
||||
- **New model: MiniMax M3**, including its video runtime.
|
||||
- **Configurable model routing and starters**, for finer control over which model handles what.
|
||||
Connectors bring all of an agent's tools — MCP servers, Skill Market skills, built-in tools, and third-party integrations — under a single permission layer. For each tool you decide whether it runs freely, pauses for your approval, or stays off, and read-only actions (like fetching or listing) are detected automatically so they aren't blocked by mistake. It's the clearest way yet to see what your agents can reach, and to keep write actions on a short leash.
|
||||
|
||||
## Improvements
|
||||
## Connect Agents, running on your own machine
|
||||
|
||||
- The chat input's "+" menu was reworked with toggle switches and grouped submenus, and pinned tools now have their own section.
|
||||
- Command output renders ANSI colors, so command logs read like your terminal.
|
||||
- Inside a task, the comment box is now a full chat input that can start a new run.
|
||||
- The topic sidebar can group conversations by status, and one click collapses or expands every group.
|
||||
- Cleaner auto-generated topic titles, with better results on DeepSeek and stray Markdown stripped from fallback titles.
|
||||
- The agent document editor renders system docs, defaults new files to `.md`, and keeps typing smooth for Chinese, Japanese, and Korean input.
|
||||
- Delete confirmations have clearer titles and wording across the app.
|
||||
- Agent documents load noticeably faster.
|
||||
What you used to create as a "Platform Agent" is now a **Connect Agent** — a name that better reflects what it is: a third-party agent running on your own device, not on LobeHub. The execution-device switcher now appears for every agent, so you can point any conversation at a specific machine. Agents can call stdio MCP tools directly through your device and their results render inline in chat, and server-run agents now scan the project folder you bind them to — automatically picking up `.agents/skills`, `.claude/skills`, and your `AGENTS.md` / `CLAUDE.md`.
|
||||
|
||||
## Fixes
|
||||
## See where your tokens go
|
||||
|
||||
- Page Agent edits now run in the cloud, so they no longer break when you switch tabs, navigate away, or hit a network blip.
|
||||
- Streaming no longer duplicates after a dropped reconnect, and home-screen starters load more reliably.
|
||||
- Desktop: macOS auto-update signing works again, the updater quits cleanly, CLI tools resolve from your shell `PATH`, and a startup crash is fixed.
|
||||
- The GitHub bot now shows its command-result card.
|
||||
The activity heatmap added a token-usage mode, so you can switch from "how often did I chat" to "how much did each day cost" without leaving the page. The topic sidebar can now group conversations by status, and one click collapses or expands every group at once.
|
||||
|
||||
## New model and chat-input polish
|
||||
|
||||
- **New model**: MiniMax M3, including its video runtime
|
||||
- **Configurable model routing and starters**, for finer control over which model handles what
|
||||
- The chat input's **`+` menu** was reworked with toggle switches and grouped submenus, and app-fixed tools now live in a dedicated **Pinned** section
|
||||
- Command output now **renders ANSI colors**, so `RunCommand` logs read just like your terminal
|
||||
- Inside a task, the comment box is now a full chat input that **kicks off a new run**
|
||||
|
||||
## Improvements and fixes
|
||||
|
||||
- Page-agent edits now run server-side, so they no longer break when you switch tabs, navigate away, or hit a network blip.
|
||||
- Cleaner auto-generated topic titles, with better results on DeepSeek, and stray Markdown tokens stripped from fallback titles.
|
||||
- The agent document editor renders system docs, defaults new files to `.md`, and preserves IME composition for CJK input.
|
||||
- Delete confirmations were restructured for clearer titles and wording across the app.
|
||||
- Desktop: macOS auto-update signing works again, the updater can quit cleanly, CLI tools resolve from your shell `PATH`, and a startup renderer crash is fixed.
|
||||
- Streaming no longer duplicates after a stale reconnect, and home-screen starters load more reliably.
|
||||
- The GitHub bot renders its `runCommand` result card, and agent documents load with noticeably less latency.
|
||||
|
||||
@@ -10,28 +10,32 @@ tags:
|
||||
|
||||
# 连接器与接入助理
|
||||
|
||||
## 新功能
|
||||
## 连接器:统一管控每一个工具
|
||||
|
||||
- **连接器。** 助理的所有工具(MCP 服务器、技能市场的技能、内置工具,以及第三方集成)现在都归入同一套权限体系。你可以为每个工具单独决定:直接放行、先暂停等你批准,还是关闭。获取、列举等只读操作会被自动识别,不会被误拦。
|
||||
- **接入助理。** 过去你创建的「平台 Agent」现在叫接入助理:它是运行在你自己设备上的第三方助理,而非 LobeHub 托管。执行设备切换器现在对所有助理可见,你可以把任意会话指向某台机器。助理能通过你的设备调用 MCP 工具,并把结果内嵌在聊天里呈现;在服务端运行的助理会扫描你绑定的项目目录,自动读取 `.agents/skills`、`.claude/skills` 以及 `AGENTS.md` / `CLAUDE.md`。
|
||||
- **热力图中的 Token 用量。** 活跃度热力图新增 Token 用量模式,无需离开页面,就能从「每天聊了多少次」切到「每天花了多少」。
|
||||
- **新模型:MiniMax M3**,含视频运行时。
|
||||
- **可配置的模型路由与开场白**,更精细地决定由哪个模型处理什么。
|
||||
连接器把助理的所有工具——MCP 服务器、技能市场的技能、内置工具,以及第三方集成——都纳入同一套权限体系。你可以为每个工具单独决定:直接放行、先暂停等你批准,还是干脆关闭;只读类操作(例如获取、列举)会被自动识别,不会被误拦。这是迄今最清晰的方式,让你看清助理能触达哪些能力,也把写入类操作牢牢攥在手里。
|
||||
|
||||
## 体验优化
|
||||
## 接入助理,跑在你自己的设备上
|
||||
|
||||
- 聊天输入框的「+」菜单重做,改用开关切换并分组归类,固定的工具单独成区。
|
||||
- 命令输出会渲染 ANSI 颜色,命令日志读起来和终端里一样。
|
||||
- 在任务里,评论框现在是完整的聊天输入框,可直接发起一次新的运行。
|
||||
- 话题侧边栏可按状态分组,一次点击即可折叠或展开全部分组。
|
||||
过去你创建的「平台 Agent」,现在叫 **接入助理**——这个名字更贴切:它是运行在你自己设备上的第三方助理,而非 LobeHub 托管的助理。执行设备切换器现在对所有助理可见,你可以把任意会话指向某台指定机器。助理能直接通过你的设备调用 stdio MCP 工具,结果会内嵌在聊天里呈现;在服务端运行的助理还会扫描你为它绑定的项目目录,自动读取 `.agents/skills`、`.claude/skills` 以及 `AGENTS.md` / `CLAUDE.md`。
|
||||
|
||||
## 看清 Token 花在哪
|
||||
|
||||
活跃度热力图新增了 Token 用量模式,无需离开页面,就能从「每天聊了多少次」切到「每天花了多少」。话题侧边栏现在支持按状态分组,一次点击即可折叠或展开全部分组。
|
||||
|
||||
## 新模型与输入框打磨
|
||||
|
||||
- **新模型**:MiniMax M3,含视频运行时
|
||||
- **可配置的模型路由与开场白**,更精细地决定由哪个模型处理什么
|
||||
- 聊天输入框的 **`+` 菜单** 重做,改用开关切换并分组归类;应用固定的工具现在收进独立的 **「固定」区**
|
||||
- 命令输出会**渲染 ANSI 颜色**,`RunCommand` 的日志读起来和终端里一样
|
||||
- 在任务里,评论框现在是完整的聊天输入框,可**直接发起一次新的运行**
|
||||
|
||||
## 体验优化与修复
|
||||
|
||||
- Page Agent 的编辑改到服务端执行,切换标签页、离开页面或网络抖动时不再中断。
|
||||
- 自动生成的话题标题更干净,在 DeepSeek 上效果更好,兜底标题里残留的 Markdown 符号也会被清除。
|
||||
- 助理文档编辑器可渲染系统文档,新建文件默认 `.md`,并让中文、日文、韩文输入更顺畅。
|
||||
- 各处删除确认弹窗的标题与文案更清晰。
|
||||
- 助理文档的加载明显更快。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- Page Agent 的编辑改到云端执行,切换标签页、离开页面或网络抖动时不再中断。
|
||||
- 修复重连断开后偶发的重复流式输出,首页开场白加载更稳定。
|
||||
- 桌面端:修复 macOS 自动更新签名、更新时能正常退出、CLI 工具可从 shell `PATH` 解析,以及启动崩溃。
|
||||
- GitHub Bot 现在会显示命令结果卡片。
|
||||
- 助理文档编辑器可渲染系统文档,新建文件默认 `.md`,并保留中日韩输入法(IME)的组合输入。
|
||||
- 各处删除确认弹窗重新梳理了标题与文案,更清晰。
|
||||
- 桌面端:修复 macOS 自动更新签名、更新时能正常退出、CLI 工具可从 shell `PATH` 解析,以及启动时的渲染进程崩溃。
|
||||
- 修复重连后偶发的重复流式输出,首页开场白加载更稳定。
|
||||
- GitHub Bot 能正确渲染 `runCommand` 结果卡片,助理文档的加载延迟明显降低。
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
title: Browser Pairing & Live Run Status
|
||||
description: >-
|
||||
Pair a device straight from your browser, watch every run's time and cost above the chat input, and try a fresh batch of new models and providers.
|
||||
|
||||
tags:
|
||||
- Devices
|
||||
- Connect Agent
|
||||
- Usage
|
||||
- Models
|
||||
---
|
||||
|
||||
# Browser Pairing & Live Run Status
|
||||
|
||||
## Features
|
||||
|
||||
- **Pair a device from your browser.** The Devices page adds a "Via Browser" option: generate a pairing code, exchange it, and the current browser tab becomes a device an agent can run on, with no desktop app required.
|
||||
- **Browse a connected device from chat.** Approved local files open in read-only preview tabs, including HTML rendered inline, even when a file sits outside the current workspace. Agents can list a device's Git worktrees, the branch switcher gained rename and delete actions, and project skills from the device appear in the working sidebar.
|
||||
- **Live run status.** A status tray sits above the chat input while an agent works, showing elapsed time, total tokens, and cost for the current run, along with the cache hit rate.
|
||||
- **New models and providers.** AntGroup and SenseNova (6.7 Flash Lite and U1 Fast) join as new providers, along with updated Longcat models that fetch their list live. Model cards now show each model's knowledge cutoff, and the model tab bar stays visible.
|
||||
|
||||
## Improvements
|
||||
|
||||
- Move many conversations to another agent at once, see status indicators in the topic sidebar, and reach more actions from the topic selector.
|
||||
- Refreshed topic sharing, with a new share page and a one-click share popover.
|
||||
- Add your own custom OAuth MCP connector during setup, and edit or uninstall connectors from their detail panel.
|
||||
- GitHub, Linear, and external links render as link cards in Markdown.
|
||||
- The page editor groups autosave history into 10-minute versions, and pages, agents, groups, and tasks lock while someone else is editing them.
|
||||
- View, rename, and delete skills from the working sidebar.
|
||||
- Desktop: open a new Home tab from the "+" button, drag to reorder tabs, and double-click the tray icon to open the main window. Cloud desktop builds are back.
|
||||
- New signups now land directly in onboarding.
|
||||
|
||||
## Fixes
|
||||
|
||||
- More reliable runs on Codex and Claude Code.
|
||||
- Better structured output on DeepSeek.
|
||||
- Clearer errors when a model list fails to load.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
title: 浏览器配对与实时运行状态
|
||||
description: 直接在浏览器里配对设备,在聊天输入框上方实时查看每次运行的耗时与花费,并用上一批新模型与新服务商。
|
||||
tags:
|
||||
- 设备
|
||||
- 接入助理
|
||||
- 用量
|
||||
- 模型
|
||||
---
|
||||
|
||||
# 浏览器配对与实时运行状态
|
||||
|
||||
## 新功能
|
||||
|
||||
- **在浏览器里配对设备。** 设备页面新增「通过浏览器」方式:生成配对码并完成交换,当前浏览器标签页就成为一台助理可运行的设备,无需安装桌面端。
|
||||
- **在聊天里浏览已连接的设备。** 经批准的本地文件以只读预览标签打开,其中 HTML 可内嵌渲染,即便文件位于当前空间之外也行。助理可以列出设备上的 Git worktree,分支切换器新增了重命名与删除操作,设备项目里的技能也会出现在工作侧边栏。
|
||||
- **实时运行状态。** 助理工作时,聊天输入框上方会出现状态栏,显示本次运行的耗时、Token 总量与花费,并附带缓存命中率。
|
||||
- **新模型与服务商。** 新增蚂蚁百灵(AntGroup)、商汤 SenseNova(6.7 Flash Lite 与 U1 Fast)等服务商,以及可在线拉取列表的 Longcat 模型。模型卡片现在会标注知识截止时间,模型标签栏也常驻显示。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 可一次性把多个会话转移到另一个助理,话题侧边栏新增状态标识,话题选择器也能直达更多操作。
|
||||
- 全新的话题分享体验,带来新的分享页与一键分享气泡。
|
||||
- 可在接入流程中添加自定义的 OAuth MCP 连接器,也能在连接器详情里编辑或卸载。
|
||||
- Markdown 里的 GitHub、Linear 与外部链接会以链接卡片形式呈现。
|
||||
- 文稿编辑器把自动保存历史按 10 分钟合并为一个版本;文稿、助理、群组与任务在他人编辑时会自动加锁。
|
||||
- 可在工作侧边栏里查看、重命名和删除技能。
|
||||
- 桌面端:可从标签栏「+」打开新的首页标签、拖拽重排标签、双击托盘图标打开主窗口;云端桌面版构建已恢复。
|
||||
- 新注册用户现在会直接进入引导流程。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- Codex 与 Claude Code 的运行更稳定。
|
||||
- DeepSeek 结构化输出更好。
|
||||
- 模型列表加载失败时的报错更清晰。
|
||||
@@ -2,15 +2,6 @@
|
||||
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
|
||||
"cloud": [],
|
||||
"community": [
|
||||
{
|
||||
"image": "/blog/assets6aebb8b5341dc37202ffecb363b9b906.webp",
|
||||
"id": "2026-06-15-browser-pairing",
|
||||
"date": "2026-06-15",
|
||||
"versionRange": [
|
||||
"2.2.3",
|
||||
"2.2.4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"image": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp",
|
||||
"id": "2026-06-08-connectors",
|
||||
|
||||
+11
-11
@@ -252,20 +252,20 @@
|
||||
"channel.wechatTips": "Please update WeChat to the latest version and restart it. The ClawBot plugin is in gradual rollout, so check Settings > Plugins to confirm access.",
|
||||
"channel.wechatUserId": "WeChat User ID",
|
||||
"channel.wechatUserIdHint": "WeChat account identifier returned by the authorization flow.",
|
||||
"transfer.button": "Move",
|
||||
"transfer.confirm.botChannels": "Bot channel connections may need to be refreshed after moving",
|
||||
"transfer.button": "Transfer",
|
||||
"transfer.confirm.botChannels": "Bot channel connections may need to be refreshed after transfer",
|
||||
"transfer.confirm.chatGroups": "Multi-agent group associations will be removed",
|
||||
"transfer.confirm.desc": "This will move the agent and all associated data (topics, messages, files, etc.) to the target workspace.",
|
||||
"transfer.confirm.plugins": "Custom plugins may not be available in the target workspace",
|
||||
"transfer.confirm.title": "Move Agent",
|
||||
"transfer.confirm.warning": "Some links won't move:",
|
||||
"transfer.copyTo": "Copy to...",
|
||||
"transfer.desc": "Move this Agent to another Workspace or your personal account.",
|
||||
"transfer.error": "Failed to move agent",
|
||||
"transfer.confirm.title": "Transfer Agent",
|
||||
"transfer.confirm.warning": "Some features don't transfer:",
|
||||
"transfer.copyTo": "Copy To",
|
||||
"transfer.desc": "Transfer this agent to another workspace or your personal account.",
|
||||
"transfer.error": "Failed to transfer agent",
|
||||
"transfer.personalAccount": "Personal Account",
|
||||
"transfer.searchWorkspace": "Search workspaces...",
|
||||
"transfer.selectTarget": "Move Agent to",
|
||||
"transfer.success": "Agent moved successfully",
|
||||
"transfer.title": "Move",
|
||||
"transfer.transferTo": "Move to..."
|
||||
"transfer.selectTarget": "Transfer Agent To",
|
||||
"transfer.success": "Agent transferred successfully",
|
||||
"transfer.title": "Transfer",
|
||||
"transfer.transferTo": "Transfer To"
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"builtinCopilot": "Built-in Copilot",
|
||||
"chatList.expandMessage": "Expand Message",
|
||||
"chatList.longMessageDetail": "View Details",
|
||||
"chatList.refreshing": "Fetching latest messages...",
|
||||
"chatMode.agent": "Agent",
|
||||
"chatMode.agentCap.env": "Runtime env",
|
||||
"chatMode.agentCap.files": "File access",
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
{
|
||||
"fleet.addColumn": "Add column",
|
||||
"fleet.allShown": "All running tasks are shown",
|
||||
"fleet.backToHome": "Back to home",
|
||||
"fleet.closeColumn": "Close column",
|
||||
"fleet.createTask": "Create task",
|
||||
"fleet.empty": "No tasks open",
|
||||
"fleet.emptyDesc": "Pick a running task on the left, or use + to add a column.",
|
||||
"fleet.noRunningTasks": "No running tasks",
|
||||
"fleet.openInChat": "Open in chat",
|
||||
"fleet.reply": "Reply",
|
||||
"fleet.runningTasks": "Running Tasks",
|
||||
"fleet.status.idle": "Idle",
|
||||
"fleet.status.paused": "Paused",
|
||||
"fleet.status.running": "Running",
|
||||
"fleet.status.scheduled": "Scheduled",
|
||||
"fleet.tooltip": "View all agents side by side",
|
||||
"gateway.description": "Description",
|
||||
"gateway.descriptionPlaceholder": "Optional",
|
||||
"gateway.deviceName": "Device Name",
|
||||
@@ -42,7 +26,6 @@
|
||||
"navigation.memoryIdentities": "Memory - Identities",
|
||||
"navigation.memoryPreferences": "Memory - Preferences",
|
||||
"navigation.noPages": "No pages yet",
|
||||
"navigation.observation": "Observation Mode",
|
||||
"navigation.onboarding": "Onboarding",
|
||||
"navigation.page": "Page",
|
||||
"navigation.pages": "Pages",
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
"features.agentSelfIteration.title": "Agent Self-iteration",
|
||||
"features.assistantMessageGroup.desc": "Group agent messages and their tool call results together for display",
|
||||
"features.assistantMessageGroup.title": "Agent Message Grouping",
|
||||
"features.fleet.desc": "Show the Fleet entry in the title bar — a side-by-side dashboard of all running tasks across your agents.",
|
||||
"features.fleet.title": "Fleet View",
|
||||
"features.groupChat.desc": "Enable multi-agent group chat coordination.",
|
||||
"features.groupChat.title": "Group Chat (Multi-Agent)",
|
||||
"features.imessage.desc": "Connect agents to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
|
||||
|
||||
+32
-32
@@ -120,16 +120,16 @@
|
||||
"agentDocuments.overwriteConfirm.title": "Overwrite existing documents?",
|
||||
"agentDocuments.overwriteConfirm.warning": "Existing documents with the same filename will be replaced.",
|
||||
"agentDocuments.title": "Agent Documents",
|
||||
"agentImport.action": "Copy to Workspace...",
|
||||
"agentImport.description": "Create an independent copy in a Workspace. The original Agent stays in your personal account.",
|
||||
"agentImport.failed": "Failed to copy agent.",
|
||||
"agentImport.action": "Import to workspace…",
|
||||
"agentImport.description": "Fork a copy of this agent into one of your workspaces. The original stays in your personal space — no sync after import.",
|
||||
"agentImport.failed": "Failed to import agent.",
|
||||
"agentImport.modal.configIncluded": "Agent configuration is copied by default.",
|
||||
"agentImport.modal.confirm": "Copy",
|
||||
"agentImport.modal.confirm": "Import",
|
||||
"agentImport.modal.includeHistory": "Copy topics and messages",
|
||||
"agentImport.modal.includeHistoryDesc": "Optional. Copies this agent’s conversation history into the new agent.",
|
||||
"agentImport.modal.knowledgeNotice": "Knowledge bindings and files are not copied yet.",
|
||||
"agentImport.success": "Agent copied to {{name}}.",
|
||||
"agentImport.title": "Copy to Workspace",
|
||||
"agentImport.success": "Agent imported to {{name}}.",
|
||||
"agentImport.title": "Import to workspace",
|
||||
"agentInfoDescription.basic.avatar": "Avatar",
|
||||
"agentInfoDescription.basic.description": "Description",
|
||||
"agentInfoDescription.basic.name": "Name",
|
||||
@@ -893,9 +893,9 @@
|
||||
"storage.actions.copyAgentGroups.button": "Copy To",
|
||||
"storage.actions.copyAgentGroups.desc": "Copy agent groups and their member agents into another workspace or personal account.",
|
||||
"storage.actions.copyAgentGroups.title": "Agent Groups Copy",
|
||||
"storage.actions.copyLobeAI.button": "Copy to...",
|
||||
"storage.actions.copyLobeAI.desc": "Keep the originals and create independent copies in another Workspace or your personal account. Topics and messages are optional.",
|
||||
"storage.actions.copyLobeAI.title": "Copy Agents",
|
||||
"storage.actions.copyLobeAI.button": "Copy To",
|
||||
"storage.actions.copyLobeAI.desc": "Copy agents, including LobeAI, into another workspace or personal account. Topics and messages are optional.",
|
||||
"storage.actions.copyLobeAI.title": "Agents Copy",
|
||||
"storage.actions.export.button": "Export",
|
||||
"storage.actions.export.exportType.agent": "Export Agent Settings",
|
||||
"storage.actions.export.exportType.agentWithMessage": "Export Agent and Messages",
|
||||
@@ -907,12 +907,12 @@
|
||||
"storage.actions.import.button": "Import",
|
||||
"storage.actions.import.title": "Import Data",
|
||||
"storage.actions.title": "Advanced Operations",
|
||||
"storage.actions.transfer.button": "Move to...",
|
||||
"storage.actions.transfer.desc": "Move agents and their data to another Workspace or your personal account. The originals leave the current space. LobeAI cannot be moved; copy it instead.",
|
||||
"storage.actions.transfer.title": "Move Agents",
|
||||
"storage.actions.transferAgentGroups.button": "Move to...",
|
||||
"storage.actions.transferAgentGroups.desc": "Move groups, member Agents, and group conversation data to another Workspace or your personal account.",
|
||||
"storage.actions.transferAgentGroups.title": "Move Groups",
|
||||
"storage.actions.transfer.button": "Transfer To",
|
||||
"storage.actions.transfer.desc": "Move agents and their data to a workspace you have access to. LobeAI, the default inbox Agent, cannot be transferred; use Copy Agents to copy it to a workspace or personal account instead.",
|
||||
"storage.actions.transfer.title": "Agents Migration",
|
||||
"storage.actions.transferAgentGroups.button": "Transfer To",
|
||||
"storage.actions.transferAgentGroups.desc": "Move agent groups, their members, and group conversation data to a workspace you have access to.",
|
||||
"storage.actions.transferAgentGroups.title": "Agent Groups Migration",
|
||||
"storage.desc": "Current storage usage in the browser",
|
||||
"storage.embeddings.used": "Vector Storage",
|
||||
"storage.migration.title": "Data Migration",
|
||||
@@ -1171,6 +1171,7 @@
|
||||
"tools.builtins.uninstallConfirm.desc": "Are you sure you want to uninstall {{name}}? This skill will be removed from the current agent.",
|
||||
"tools.builtins.uninstallConfirm.title": "Uninstall {{name}}",
|
||||
"tools.builtins.uninstalled": "Uninstalled",
|
||||
"tools.disabled": "The current model does not support function calls and cannot use the skill",
|
||||
"tools.composio.addServer": "Add Server",
|
||||
"tools.composio.authCompleted": "Authentication Completed",
|
||||
"tools.composio.authFailed": "Authentication Failed",
|
||||
@@ -1180,6 +1181,9 @@
|
||||
"tools.composio.disconnect": "Disconnect",
|
||||
"tools.composio.disconnected": "Disconnected",
|
||||
"tools.composio.error": "Error",
|
||||
"tools.composio.remove": "Remove",
|
||||
"tools.composio.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
|
||||
"tools.composio.removeConfirm.title": "Remove {{name}}?",
|
||||
"tools.composio.groupName": "Composio Tools",
|
||||
"tools.composio.manage": "Manage Composio",
|
||||
"tools.composio.manageTitle": "Manage Composio Integration",
|
||||
@@ -1188,9 +1192,6 @@
|
||||
"tools.composio.oauthRequired": "Please complete OAuth authentication in the new window",
|
||||
"tools.composio.pendingAuth": "Pending Authentication",
|
||||
"tools.composio.reauthorize": "Re-authorize",
|
||||
"tools.composio.remove": "Remove",
|
||||
"tools.composio.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
|
||||
"tools.composio.removeConfirm.title": "Remove {{name}}?",
|
||||
"tools.composio.serverCreated": "Server created successfully",
|
||||
"tools.composio.serverCreatedFailed": "Failed to create server",
|
||||
"tools.composio.serverRemoved": "Server removed",
|
||||
@@ -1243,7 +1244,6 @@
|
||||
"tools.composio.servers.zendesk.readme": "Integrate with Zendesk to manage support tickets and customer interactions. Create, update, and track support requests, access customer data, and streamline your support operations.",
|
||||
"tools.composio.tools": "tools",
|
||||
"tools.composio.verifyAuth": "I have completed authentication",
|
||||
"tools.disabled": "The current model does not support function calls and cannot use the skill",
|
||||
"tools.lobehubSkill.authorize": "Authorize",
|
||||
"tools.lobehubSkill.connect": "Connect",
|
||||
"tools.lobehubSkill.connected": "Connected",
|
||||
@@ -1598,9 +1598,9 @@
|
||||
"workspace.general.copyAgentGroups.modal.untitledGroup": "Untitled Agent Group",
|
||||
"workspace.general.copyLobeAI.modal.back": "Back",
|
||||
"workspace.general.copyLobeAI.modal.continue": "Continue",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.config.desc": "Required. Copies the model, prompt, tools, and Agent profile into a new Agent.",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.config.desc": "Required. Copies the model, prompt, tools, and Agent profile.",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.config.title": "Agent configuration",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.history.desc": "Optional. Copies selected Agents’ topics and messages into the new Agents.",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.history.desc": "Optional. Copies selected agents’ topics and messages into the new agents.",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.history.title": "Topics and messages",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.reason": "Not supported yet. Reconnect them in the target workspace or personal account after copying.",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.title": "Knowledge bases and files",
|
||||
@@ -1612,14 +1612,14 @@
|
||||
"workspace.general.copyLobeAI.modal.defaultInboxTitle": "LobeAI",
|
||||
"workspace.general.copyLobeAI.modal.failed": "Failed to copy agents",
|
||||
"workspace.general.copyLobeAI.modal.includeHistory": "Copy topics and messages",
|
||||
"workspace.general.copyLobeAI.modal.includeHistoryDesc": "Optional. Copies selected Agents’ conversation history into the new Agents.",
|
||||
"workspace.general.copyLobeAI.modal.includeHistoryDesc": "Optional. Copies selected agents’ conversation history into the new agents.",
|
||||
"workspace.general.copyLobeAI.modal.loadFailed": "Failed to load agents",
|
||||
"workspace.general.copyLobeAI.modal.noAgents": "No agents available to copy",
|
||||
"workspace.general.copyLobeAI.modal.selectAgents": "Select Agents to copy. Originals stay where they are.",
|
||||
"workspace.general.copyLobeAI.modal.selectAgents": "Select agents to copy.",
|
||||
"workspace.general.copyLobeAI.modal.selectPlaceholder": "Select workspace or personal account...",
|
||||
"workspace.general.copyLobeAI.modal.selectTarget": "Choose where to create the copies. The originals stay where they are.",
|
||||
"workspace.general.copyLobeAI.modal.selectTarget": "Choose the target workspace or personal account. Agent configuration is copied by default.",
|
||||
"workspace.general.copyLobeAI.modal.selected": "selected",
|
||||
"workspace.general.copyLobeAI.modal.selectedAgent": "This Agent will be copied. The original stays where it is.",
|
||||
"workspace.general.copyLobeAI.modal.selectedAgent": "Agent to copy.",
|
||||
"workspace.general.copyLobeAI.modal.success": "{{count}} agent(s) copied",
|
||||
"workspace.general.copyLobeAI.modal.title": "Copy Agents",
|
||||
"workspace.general.copyLobeAI.modal.untitledAgent": "Untitled Agent",
|
||||
@@ -1695,17 +1695,17 @@
|
||||
"workspace.general.transferAgentGroups.modal.untitledGroup": "Untitled Agent Group",
|
||||
"workspace.general.transferAgents.modal.back": "Back",
|
||||
"workspace.general.transferAgents.modal.continue": "Continue",
|
||||
"workspace.general.transferAgents.modal.failed": "Failed to move agents",
|
||||
"workspace.general.transferAgents.modal.failed": "Failed to transfer agents",
|
||||
"workspace.general.transferAgents.modal.loadFailed": "Failed to load agents",
|
||||
"workspace.general.transferAgents.modal.noAgents": "No agents in this workspace",
|
||||
"workspace.general.transferAgents.modal.selectAgents": "Select Agents to move to {{target}}.",
|
||||
"workspace.general.transferAgents.modal.selectAgents": "Select agents to transfer to {{target}}.",
|
||||
"workspace.general.transferAgents.modal.selectPlaceholder": "Select workspace or personal account...",
|
||||
"workspace.general.transferAgents.modal.selectTarget": "Choose where to move the Agents. They will leave the current space.",
|
||||
"workspace.general.transferAgents.modal.selectTarget": "Choose a workspace or personal account to transfer agents to.",
|
||||
"workspace.general.transferAgents.modal.selected": "selected",
|
||||
"workspace.general.transferAgents.modal.selectedAgent": "This Agent will move to {{target}} and leave the current space.",
|
||||
"workspace.general.transferAgents.modal.success": "{{count}} agent(s) moved",
|
||||
"workspace.general.transferAgents.modal.title": "Move Agents",
|
||||
"workspace.general.transferAgents.modal.transfer": "Move {{count}} agent(s)",
|
||||
"workspace.general.transferAgents.modal.selectedAgent": "Agent to transfer to {{target}}.",
|
||||
"workspace.general.transferAgents.modal.success": "{{count}} agent(s) transferred successfully",
|
||||
"workspace.general.transferAgents.modal.title": "Transfer Agents",
|
||||
"workspace.general.transferAgents.modal.transfer": "Transfer {{count}} agent(s)",
|
||||
"workspace.general.transferAgents.modal.warning": "Custom plugins may not be available and multi-agent group associations will be removed.",
|
||||
"workspace.general.transferAgents.personalAccount": "Personal Account",
|
||||
"workspace.general.transferPrimary.cta": "Transfer Primary Owner",
|
||||
|
||||
+15
-15
@@ -252,20 +252,20 @@
|
||||
"channel.wechatTips": "请将微信更新至最新版本并重新启动。ClawBot 插件正在逐步推广,请前往设置 > 插件确认是否已获得访问权限。",
|
||||
"channel.wechatUserId": "微信用户 ID",
|
||||
"channel.wechatUserIdHint": "通过授权流程返回的微信账号标识符。",
|
||||
"transfer.button": "移动",
|
||||
"transfer.confirm.botChannels": "移动后可能需要重新刷新机器人频道连接",
|
||||
"transfer.confirm.chatGroups": "多助理群组关联将被移除",
|
||||
"transfer.confirm.desc": "这会把助理及相关数据(话题、消息、文件等)移动到目标空间。",
|
||||
"transfer.confirm.plugins": "自定义技能可能无法在目标空间使用",
|
||||
"transfer.confirm.title": "移动助理",
|
||||
"transfer.confirm.warning": "部分关联不会一起移动:",
|
||||
"transfer.copyTo": "复制到...",
|
||||
"transfer.desc": "将此助理移动到另一个空间或您的个人账户。",
|
||||
"transfer.error": "移动助理失败",
|
||||
"transfer.button": "转移",
|
||||
"transfer.confirm.botChannels": "转移后可能需要刷新机器人频道连接",
|
||||
"transfer.confirm.chatGroups": "多代理组关联将被移除",
|
||||
"transfer.confirm.desc": "这将把代理及所有相关数据(主题、消息、文件等)转移到目标工作区。",
|
||||
"transfer.confirm.plugins": "自定义插件可能无法在目标工作区使用",
|
||||
"transfer.confirm.title": "转移代理",
|
||||
"transfer.confirm.warning": "以下功能无法转移:",
|
||||
"transfer.copyTo": "复制到",
|
||||
"transfer.desc": "将此代理转移到另一个工作区或您的个人账户。",
|
||||
"transfer.error": "代理转移失败",
|
||||
"transfer.personalAccount": "个人账户",
|
||||
"transfer.searchWorkspace": "搜索空间...",
|
||||
"transfer.selectTarget": "将助理移动到",
|
||||
"transfer.success": "助理已移动",
|
||||
"transfer.title": "移动",
|
||||
"transfer.transferTo": "移动到..."
|
||||
"transfer.searchWorkspace": "搜索工作区...",
|
||||
"transfer.selectTarget": "将代理转移到",
|
||||
"transfer.success": "代理转移成功",
|
||||
"transfer.title": "转移",
|
||||
"transfer.transferTo": "转移到"
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"builtinCopilot": "内置 Copilot",
|
||||
"chatList.expandMessage": "展开消息",
|
||||
"chatList.longMessageDetail": "查看详情",
|
||||
"chatList.refreshing": "正在获取最新消息...",
|
||||
"chatMode.agent": "智能",
|
||||
"chatMode.agentCap.env": "运行环境",
|
||||
"chatMode.agentCap.files": "文件访问",
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
{
|
||||
"fleet.addColumn": "添加一列",
|
||||
"fleet.allShown": "所有运行中的任务都已展示",
|
||||
"fleet.backToHome": "返回首页",
|
||||
"fleet.closeColumn": "关闭此列",
|
||||
"fleet.createTask": "创建任务",
|
||||
"fleet.empty": "暂无打开的任务",
|
||||
"fleet.emptyDesc": "从左侧选择一个运行中的任务,或点击 + 添加一列。",
|
||||
"fleet.noRunningTasks": "暂无运行中的任务",
|
||||
"fleet.openInChat": "在对话中打开",
|
||||
"fleet.reply": "回复",
|
||||
"fleet.runningTasks": "运行中的任务",
|
||||
"fleet.status.idle": "空闲",
|
||||
"fleet.status.paused": "已暂停",
|
||||
"fleet.status.running": "运行中",
|
||||
"fleet.status.scheduled": "已排期",
|
||||
"fleet.tooltip": "并排查看所有助手",
|
||||
"gateway.description": "描述",
|
||||
"gateway.descriptionPlaceholder": "可选",
|
||||
"gateway.deviceName": "设备名称",
|
||||
@@ -42,7 +26,6 @@
|
||||
"navigation.memoryIdentities": "记忆 - 身份",
|
||||
"navigation.memoryPreferences": "记忆 - 偏好",
|
||||
"navigation.noPages": "暂无页面",
|
||||
"navigation.observation": "观测模式",
|
||||
"navigation.onboarding": "引导",
|
||||
"navigation.page": "文稿",
|
||||
"navigation.pages": "文稿",
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
"features.agentSelfIteration.title": "Agent 自我迭代",
|
||||
"features.assistantMessageGroup.desc": "将代理消息及其工具调用结果组合在一起显示",
|
||||
"features.assistantMessageGroup.title": "代理消息分组",
|
||||
"features.fleet.desc": "在标题栏显示 Fleet 入口——把当前账号下所有运行中的任务并排展示的看板。",
|
||||
"features.fleet.title": "Fleet 并排视图",
|
||||
"features.groupChat.desc": "启用多代理协同群聊功能。",
|
||||
"features.groupChat.title": "群聊(多代理)",
|
||||
"features.imessage.desc": "通过本地 LobeHub 桌面 BlueBubbles 桥接器将代理连接到 iMessage。",
|
||||
|
||||
+51
-51
@@ -120,16 +120,16 @@
|
||||
"agentDocuments.overwriteConfirm.title": "要覆盖现有文档吗?",
|
||||
"agentDocuments.overwriteConfirm.warning": "同名文档将被替换。",
|
||||
"agentDocuments.title": "助理文档",
|
||||
"agentImport.action": "复制到空间...",
|
||||
"agentImport.description": "在空间中创建一份独立副本。原助理会保留在个人账户中。",
|
||||
"agentImport.failed": "复制助理失败。",
|
||||
"agentImport.modal.configIncluded": "助理配置默认会被复制。",
|
||||
"agentImport.modal.confirm": "复制",
|
||||
"agentImport.modal.includeHistory": "复制话题和消息",
|
||||
"agentImport.modal.includeHistoryDesc": "可选。将此助理的对话历史复制到新助理中。",
|
||||
"agentImport.action": "导入到工作区…",
|
||||
"agentImport.description": "将此代理的副本分叉到您的一个工作区中。原始代理保留在您的个人空间中——导入后不会同步。",
|
||||
"agentImport.failed": "导入代理失败。",
|
||||
"agentImport.modal.configIncluded": "代理配置默认会被复制。",
|
||||
"agentImport.modal.confirm": "导入",
|
||||
"agentImport.modal.includeHistory": "复制主题和消息",
|
||||
"agentImport.modal.includeHistoryDesc": "可选。将此代理的对话历史复制到新代理中。",
|
||||
"agentImport.modal.knowledgeNotice": "知识绑定和文件尚未复制。",
|
||||
"agentImport.success": "助理已复制到 {{name}}。",
|
||||
"agentImport.title": "复制到空间",
|
||||
"agentImport.success": "代理已导入到 {{name}}。",
|
||||
"agentImport.title": "导入到工作区",
|
||||
"agentInfoDescription.basic.avatar": "头像",
|
||||
"agentInfoDescription.basic.description": "描述",
|
||||
"agentInfoDescription.basic.name": "名称",
|
||||
@@ -893,9 +893,9 @@
|
||||
"storage.actions.copyAgentGroups.button": "复制到",
|
||||
"storage.actions.copyAgentGroups.desc": "将代理组及其成员代理复制到另一个工作区或个人账户。",
|
||||
"storage.actions.copyAgentGroups.title": "代理组复制",
|
||||
"storage.actions.copyLobeAI.button": "复制到...",
|
||||
"storage.actions.copyLobeAI.desc": "保留原助理,并在另一个空间或个人账户中创建独立副本。话题和消息为可选项。",
|
||||
"storage.actions.copyLobeAI.title": "复制助理",
|
||||
"storage.actions.copyLobeAI.button": "复制到",
|
||||
"storage.actions.copyLobeAI.desc": "将代理(包括 LobeAI)复制到另一个工作区或个人账户。主题和消息为可选项。",
|
||||
"storage.actions.copyLobeAI.title": "代理复制",
|
||||
"storage.actions.export.button": "导出",
|
||||
"storage.actions.export.exportType.agent": "导出助理设定",
|
||||
"storage.actions.export.exportType.agentWithMessage": "导出助理和消息",
|
||||
@@ -907,12 +907,12 @@
|
||||
"storage.actions.import.button": "导入",
|
||||
"storage.actions.import.title": "导入数据",
|
||||
"storage.actions.title": "高级操作",
|
||||
"storage.actions.transfer.button": "移动到...",
|
||||
"storage.actions.transfer.desc": "将助理及其数据移动到另一个空间或个人账户。原助理会离开当前空间。LobeAI 无法移动,请改用复制。",
|
||||
"storage.actions.transfer.title": "移动助理",
|
||||
"storage.actions.transferAgentGroups.button": "移动到...",
|
||||
"storage.actions.transferAgentGroups.desc": "将群组、成员助理和群组对话数据移动到另一个空间或个人账户。",
|
||||
"storage.actions.transferAgentGroups.title": "移动群组",
|
||||
"storage.actions.transfer.button": "转移到",
|
||||
"storage.actions.transfer.desc": "将代理及其数据转移到您有权限访问的工作区。LobeAI(默认收件箱代理)无法转移;请使用“复制代理”将其复制到工作区或个人账户。",
|
||||
"storage.actions.transfer.title": "代理迁移",
|
||||
"storage.actions.transferAgentGroups.button": "转移到",
|
||||
"storage.actions.transferAgentGroups.desc": "将代理组、其成员及组对话数据转移到您有权限访问的工作区。",
|
||||
"storage.actions.transferAgentGroups.title": "代理组迁移",
|
||||
"storage.desc": "当前浏览器中的存储用量",
|
||||
"storage.embeddings.used": "向量存储",
|
||||
"storage.migration.title": "数据迁移",
|
||||
@@ -1171,6 +1171,7 @@
|
||||
"tools.builtins.uninstallConfirm.desc": "确定要卸载 {{name}} 吗?此技能将从当前助手中移除。",
|
||||
"tools.builtins.uninstallConfirm.title": "卸载 {{name}}",
|
||||
"tools.builtins.uninstalled": "已卸载",
|
||||
"tools.disabled": "当前模型不支持函数调用,无法使用技能",
|
||||
"tools.composio.addServer": "添加服务器",
|
||||
"tools.composio.authCompleted": "认证完成",
|
||||
"tools.composio.authFailed": "认证失败",
|
||||
@@ -1180,6 +1181,9 @@
|
||||
"tools.composio.disconnect": "断开连接",
|
||||
"tools.composio.disconnected": "已断开连接",
|
||||
"tools.composio.error": "错误",
|
||||
"tools.composio.remove": "移除",
|
||||
"tools.composio.removeConfirm.desc": "{{name}} 将从您的已连接服务中永久移除,此操作不可撤销。",
|
||||
"tools.composio.removeConfirm.title": "移除 {{name}}?",
|
||||
"tools.composio.groupName": "Composio 工具",
|
||||
"tools.composio.manage": "管理 Composio",
|
||||
"tools.composio.manageTitle": "管理 Composio 集成",
|
||||
@@ -1188,9 +1192,6 @@
|
||||
"tools.composio.oauthRequired": "请在新窗口中完成 OAuth 认证",
|
||||
"tools.composio.pendingAuth": "待认证",
|
||||
"tools.composio.reauthorize": "重新授权",
|
||||
"tools.composio.remove": "移除",
|
||||
"tools.composio.removeConfirm.desc": "{{name}} 将从您的已连接服务中永久移除,此操作不可撤销。",
|
||||
"tools.composio.removeConfirm.title": "移除 {{name}}?",
|
||||
"tools.composio.serverCreated": "服务器创建成功",
|
||||
"tools.composio.serverCreatedFailed": "服务器创建失败",
|
||||
"tools.composio.serverRemoved": "服务器已删除",
|
||||
@@ -1243,7 +1244,6 @@
|
||||
"tools.composio.servers.zendesk.readme": "集成 Zendesk 以管理支持工单和客户互动。创建、更新和跟踪支持请求,访问客户数据,并优化您的客服流程。",
|
||||
"tools.composio.tools": "个工具",
|
||||
"tools.composio.verifyAuth": "我已完成认证",
|
||||
"tools.disabled": "当前模型不支持函数调用,无法使用技能",
|
||||
"tools.lobehubSkill.authorize": "授权",
|
||||
"tools.lobehubSkill.connect": "连接",
|
||||
"tools.lobehubSkill.connected": "已连接",
|
||||
@@ -1598,31 +1598,31 @@
|
||||
"workspace.general.copyAgentGroups.modal.untitledGroup": "未命名代理组",
|
||||
"workspace.general.copyLobeAI.modal.back": "返回",
|
||||
"workspace.general.copyLobeAI.modal.continue": "继续",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.config.desc": "必选。将模型、提示、工具和助理档案复制到新助理中。",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.config.title": "助理配置",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.history.desc": "可选。将所选助理的话题和消息复制到新助理中。",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.history.title": "话题和消息",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.reason": "尚不支持。复制后可在目标空间或个人账户中重新连接。",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.config.desc": "必选。复制模型、提示、工具和代理配置文件。",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.config.title": "代理配置",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.history.desc": "可选。将选定代理的主题和消息复制到新代理中。",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.history.title": "主题和消息",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.reason": "尚不支持。在复制后重新连接到目标工作区或个人账户。",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.title": "知识库和文件",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.optional": "可选",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.required": "默认选中",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.title": "复制选项",
|
||||
"workspace.general.copyLobeAI.modal.copyOptions.unsupported": "不可用",
|
||||
"workspace.general.copyLobeAI.modal.create": "复制 {{count}} 个助理",
|
||||
"workspace.general.copyLobeAI.modal.create": "复制 {{count}} 个代理",
|
||||
"workspace.general.copyLobeAI.modal.defaultInboxTitle": "LobeAI",
|
||||
"workspace.general.copyLobeAI.modal.failed": "复制助理失败",
|
||||
"workspace.general.copyLobeAI.modal.includeHistory": "复制话题和消息",
|
||||
"workspace.general.copyLobeAI.modal.includeHistoryDesc": "可选。将所选助理的对话历史复制到新助理中。",
|
||||
"workspace.general.copyLobeAI.modal.loadFailed": "加载助理失败",
|
||||
"workspace.general.copyLobeAI.modal.noAgents": "没有可复制的助理",
|
||||
"workspace.general.copyLobeAI.modal.selectAgents": "选择要复制的助理。原助理会保留在原位置。",
|
||||
"workspace.general.copyLobeAI.modal.selectPlaceholder": "选择空间或个人账户...",
|
||||
"workspace.general.copyLobeAI.modal.selectTarget": "选择副本创建位置。原助理会保留在原位置。",
|
||||
"workspace.general.copyLobeAI.modal.failed": "复制代理失败",
|
||||
"workspace.general.copyLobeAI.modal.includeHistory": "复制主题和消息",
|
||||
"workspace.general.copyLobeAI.modal.includeHistoryDesc": "可选。将选定代理的对话历史复制到新代理中。",
|
||||
"workspace.general.copyLobeAI.modal.loadFailed": "加载代理失败",
|
||||
"workspace.general.copyLobeAI.modal.noAgents": "没有可复制的代理",
|
||||
"workspace.general.copyLobeAI.modal.selectAgents": "选择要复制的代理。",
|
||||
"workspace.general.copyLobeAI.modal.selectPlaceholder": "选择工作区或个人账户...",
|
||||
"workspace.general.copyLobeAI.modal.selectTarget": "选择目标工作区或个人账户。代理配置默认会被复制。",
|
||||
"workspace.general.copyLobeAI.modal.selected": "已选",
|
||||
"workspace.general.copyLobeAI.modal.selectedAgent": "此助理会被复制,原助理会保留在原位置。",
|
||||
"workspace.general.copyLobeAI.modal.success": "{{count}} 个助理已复制",
|
||||
"workspace.general.copyLobeAI.modal.title": "复制助理",
|
||||
"workspace.general.copyLobeAI.modal.untitledAgent": "未命名助理",
|
||||
"workspace.general.copyLobeAI.modal.selectedAgent": "要复制的代理。",
|
||||
"workspace.general.copyLobeAI.modal.success": "{{count}} 个代理已复制",
|
||||
"workspace.general.copyLobeAI.modal.title": "复制代理",
|
||||
"workspace.general.copyLobeAI.modal.untitledAgent": "未命名代理",
|
||||
"workspace.general.delete.confirm.content": "此操作无法撤销。输入工作区名称 \"{{name}}\" 以确认。",
|
||||
"workspace.general.delete.confirm.continue": "继续",
|
||||
"workspace.general.delete.confirm.mismatch": "名称不匹配。删除已中止。",
|
||||
@@ -1695,18 +1695,18 @@
|
||||
"workspace.general.transferAgentGroups.modal.untitledGroup": "未命名代理组",
|
||||
"workspace.general.transferAgents.modal.back": "返回",
|
||||
"workspace.general.transferAgents.modal.continue": "继续",
|
||||
"workspace.general.transferAgents.modal.failed": "移动助理失败",
|
||||
"workspace.general.transferAgents.modal.loadFailed": "加载助理失败",
|
||||
"workspace.general.transferAgents.modal.noAgents": "此空间中没有助理",
|
||||
"workspace.general.transferAgents.modal.selectAgents": "选择要移动到 {{target}} 的助理。",
|
||||
"workspace.general.transferAgents.modal.selectPlaceholder": "选择空间或个人账户...",
|
||||
"workspace.general.transferAgents.modal.selectTarget": "选择助理移动到哪里。原助理会离开当前空间。",
|
||||
"workspace.general.transferAgents.modal.failed": "转移代理失败",
|
||||
"workspace.general.transferAgents.modal.loadFailed": "加载代理失败",
|
||||
"workspace.general.transferAgents.modal.noAgents": "此工作区中没有代理",
|
||||
"workspace.general.transferAgents.modal.selectAgents": "选择要转移到 {{target}} 的代理。",
|
||||
"workspace.general.transferAgents.modal.selectPlaceholder": "选择工作区或个人账户...",
|
||||
"workspace.general.transferAgents.modal.selectTarget": "选择一个工作区或个人账户以转移代理。",
|
||||
"workspace.general.transferAgents.modal.selected": "已选",
|
||||
"workspace.general.transferAgents.modal.selectedAgent": "此助理会移动到 {{target}},并离开当前空间。",
|
||||
"workspace.general.transferAgents.modal.success": "{{count}} 个助理已移动",
|
||||
"workspace.general.transferAgents.modal.title": "移动助理",
|
||||
"workspace.general.transferAgents.modal.transfer": "移动 {{count}} 个助理",
|
||||
"workspace.general.transferAgents.modal.warning": "自定义技能可能不可用,多助理群组关联将被移除。",
|
||||
"workspace.general.transferAgents.modal.selectedAgent": "要转移到 {{target}} 的代理。",
|
||||
"workspace.general.transferAgents.modal.success": "{{count}} 个代理已成功转移",
|
||||
"workspace.general.transferAgents.modal.title": "转移代理",
|
||||
"workspace.general.transferAgents.modal.transfer": "转移 {{count}} 个代理",
|
||||
"workspace.general.transferAgents.modal.warning": "自定义插件可能不可用,多代理组关联将被移除。",
|
||||
"workspace.general.transferAgents.personalAccount": "个人账户",
|
||||
"workspace.general.transferPrimary.cta": "转移主要所有者",
|
||||
"workspace.general.transferPrimary.description": "将主要所有权转移给另一个所有者。新主要所有者将接管此工作区的计费和主要权限。",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/lobehub",
|
||||
"version": "2.2.4",
|
||||
"version": "2.2.5",
|
||||
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
||||
"keywords": [
|
||||
"framework",
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
* Delegates to AgentManagerRuntime for actual implementation.
|
||||
*/
|
||||
import { AgentManagerRuntime } from '@lobechat/agent-manager-runtime';
|
||||
import type { BuiltinToolContext, BuiltinToolResult, ToolAfterCallContext } from '@lobechat/types';
|
||||
import type { BuiltinToolContext, BuiltinToolResult } from '@lobechat/types';
|
||||
import { BaseExecutor } from '@lobechat/types';
|
||||
|
||||
import { getAgentStoreState } from '@/store/agent';
|
||||
import { getChatStoreState } from '@/store/chat';
|
||||
import { agentService } from '@/services/agent';
|
||||
import { discoverService } from '@/services/discover';
|
||||
|
||||
@@ -22,13 +20,6 @@ import type {
|
||||
} from './types';
|
||||
import { AgentBuilderApiName, AgentBuilderIdentifier } from './types';
|
||||
|
||||
// Write APIs that mutate agent state and require a client-side store refresh.
|
||||
const WRITE_APIS = new Set<string>([
|
||||
AgentBuilderApiName.updateAgentConfig,
|
||||
AgentBuilderApiName.updatePrompt,
|
||||
AgentBuilderApiName.installPlugin,
|
||||
]);
|
||||
|
||||
const runtime = new AgentManagerRuntime({
|
||||
agentService,
|
||||
discoverService,
|
||||
@@ -103,21 +94,6 @@ class AgentBuilderExecutor extends BaseExecutor<typeof AgentBuilderApiName> {
|
||||
|
||||
return runtime.installPlugin(agentId, params);
|
||||
};
|
||||
|
||||
// ==================== Hooks ====================
|
||||
|
||||
onAfterCall = async ({ apiName, result }: ToolAfterCallContext): Promise<void> => {
|
||||
if (!result.success || !WRITE_APIS.has(apiName)) return;
|
||||
|
||||
// AgentBuilderProvider keeps chatStore.activeAgentId in sync with the agent
|
||||
// being edited. After a successful write the server has already updated the
|
||||
// DB, so we re-fetch the config here to update the Zustand store and
|
||||
// re-render the left-sidebar without requiring a page reload.
|
||||
const editingAgentId = getChatStoreState().activeAgentId;
|
||||
if (!editingAgentId) return;
|
||||
|
||||
await getAgentStoreState().internal_refreshAgentConfig(editingAgentId);
|
||||
};
|
||||
}
|
||||
|
||||
export const agentBuilderExecutor = new AgentBuilderExecutor();
|
||||
|
||||
@@ -15,7 +15,6 @@ export const DEFAULT_PREFERENCE: UserPreference = {
|
||||
lab: {
|
||||
enableAgentDocumentFloatingChatPanel: false,
|
||||
enableAgentSelfIteration: false,
|
||||
enableFleet: false,
|
||||
enableInputMarkdown: true,
|
||||
enablePlatformAgent: false,
|
||||
},
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parse } from '../parse';
|
||||
import type { Message } from '../types/shared';
|
||||
|
||||
/**
|
||||
* Dual-form message-chain reader (LOBE-10445, phase 1)
|
||||
*
|
||||
* Two persisted chain shapes must parse to equivalent display output:
|
||||
*
|
||||
* - **tool-anchored (old)**: the next step's assistant hangs off the
|
||||
* previous step's *last tool result* (`assistant.parent = lastToolMsgIdEver`).
|
||||
* - **assistant-anchored (new)**: the next step's assistant hangs off the
|
||||
* *most recent non-tool message* (`assistant.parent = prev assistant/user`),
|
||||
* so a tool result and the next assistant are siblings under one assistant.
|
||||
*
|
||||
* Invariants the role-aware reader enforces:
|
||||
* 1. a `tool` message is always inline data of its assistant (both forms).
|
||||
* 2. a branch is ≥2 *non-tool* siblings under one parent.
|
||||
*
|
||||
* Under both invariants the five fixture classes below — old, new, mixed,
|
||||
* parallel-tool, regenerate-branch — must produce the same active flatList.
|
||||
*/
|
||||
|
||||
interface StepSpec {
|
||||
content?: string;
|
||||
id: string;
|
||||
/** tool_call_ids; each spawns a tool-result message `${id}__${tc}` */
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
type Form = 'old' | 'new';
|
||||
|
||||
/**
|
||||
* Build a single linear multi-step assistant turn in either chain form.
|
||||
* Tool-result messages always parent to their calling assistant; only the
|
||||
* *next assistant's* parent differs between forms.
|
||||
*/
|
||||
const buildTurn = (
|
||||
userId: string,
|
||||
steps: StepSpec[],
|
||||
form: Form,
|
||||
agentId = 'agent-a',
|
||||
): Message[] => {
|
||||
const msgs: Message[] = [];
|
||||
let clock = 0;
|
||||
|
||||
msgs.push({ content: 'q', createdAt: clock++, id: userId, role: 'user', updatedAt: 0 });
|
||||
|
||||
let prevNonToolId = userId; // new-form anchor: most recent non-tool message
|
||||
let lastToolIdEver: string | undefined; // old-form anchor: lastToolMsgIdEver
|
||||
|
||||
steps.forEach((step, i) => {
|
||||
const parentId =
|
||||
i === 0 ? userId : form === 'old' ? (lastToolIdEver ?? prevNonToolId) : prevNonToolId;
|
||||
|
||||
const assistant: Message = {
|
||||
agentId,
|
||||
content: step.content ?? '',
|
||||
createdAt: clock++,
|
||||
id: step.id,
|
||||
parentId,
|
||||
role: 'assistant',
|
||||
updatedAt: 0,
|
||||
};
|
||||
if (step.tools?.length) {
|
||||
assistant.tools = step.tools.map((tc) => ({
|
||||
apiName: 'x',
|
||||
arguments: '{}',
|
||||
id: tc,
|
||||
identifier: 'x',
|
||||
result_msg_id: `${step.id}__${tc}`,
|
||||
type: 'default',
|
||||
}));
|
||||
}
|
||||
msgs.push(assistant);
|
||||
|
||||
for (const tc of step.tools ?? []) {
|
||||
const toolId = `${step.id}__${tc}`;
|
||||
msgs.push({
|
||||
content: 'r',
|
||||
createdAt: clock++,
|
||||
id: toolId,
|
||||
parentId: step.id,
|
||||
role: 'tool',
|
||||
tool_call_id: tc,
|
||||
updatedAt: 0,
|
||||
});
|
||||
lastToolIdEver = toolId;
|
||||
}
|
||||
|
||||
prevNonToolId = step.id;
|
||||
});
|
||||
|
||||
return msgs;
|
||||
};
|
||||
|
||||
/** Normalize a flatList into a render-shape comparable across chain forms. */
|
||||
const shape = (flatList: Message[]) =>
|
||||
flatList.map((m) => ({
|
||||
childIds: (m as any).children?.map((c: any) => ({
|
||||
id: c.id,
|
||||
tools: (c.tools ?? []).map((t: any) => t.result_msg_id),
|
||||
})),
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
}));
|
||||
|
||||
describe('dual-form message chain (LOBE-10445)', () => {
|
||||
// Canonical turn: u1 → a1(tc1) → a2(tc2) → a3(final, no tool)
|
||||
const canonical: StepSpec[] = [
|
||||
{ content: 'step1', id: 'a1', tools: ['tc1'] },
|
||||
{ content: 'step2', id: 'a2', tools: ['tc2'] },
|
||||
{ content: 'final', id: 'a3' },
|
||||
];
|
||||
|
||||
// Expected: user + one merged assistantGroup holding the whole chain.
|
||||
const expectedCanonical = [
|
||||
{ childIds: undefined, id: 'u1', role: 'user' },
|
||||
{
|
||||
childIds: [
|
||||
{ id: 'a1', tools: ['a1__tc1'] },
|
||||
{ id: 'a2', tools: ['a2__tc2'] },
|
||||
{ id: 'a3', tools: [] },
|
||||
],
|
||||
id: 'a1',
|
||||
role: 'assistantGroup',
|
||||
},
|
||||
];
|
||||
|
||||
it('① tool-anchored (old) → single merged group', () => {
|
||||
const result = parse(buildTurn('u1', canonical, 'old'));
|
||||
expect(shape(result.flatList)).toEqual(expectedCanonical);
|
||||
});
|
||||
|
||||
it('② assistant-anchored (new) → single merged group', () => {
|
||||
const result = parse(buildTurn('u1', canonical, 'new'));
|
||||
expect(shape(result.flatList)).toEqual(expectedCanonical);
|
||||
});
|
||||
|
||||
it('② new form parses equivalent to old form (flatList + contextTree)', () => {
|
||||
const oldR = parse(buildTurn('u1', canonical, 'old'));
|
||||
const newR = parse(buildTurn('u1', canonical, 'new'));
|
||||
expect(shape(newR.flatList)).toEqual(shape(oldR.flatList));
|
||||
// contextTree must also collapse to [message(u1), assistantGroup(a1)] in both forms
|
||||
expect(newR.contextTree).toEqual(oldR.contextTree);
|
||||
expect(newR.contextTree.map((n) => ({ id: n.id, type: n.type }))).toEqual([
|
||||
{ id: 'u1', type: 'message' },
|
||||
{ id: 'a1', type: 'assistantGroup' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('③ mixed forms inside one turn → single merged group', () => {
|
||||
// a2 attaches old-style (under a1's tool); a3 attaches new-style (under a2)
|
||||
const msgs: Message[] = [
|
||||
{ content: 'q', createdAt: 0, id: 'u1', role: 'user', updatedAt: 0 },
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'step1',
|
||||
createdAt: 1,
|
||||
id: 'a1',
|
||||
parentId: 'u1',
|
||||
role: 'assistant',
|
||||
tools: [
|
||||
{
|
||||
apiName: 'x',
|
||||
arguments: '{}',
|
||||
id: 'tc1',
|
||||
identifier: 'x',
|
||||
result_msg_id: 'a1__tc1',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
content: 'r',
|
||||
createdAt: 2,
|
||||
id: 'a1__tc1',
|
||||
parentId: 'a1',
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc1',
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'step2',
|
||||
createdAt: 3,
|
||||
id: 'a2',
|
||||
parentId: 'a1__tc1', // OLD-style: under the tool
|
||||
role: 'assistant',
|
||||
tools: [
|
||||
{
|
||||
apiName: 'x',
|
||||
arguments: '{}',
|
||||
id: 'tc2',
|
||||
identifier: 'x',
|
||||
result_msg_id: 'a2__tc2',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
content: 'r',
|
||||
createdAt: 4,
|
||||
id: 'a2__tc2',
|
||||
parentId: 'a2',
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc2',
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'final',
|
||||
createdAt: 5,
|
||||
id: 'a3',
|
||||
parentId: 'a2', // NEW-style: under the assistant (sibling of a2__tc2)
|
||||
role: 'assistant',
|
||||
updatedAt: 0,
|
||||
},
|
||||
];
|
||||
const result = parse(msgs);
|
||||
expect(shape(result.flatList)).toEqual(expectedCanonical);
|
||||
});
|
||||
|
||||
it('④ parallel tools then continuation → merged group (both forms)', () => {
|
||||
const steps: StepSpec[] = [
|
||||
{ content: 'step1', id: 'a1', tools: ['tc1', 'tc2'] },
|
||||
{ content: 'final', id: 'a2' },
|
||||
];
|
||||
const expected = [
|
||||
{ childIds: undefined, id: 'u1', role: 'user' },
|
||||
{
|
||||
childIds: [
|
||||
{ id: 'a1', tools: ['a1__tc1', 'a1__tc2'] },
|
||||
{ id: 'a2', tools: [] },
|
||||
],
|
||||
id: 'a1',
|
||||
role: 'assistantGroup',
|
||||
},
|
||||
];
|
||||
expect(shape(parse(buildTurn('u1', steps, 'old')).flatList)).toEqual(expected);
|
||||
expect(shape(parse(buildTurn('u1', steps, 'new')).flatList)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('⑤ regenerate branch: tool siblings do not inflate branch count', () => {
|
||||
// user has TWO assistant branches (regenerate). Branch a-x is a tool group;
|
||||
// branch a-y is a plain reply. activeBranchIndex picks a-y.
|
||||
const msgs: Message[] = [
|
||||
{
|
||||
content: 'q',
|
||||
createdAt: 0,
|
||||
id: 'u1',
|
||||
metadata: { activeBranchIndex: 1 },
|
||||
role: 'user',
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'branch x',
|
||||
createdAt: 1,
|
||||
id: 'a-x',
|
||||
parentId: 'u1',
|
||||
role: 'assistant',
|
||||
tools: [
|
||||
{
|
||||
apiName: 'x',
|
||||
arguments: '{}',
|
||||
id: 'tcx',
|
||||
identifier: 'x',
|
||||
result_msg_id: 'a-x__tcx',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
content: 'r',
|
||||
createdAt: 2,
|
||||
id: 'a-x__tcx',
|
||||
parentId: 'a-x',
|
||||
role: 'tool',
|
||||
tool_call_id: 'tcx',
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'branch y',
|
||||
createdAt: 3,
|
||||
id: 'a-y',
|
||||
parentId: 'u1',
|
||||
role: 'assistant',
|
||||
updatedAt: 0,
|
||||
},
|
||||
];
|
||||
const result = parse(msgs);
|
||||
const ids = result.flatList.map((m) => m.id);
|
||||
// active branch is a-y (index 1); a-x's tool result must NOT appear as a peer entry
|
||||
expect(ids).toEqual(['u1', 'a-y']);
|
||||
});
|
||||
|
||||
// A regenerated continuation in the new form: the tool-using assistant a1 has
|
||||
// its tool result PLUS two non-tool assistant children (a2a, a2b). These are a
|
||||
// branch, so the active one must be chosen via activeBranchIndex — not the
|
||||
// earliest — and the inactive one must not leak into the merged group chain.
|
||||
const regenContinuation = (activeBranchIndex: number): Message[] => [
|
||||
{ content: 'q', createdAt: 0, id: 'u1', role: 'user', updatedAt: 0 },
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'step1',
|
||||
createdAt: 1,
|
||||
id: 'a1',
|
||||
metadata: { activeBranchIndex },
|
||||
parentId: 'u1',
|
||||
role: 'assistant',
|
||||
tools: [
|
||||
{
|
||||
apiName: 'x',
|
||||
arguments: '{}',
|
||||
id: 'tc1',
|
||||
identifier: 'x',
|
||||
result_msg_id: 'a1__tc1',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
content: 'r',
|
||||
createdAt: 2,
|
||||
id: 'a1__tc1',
|
||||
parentId: 'a1',
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc1',
|
||||
updatedAt: 0,
|
||||
},
|
||||
// two regenerated continuations, both children of a1 (siblings of the tool)
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'cont A',
|
||||
createdAt: 3,
|
||||
id: 'a2a',
|
||||
parentId: 'a1',
|
||||
role: 'assistant',
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'cont B',
|
||||
createdAt: 4,
|
||||
id: 'a2b',
|
||||
parentId: 'a1',
|
||||
role: 'assistant',
|
||||
updatedAt: 0,
|
||||
},
|
||||
];
|
||||
|
||||
it('⑥ assistant-anchored regenerated continuation follows activeBranchIndex', () => {
|
||||
// activeBranchIndex 1 → a2b is the active continuation merged into the group
|
||||
const r1 = parse(regenContinuation(1));
|
||||
expect(shape(r1.flatList)).toEqual([
|
||||
{ childIds: undefined, id: 'u1', role: 'user' },
|
||||
{
|
||||
childIds: [
|
||||
{ id: 'a1', tools: ['a1__tc1'] },
|
||||
{ id: 'a2b', tools: [] },
|
||||
],
|
||||
id: 'a1',
|
||||
role: 'assistantGroup',
|
||||
},
|
||||
]);
|
||||
expect(r1.flatList.map((m) => m.id)).not.toContain('a2a');
|
||||
|
||||
// activeBranchIndex 0 → the OTHER branch (a2a) is active; not blindly earliest-by-rule
|
||||
const r0 = parse(regenContinuation(0));
|
||||
expect((r0.flatList[1] as any).children.map((c: any) => c.id)).toEqual(['a1', 'a2a']);
|
||||
expect(r0.flatList.map((m) => m.id)).not.toContain('a2b');
|
||||
});
|
||||
|
||||
it('⑦ async-task summary with assistant-anchored parent stays out of the group', () => {
|
||||
// a1 spawns async tasks under its tool; the follow-up summary uses the NEW
|
||||
// assistant-anchored parent (summary.parentId === a1). It must render after
|
||||
// the tasks aggregation (group → tasks → summary), NOT inside the group.
|
||||
const msgs: Message[] = [
|
||||
{ content: 'q', createdAt: 0, id: 'u1', role: 'user', updatedAt: 0 },
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'spawning',
|
||||
createdAt: 1,
|
||||
id: 'a1',
|
||||
parentId: 'u1',
|
||||
role: 'assistant',
|
||||
tools: [
|
||||
{
|
||||
apiName: 'dispatch',
|
||||
arguments: '{}',
|
||||
id: 'tc1',
|
||||
identifier: 'x',
|
||||
result_msg_id: 'a1__tc1',
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
content: 'r',
|
||||
createdAt: 2,
|
||||
id: 'a1__tc1',
|
||||
parentId: 'a1',
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc1',
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'task 1',
|
||||
createdAt: 3,
|
||||
id: 'task-1',
|
||||
parentId: 'a1__tc1',
|
||||
role: 'task',
|
||||
updatedAt: 0,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'task 2',
|
||||
createdAt: 4,
|
||||
id: 'task-2',
|
||||
parentId: 'a1__tc1',
|
||||
role: 'task',
|
||||
updatedAt: 0,
|
||||
},
|
||||
// assistant-anchored post-task summary
|
||||
{
|
||||
agentId: 'agent-a',
|
||||
content: 'summary',
|
||||
createdAt: 5,
|
||||
id: 'summary',
|
||||
parentId: 'a1',
|
||||
role: 'assistant',
|
||||
updatedAt: 0,
|
||||
},
|
||||
];
|
||||
const result = parse(msgs);
|
||||
const rows = result.flatList.map((m) => ({ id: m.id, role: m.role }));
|
||||
expect(rows).toEqual([
|
||||
{ id: 'u1', role: 'user' },
|
||||
{ id: 'a1', role: 'assistantGroup' },
|
||||
{ id: result.flatList[2].id, role: 'tasks' },
|
||||
{ id: 'summary', role: 'assistant' },
|
||||
]);
|
||||
// the group must contain ONLY a1 — the summary must not be folded inside it
|
||||
expect((result.flatList[1] as any).children.map((c: any) => c.id)).toEqual(['a1']);
|
||||
});
|
||||
});
|
||||
@@ -156,13 +156,8 @@ export class ContextTreeBuilder {
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 6: Branch — multiple NON-TOOL children (LOBE-10445 invariant 2).
|
||||
// Tool children are inline data of their assistant (handled by Priority 4),
|
||||
// never branch candidates.
|
||||
const nonToolChildren = idNode.children.filter(
|
||||
(child) => this.messageMap.get(child.id)?.role !== 'tool',
|
||||
);
|
||||
if (nonToolChildren.length > 1) {
|
||||
// Priority 6: Branch (multiple children)
|
||||
if (idNode.children.length > 1) {
|
||||
// Add current message node
|
||||
const messageNode = this.createMessageNode(message);
|
||||
contextTree.push(messageNode);
|
||||
@@ -206,13 +201,13 @@ export class ContextTreeBuilder {
|
||||
private isAssistantGroupNode(message: Message, idNode: IdNode): boolean {
|
||||
if (message.role !== 'assistant') return false;
|
||||
|
||||
// Role-aware (LOBE-10445): an assistant heads a group when it has ANY tool
|
||||
// child — not only when ALL children are tools. In the assistant-anchored
|
||||
// form the next step's assistant is a sibling of the tool results, so a
|
||||
// group head legitimately has a mix of tool + assistant children. (In the
|
||||
// old tool-anchored form a tool-using assistant only ever had tool children,
|
||||
// so this stays a no-op for legacy data.)
|
||||
return idNode.children.some((child) => this.messageMap.get(child.id)?.role === 'tool');
|
||||
return (
|
||||
idNode.children.length > 0 &&
|
||||
idNode.children.every((child) => {
|
||||
const childMsg = this.messageMap.get(child.id);
|
||||
return childMsg?.role === 'tool';
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -290,11 +290,6 @@ export class FlatListBuilder {
|
||||
|
||||
// Priority 3a: Compare mode from user message metadata
|
||||
const childMessages = this.childrenMap.get(message.id) ?? [];
|
||||
// Non-tool children only are branch candidates (LOBE-10445 invariant 2):
|
||||
// a tool child is inline data of its assistant, never a sibling branch.
|
||||
const nonToolChildMessages = childMessages.filter(
|
||||
(childId) => this.messageMap.get(childId)?.role !== 'tool',
|
||||
);
|
||||
if (this.isCompareMode(message) && childMessages.length > 1) {
|
||||
// Add user message
|
||||
flatList.push(message);
|
||||
@@ -339,10 +334,10 @@ export class FlatListBuilder {
|
||||
|
||||
// Priority 3d: User message with branches (multiple assistant children)
|
||||
// Branch indicator should be on the active assistant child message
|
||||
if (message.role === 'user' && nonToolChildMessages.length > 1) {
|
||||
if (message.role === 'user' && childMessages.length > 1) {
|
||||
const activeBranchId = this.branchResolver.getActiveBranchIdFromMetadata(
|
||||
message,
|
||||
nonToolChildMessages,
|
||||
childMessages,
|
||||
this.childrenMap,
|
||||
);
|
||||
|
||||
@@ -358,7 +353,7 @@ export class FlatListBuilder {
|
||||
flatList.push(message);
|
||||
processedIds.add(message.id);
|
||||
|
||||
const activeBranchIndex = nonToolChildMessages.indexOf(activeBranchId);
|
||||
const activeBranchIndex = childMessages.indexOf(activeBranchId);
|
||||
|
||||
// Continue with active branch - check if it's an assistantGroup
|
||||
const activeBranchMsg = this.messageMap.get(activeBranchId);
|
||||
@@ -389,7 +384,7 @@ export class FlatListBuilder {
|
||||
// Add branch info to the assistantGroup message
|
||||
const groupMessageWithBranches = this.createMessageWithBranches(
|
||||
groupMessage,
|
||||
nonToolChildMessages.length,
|
||||
childMessages.length,
|
||||
activeBranchIndex,
|
||||
);
|
||||
flatList.push(groupMessageWithBranches);
|
||||
@@ -409,7 +404,7 @@ export class FlatListBuilder {
|
||||
// Regular assistant message (not assistantGroup) - add branch info
|
||||
const activeBranchWithBranches = this.createMessageWithBranches(
|
||||
activeBranchMsg,
|
||||
nonToolChildMessages.length,
|
||||
childMessages.length,
|
||||
activeBranchIndex,
|
||||
);
|
||||
flatList.push(activeBranchWithBranches);
|
||||
@@ -424,10 +419,10 @@ export class FlatListBuilder {
|
||||
|
||||
// Priority 3e: Assistant message with branches (multiple user children)
|
||||
// Branch indicator should be on the active user child message
|
||||
if (message.role === 'assistant' && nonToolChildMessages.length > 1) {
|
||||
if (message.role === 'assistant' && childMessages.length > 1) {
|
||||
const activeBranchId = this.branchResolver.getActiveBranchIdFromMetadata(
|
||||
message,
|
||||
nonToolChildMessages,
|
||||
childMessages,
|
||||
this.childrenMap,
|
||||
);
|
||||
|
||||
@@ -443,7 +438,7 @@ export class FlatListBuilder {
|
||||
flatList.push(message);
|
||||
processedIds.add(message.id);
|
||||
|
||||
const activeBranchIndex = nonToolChildMessages.indexOf(activeBranchId);
|
||||
const activeBranchIndex = childMessages.indexOf(activeBranchId);
|
||||
|
||||
// Continue with active branch and add branch info to the user child
|
||||
const activeBranchMsg = this.messageMap.get(activeBranchId);
|
||||
@@ -451,7 +446,7 @@ export class FlatListBuilder {
|
||||
// Add branch info to the active user child message
|
||||
const activeBranchWithBranches = this.createMessageWithBranches(
|
||||
activeBranchMsg,
|
||||
nonToolChildMessages.length,
|
||||
childMessages.length,
|
||||
activeBranchIndex,
|
||||
);
|
||||
flatList.push(activeBranchWithBranches);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ContextNode, IdNode, Message, MessageNode, SignalCallbacksNode } from '../types';
|
||||
import { BranchResolver } from './BranchResolver';
|
||||
|
||||
/**
|
||||
* Persisted external-signal lineage on `message.metadata.signal` —
|
||||
@@ -56,8 +55,6 @@ export class MessageCollector {
|
||||
constructor(
|
||||
private messageMap: Map<string, Message>,
|
||||
private childrenMap: Map<string | null, string[]>,
|
||||
// BranchResolver is stateless; default keeps existing 2-arg call sites working.
|
||||
private branchResolver: BranchResolver = new BranchResolver(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -135,114 +132,59 @@ export class MessageCollector {
|
||||
const toolMessages = this.collectToolMessages(currentAssistant, allMessages);
|
||||
allToolMessages.push(...toolMessages);
|
||||
|
||||
// Find the next step's assistant. Role-aware dual-form walk (LOBE-10445):
|
||||
// the continuation may hang off this assistant directly (assistant-anchored
|
||||
// / new form) OR off one of its tool results (tool-anchored / old form).
|
||||
const continuation = this.findFlatChainContinuation(
|
||||
currentAssistant,
|
||||
toolMessages,
|
||||
allMessages,
|
||||
processedIds,
|
||||
groupAgentId,
|
||||
);
|
||||
if (!continuation) return;
|
||||
|
||||
if (continuation.tools && continuation.tools.length > 0) {
|
||||
// Continue the chain (recursion marks it processed at the top)
|
||||
this.collectAssistantChain(
|
||||
continuation,
|
||||
allMessages,
|
||||
assistantChain,
|
||||
allToolMessages,
|
||||
processedIds,
|
||||
);
|
||||
} else {
|
||||
// Final assistant without tools — caller marks the whole chain processed
|
||||
assistantChain.push(continuation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next assistant in a tool-using step's chain (flat variant).
|
||||
*
|
||||
* Dual-form aware: candidates are gathered from BOTH the assistant's own
|
||||
* non-tool children (new assistant-anchored form, where the next assistant is
|
||||
* a sibling of the tool results) AND each tool result's children (old
|
||||
* tool-anchored form).
|
||||
*
|
||||
* Two guards keep the assistant-anchored candidate honest:
|
||||
* - **Fan-out guard**: if any tool hosts an AgentCouncil or spawned async
|
||||
* tasks, the chain does NOT continue linearly through this step — neither
|
||||
* through that tool's children nor through an assistant-anchored follow-up
|
||||
* (a post-task summary whose `parentId === currentAssistant.id`). Those are
|
||||
* emitted by the council/tasks flow AFTER the group, so the assistant seed
|
||||
* is dropped and the chain ends here.
|
||||
* - **Branch resolution**: when >1 non-tool same-agent continuations share a
|
||||
* parent (e.g. a regenerated continuation), pick the active one via
|
||||
* `activeBranchIndex` instead of blindly taking the earliest.
|
||||
*/
|
||||
private findFlatChainContinuation(
|
||||
currentAssistant: Message,
|
||||
toolMessages: Message[],
|
||||
allMessages: Message[],
|
||||
processedIds: Set<string>,
|
||||
groupAgentId: string | undefined,
|
||||
): Message | undefined {
|
||||
const candidateParentIds = new Set<string>();
|
||||
let hasFanOutTool = false;
|
||||
// Find next assistant after tools
|
||||
for (const toolMsg of toolMessages) {
|
||||
const isCouncil = (toolMsg.metadata as any)?.agentCouncil === true;
|
||||
const toolChildren = allMessages.filter((m) => m.parentId === toolMsg.id);
|
||||
const hasTaskChild = toolChildren.some((m) => m.role === 'task');
|
||||
if (isCouncil || hasTaskChild) {
|
||||
hasFanOutTool = true;
|
||||
// Stop if tool message has agentCouncil mode - its children belong to AgentCouncil
|
||||
if ((toolMsg.metadata as any)?.agentCouncil === true) {
|
||||
continue;
|
||||
}
|
||||
candidateParentIds.add(toolMsg.id);
|
||||
|
||||
const nextMessages = allMessages.filter((m) => m.parentId === toolMsg.id);
|
||||
|
||||
// Stop if there are task children - they should be handled separately, not part of AssistantGroup
|
||||
// This ensures that messages after a task are not merged into the AssistantGroup before the task
|
||||
const taskChildren = nextMessages.filter((m) => m.role === 'task');
|
||||
if (taskChildren.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const nextMsg of nextMessages) {
|
||||
// Skip an already-collected follower: a duplicated tool_call_id can make
|
||||
// collectToolMessages surface an earlier turn's tool result first, and
|
||||
// returning after that no-op recursion would drop this assistant's real
|
||||
// continuation under a later tool.
|
||||
if (processedIds.has(nextMsg.id)) continue;
|
||||
// Only continue if the next assistant has the SAME agentId
|
||||
// Different agentId means it's a different agent responding (e.g., via speak tool)
|
||||
const isSameAgent = nextMsg.agentId === groupAgentId;
|
||||
// Skip signal-tagged toolless callbacks () — they're a
|
||||
// side-channel under the same parent tool and get collected
|
||||
// separately by `collectFlatSignalCallbacks`.
|
||||
if (getMessageSignal(nextMsg)) continue;
|
||||
|
||||
if (
|
||||
nextMsg.role === 'assistant' &&
|
||||
nextMsg.tools &&
|
||||
nextMsg.tools.length > 0 &&
|
||||
isSameAgent
|
||||
) {
|
||||
// Continue the chain only for same agent
|
||||
this.collectAssistantChain(
|
||||
nextMsg,
|
||||
allMessages,
|
||||
assistantChain,
|
||||
allToolMessages,
|
||||
processedIds,
|
||||
);
|
||||
return;
|
||||
} else if (nextMsg.role === 'assistant' && isSameAgent) {
|
||||
// Final assistant without tools (same agent)
|
||||
assistantChain.push(nextMsg);
|
||||
return;
|
||||
}
|
||||
// If different agentId, don't add to chain - let it be processed separately
|
||||
}
|
||||
}
|
||||
// Assistant-anchored continuation only counts when this step did not fan out.
|
||||
if (!hasFanOutTool) candidateParentIds.add(currentAssistant.id);
|
||||
|
||||
const candidates = allMessages
|
||||
.filter((m) => m.parentId != null && candidateParentIds.has(m.parentId))
|
||||
.filter((m) => m.role !== 'tool' && !processedIds.has(m.id))
|
||||
.filter((m) => m.role === 'assistant' && m.agentId === groupAgentId && !getMessageSignal(m))
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
const activeId = this.resolveActiveContinuationId(candidates);
|
||||
return activeId ? candidates.find((m) => m.id === activeId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the active continuation among same-step candidates (sorted by
|
||||
* createdAt). One candidate ⇒ a linear continuation. >1 non-tool siblings
|
||||
* under a single parent ⇒ a branch (e.g. a regenerated continuation), so
|
||||
* consult the parent's `activeBranchIndex` via BranchResolver instead of
|
||||
* blindly taking the earliest — otherwise the inactive branch is silently
|
||||
* chosen and the active one dropped. Returns undefined when there is no
|
||||
* continuation, or the active branch is an optimistic not-yet-created one.
|
||||
*/
|
||||
private resolveActiveContinuationId(sortedCandidates: Message[]): string | undefined {
|
||||
if (sortedCandidates.length === 0) return undefined;
|
||||
const earliest = sortedCandidates[0];
|
||||
const parentId = earliest.parentId;
|
||||
if (parentId == null) return earliest.id;
|
||||
|
||||
// Branch siblings share one parent; only those under the earliest
|
||||
// candidate's parent participate in this branch decision. Use childrenMap
|
||||
// (creation) order so it lines up with how activeBranchIndex is assigned.
|
||||
const eligibleIds = new Set(sortedCandidates.map((m) => m.id));
|
||||
const siblingIds = (this.childrenMap.get(parentId) ?? []).filter((id) => eligibleIds.has(id));
|
||||
if (siblingIds.length <= 1) return earliest.id;
|
||||
|
||||
const parentMsg = this.messageMap.get(parentId);
|
||||
if (!parentMsg) return earliest.id;
|
||||
|
||||
return this.branchResolver.getActiveBranchIdFromMetadata(
|
||||
parentMsg,
|
||||
siblingIds,
|
||||
this.childrenMap,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,61 +296,41 @@ export class MessageCollector {
|
||||
}
|
||||
children.push(messageNode);
|
||||
|
||||
// Find the next step's assistant (dual-form aware, see findChainContinuationNode)
|
||||
const nextNode = this.findChainContinuationNode(idNode, agentId);
|
||||
if (nextNode) {
|
||||
const nextMsg = this.messageMap.get(nextNode.id)!;
|
||||
this.collectAssistantGroupMessages(nextMsg, nextNode, children, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the IdNode of the next assistant in a tool-using step's chain
|
||||
* (contextTree variant of {@link findFlatChainContinuation}). Same fan-out
|
||||
* guard (AgentCouncil / async tasks end the chain — including any
|
||||
* assistant-anchored post-task summary) and branch resolution (>1 non-tool
|
||||
* siblings under one parent ⇒ pick the active branch) as the flat variant.
|
||||
* Signal-tagged toolless siblings (Monitor callbacks etc.) are skipped so the
|
||||
* main chain walks the real follower.
|
||||
*/
|
||||
private findChainContinuationNode(idNode: IdNode, groupAgentId?: string): IdNode | undefined {
|
||||
const candidateNodes: IdNode[] = [];
|
||||
let hasFanOutTool = false;
|
||||
|
||||
// (b) each tool result's children (old form); detect fan-out tools
|
||||
// Find next assistant message after tools
|
||||
for (const toolNode of idNode.children) {
|
||||
const toolMsg = this.messageMap.get(toolNode.id);
|
||||
if (toolMsg?.role !== 'tool') continue;
|
||||
const isCouncil = (toolMsg.metadata as any)?.agentCouncil === true;
|
||||
const hasTaskChild = toolNode.children.some(
|
||||
(child) => this.messageMap.get(child.id)?.role === 'task',
|
||||
);
|
||||
if (isCouncil || hasTaskChild) {
|
||||
hasFanOutTool = true;
|
||||
|
||||
// Stop if tool message has agentCouncil mode - its children belong to AgentCouncil
|
||||
if ((toolMsg.metadata as any)?.agentCouncil === true) {
|
||||
continue;
|
||||
}
|
||||
candidateNodes.push(...toolNode.children);
|
||||
}
|
||||
|
||||
// (a) the assistant's own non-tool children (new form) — only when the step
|
||||
// did not fan out (otherwise they are post-fan-out summaries, not inline)
|
||||
if (!hasFanOutTool) {
|
||||
for (const child of idNode.children) {
|
||||
if (this.messageMap.get(child.id)?.role === 'tool') continue;
|
||||
candidateNodes.push(child);
|
||||
// Stop if there are ANY task children - they should be processed separately, not part of AssistantGroup
|
||||
// This ensures that messages after a task are not merged into the AssistantGroup before the task
|
||||
const taskChildren = toolNode.children.filter((child) => {
|
||||
const childMsg = this.messageMap.get(child.id);
|
||||
return childMsg?.role === 'task';
|
||||
});
|
||||
if (taskChildren.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the next main-chain assistant under this tool. Signal-tagged
|
||||
// toolless siblings (Monitor callbacks etc., ) share the
|
||||
// same parent tool but live on a side-channel — skip them here so
|
||||
// the main chain still walks the real follower. The signal blocks
|
||||
// are emitted separately by `collectSignalCallbacks`.
|
||||
for (const nextChild of toolNode.children) {
|
||||
const nextMsg = this.messageMap.get(nextChild.id);
|
||||
if (nextMsg?.role !== 'assistant') continue;
|
||||
if (nextMsg.agentId !== agentId) continue;
|
||||
if (getMessageSignal(nextMsg)) continue; // skip signal callbacks
|
||||
// Recursively collect this assistant and its descendants (same agent only)
|
||||
this.collectAssistantGroupMessages(nextMsg, nextChild, children, agentId);
|
||||
return; // Only follow one path
|
||||
}
|
||||
}
|
||||
|
||||
const eligible = candidateNodes
|
||||
.map((node) => ({ msg: this.messageMap.get(node.id), node }))
|
||||
.filter(
|
||||
(c) =>
|
||||
c.msg?.role === 'assistant' && c.msg.agentId === groupAgentId && !getMessageSignal(c.msg),
|
||||
)
|
||||
.sort((a, b) => a.msg!.createdAt - b.msg!.createdAt);
|
||||
|
||||
const activeId = this.resolveActiveContinuationId(eligible.map((c) => c.msg!));
|
||||
return activeId ? eligible.find((c) => c.node.id === activeId)?.node : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,21 +503,51 @@ export class MessageCollector {
|
||||
* Only follows messages from the SAME agent (matching agentId)
|
||||
*/
|
||||
findLastNodeInAssistantGroup(idNode: IdNode, groupAgentId?: string): IdNode | null {
|
||||
// Walk the chain to its next step (dual-form aware, see findChainContinuationNode)
|
||||
const nextNode = this.findChainContinuationNode(idNode, groupAgentId);
|
||||
if (nextNode) {
|
||||
return this.findLastNodeInAssistantGroup(nextNode, groupAgentId);
|
||||
}
|
||||
// Check if has tool children
|
||||
const toolChildren = idNode.children.filter((child) => {
|
||||
const childMsg = this.messageMap.get(child.id);
|
||||
return childMsg?.role === 'tool';
|
||||
});
|
||||
|
||||
// No further same-agent assistant. If this step still owns tool results
|
||||
// (e.g. the last tool hosts an AgentCouncil / tasks), return the last tool
|
||||
// node so findNextAfterTools can inspect it; otherwise this node is the tail.
|
||||
const toolChildren = idNode.children.filter(
|
||||
(child) => this.messageMap.get(child.id)?.role === 'tool',
|
||||
);
|
||||
if (toolChildren.length === 0) {
|
||||
return idNode;
|
||||
}
|
||||
|
||||
// Check if any tool has an assistant child with the same agentId
|
||||
for (const toolNode of toolChildren) {
|
||||
const toolMsg = this.messageMap.get(toolNode.id);
|
||||
|
||||
// Stop if tool message has agentCouncil mode - its children belong to AgentCouncil
|
||||
if ((toolMsg?.metadata as any)?.agentCouncil === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stop if there are ANY task children - they should be processed separately, not part of AssistantGroup
|
||||
// This ensures that messages after a task are not merged into the AssistantGroup before the task
|
||||
const taskNodes = toolNode.children.filter((child) => {
|
||||
const childMsg = this.messageMap.get(child.id);
|
||||
return childMsg?.role === 'task';
|
||||
});
|
||||
if (taskNodes.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pick the next main-chain assistant under this tool. Mirror the
|
||||
// skip rule used by `collectAssistantGroupMessages`: signal-tagged
|
||||
// toolless siblings (Monitor callbacks etc., ) share the
|
||||
// parent tool but live on a side-channel — if they appear before
|
||||
// the real follower, blindly taking children[0] would end the
|
||||
// walk on a callback node and truncate the AssistantGroup tail.
|
||||
for (const nextChild of toolNode.children) {
|
||||
const nextMsg = this.messageMap.get(nextChild.id);
|
||||
if (nextMsg?.role !== 'assistant') continue;
|
||||
if (nextMsg.agentId !== groupAgentId) continue;
|
||||
if (getMessageSignal(nextMsg)) continue;
|
||||
return this.findLastNodeInAssistantGroup(nextChild, groupAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
// No more assistant messages from the same agent, return the last tool node
|
||||
return toolChildren.at(-1) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,7 @@ export class Transformer {
|
||||
|
||||
// Initialize utility classes
|
||||
this.branchResolver = new BranchResolver();
|
||||
this.messageCollector = new MessageCollector(
|
||||
this.messageMap,
|
||||
helperMaps.childrenMap,
|
||||
this.branchResolver,
|
||||
);
|
||||
this.messageCollector = new MessageCollector(this.messageMap, helperMaps.childrenMap);
|
||||
this.messageTransformer = new MessageTransformer();
|
||||
|
||||
// Initialize builder classes
|
||||
|
||||
@@ -361,32 +361,6 @@ describe('AgentDocumentModel', () => {
|
||||
|
||||
expect(result.map((doc) => doc.id)).toEqual([ownDoc.id]);
|
||||
});
|
||||
|
||||
it('should list current-agent document summaries by underlying document ids', async () => {
|
||||
const ownDoc = await agentDocumentModel.create(agentId, 'own.md', 'own content', {
|
||||
sourceType: 'file',
|
||||
});
|
||||
const webDoc = await agentDocumentModel.create(agentId, 'web-page', 'web content', {
|
||||
fileType: 'article',
|
||||
sourceType: 'web',
|
||||
});
|
||||
const secondAgentDoc = await agentDocumentModel.create(
|
||||
secondAgentId,
|
||||
'second.md',
|
||||
'second content',
|
||||
{ sourceType: 'file' },
|
||||
);
|
||||
|
||||
const result = await agentDocumentModel.listByDocumentIds(
|
||||
agentId,
|
||||
[ownDoc.documentId, webDoc.documentId, secondAgentDoc.documentId],
|
||||
{ sourceType: 'file' },
|
||||
);
|
||||
|
||||
expect(result.map((doc) => doc.id)).toEqual([ownDoc.id]);
|
||||
expect(result[0]).not.toHaveProperty('content');
|
||||
expect(result[0]).not.toHaveProperty('editorData');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update and upsert', () => {
|
||||
@@ -759,48 +733,6 @@ describe('AgentDocumentModel', () => {
|
||||
expect(byTemplate.every((item) => item.templateId === 'claw')).toBe(true);
|
||||
});
|
||||
|
||||
it('should list document summaries without content or editor data', async () => {
|
||||
const fileDoc = await agentDocumentModel.create(agentId, 'file.md', 'file content', {
|
||||
editorData: { root: { children: [{ text: 'file content' }] } },
|
||||
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
|
||||
sourceType: 'file',
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
await agentDocumentModel.create(agentId, 'web-page', 'web content', {
|
||||
fileType: 'article',
|
||||
sourceType: 'web',
|
||||
updatedAt: new Date('2026-01-01T00:00:01.000Z'),
|
||||
});
|
||||
await agentDocumentModel.create(secondAgentId, 'other-agent.md', 'other content', {
|
||||
sourceType: 'file',
|
||||
});
|
||||
|
||||
const all = await agentDocumentModel.listByAgent(agentId);
|
||||
|
||||
expect(all.map((item) => item.filename)).toEqual(['web-page', 'file.md']);
|
||||
for (const item of all) {
|
||||
expect(item).not.toHaveProperty('content');
|
||||
expect(item).not.toHaveProperty('editorData');
|
||||
}
|
||||
|
||||
const fileSummary = all.find((item) => item.id === fileDoc.id);
|
||||
expect(fileSummary).toMatchObject({
|
||||
category: 'document',
|
||||
documentId: fileDoc.documentId,
|
||||
filename: 'file.md',
|
||||
id: fileDoc.id,
|
||||
isFolder: false,
|
||||
isSkillBundle: false,
|
||||
isSkillIndex: false,
|
||||
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
|
||||
sourceType: 'file',
|
||||
title: 'file',
|
||||
});
|
||||
|
||||
const webOnly = await agentDocumentModel.listByAgent(agentId, { sourceType: 'web' });
|
||||
expect(webOnly.map((item) => item.filename)).toEqual(['web-page']);
|
||||
});
|
||||
|
||||
it('should return only skill-managed docs for skill registry assembly', async () => {
|
||||
const bundle = await agentDocumentModel.create(agentId, 'bug-triage', 'bundle body', {
|
||||
fileType: SKILL_BUNDLE_FILE_TYPE,
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
import type {
|
||||
AgentDocument,
|
||||
AgentDocumentContextRow,
|
||||
AgentDocumentListItem,
|
||||
AgentDocumentListSourceType,
|
||||
AgentDocumentPolicy,
|
||||
AgentDocumentSourceType,
|
||||
AgentDocumentWithRules,
|
||||
@@ -72,20 +70,6 @@ interface ConvertAgentDocumentToSkillIndexParams {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface AgentDocumentListQueryRow {
|
||||
description: string | null;
|
||||
documentId: string;
|
||||
filename: string | null;
|
||||
fileType: string;
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
policy: unknown;
|
||||
sourceType: AgentDocumentSourceType;
|
||||
templateId: string | null;
|
||||
title: string | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class AgentDocumentModel {
|
||||
private userId: string;
|
||||
private workspaceId?: string;
|
||||
@@ -180,29 +164,6 @@ export class AgentDocumentModel {
|
||||
};
|
||||
}
|
||||
|
||||
private toAgentDocumentListItem(row: AgentDocumentListQueryRow): AgentDocumentListItem {
|
||||
const filename = row.filename ?? '';
|
||||
const policy = (row.policy as AgentDocumentPolicy | null) ?? null;
|
||||
const item = {
|
||||
description: row.description ?? null,
|
||||
documentId: row.documentId,
|
||||
fileType: row.fileType,
|
||||
filename,
|
||||
id: row.id,
|
||||
loadPosition: policy?.context?.position,
|
||||
parentId: row.parentId ?? null,
|
||||
sourceType: row.sourceType,
|
||||
templateId: row.templateId ?? null,
|
||||
title: row.title ?? filename,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
|
||||
return {
|
||||
...item,
|
||||
...deriveAgentDocumentFields(item),
|
||||
};
|
||||
}
|
||||
|
||||
private buildDeletedAtFilters(options?: AgentDocumentQueryOptions) {
|
||||
if (options?.deletedOnly) return [isNotNull(agentDocuments.deletedAt)];
|
||||
if (options?.includeDeleted) return [];
|
||||
@@ -949,40 +910,6 @@ export class AgentDocumentModel {
|
||||
});
|
||||
}
|
||||
|
||||
async listByAgent(
|
||||
agentId: string,
|
||||
options?: { sourceType?: AgentDocumentListSourceType },
|
||||
): Promise<AgentDocumentListItem[]> {
|
||||
const sourceType = options?.sourceType;
|
||||
const results = await this.db
|
||||
.select({
|
||||
description: documents.description,
|
||||
documentId: agentDocuments.documentId,
|
||||
fileType: documents.fileType,
|
||||
filename: documents.filename,
|
||||
id: agentDocuments.id,
|
||||
parentId: documents.parentId,
|
||||
policy: agentDocuments.policy,
|
||||
sourceType: documents.sourceType,
|
||||
templateId: agentDocuments.templateId,
|
||||
title: documents.title,
|
||||
updatedAt: agentDocuments.updatedAt,
|
||||
})
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
this.agentDocOwnership(),
|
||||
eq(agentDocuments.agentId, agentId),
|
||||
isNull(agentDocuments.deletedAt),
|
||||
...(sourceType && sourceType !== 'all' ? [eq(documents.sourceType, sourceType)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentDocuments.updatedAt));
|
||||
|
||||
return results.map((row) => this.toAgentDocumentListItem(row));
|
||||
}
|
||||
|
||||
async findSkillDocsByAgent(agentId: string): Promise<AgentDocumentWithRules[]> {
|
||||
const results = await this.db
|
||||
.select({ doc: documents, settings: agentDocuments })
|
||||
@@ -1126,44 +1053,6 @@ export class AgentDocumentModel {
|
||||
});
|
||||
}
|
||||
|
||||
async listByDocumentIds(
|
||||
agentId: string,
|
||||
documentIds: string[],
|
||||
options?: { sourceType?: AgentDocumentListSourceType },
|
||||
): Promise<AgentDocumentListItem[]> {
|
||||
if (documentIds.length === 0) return [];
|
||||
|
||||
const sourceType = options?.sourceType;
|
||||
const results = await this.db
|
||||
.select({
|
||||
description: documents.description,
|
||||
documentId: agentDocuments.documentId,
|
||||
fileType: documents.fileType,
|
||||
filename: documents.filename,
|
||||
id: agentDocuments.id,
|
||||
parentId: documents.parentId,
|
||||
policy: agentDocuments.policy,
|
||||
sourceType: documents.sourceType,
|
||||
templateId: agentDocuments.templateId,
|
||||
title: documents.title,
|
||||
updatedAt: agentDocuments.updatedAt,
|
||||
})
|
||||
.from(agentDocuments)
|
||||
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
|
||||
.where(
|
||||
and(
|
||||
this.agentDocOwnership(),
|
||||
eq(agentDocuments.agentId, agentId),
|
||||
inArray(agentDocuments.documentId, documentIds),
|
||||
isNull(agentDocuments.deletedAt),
|
||||
...(sourceType && sourceType !== 'all' ? [eq(documents.sourceType, sourceType)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentDocuments.updatedAt));
|
||||
|
||||
return results.map((row) => this.toAgentDocumentListItem(row));
|
||||
}
|
||||
|
||||
async hasByAgent(agentId: string): Promise<boolean> {
|
||||
const [result] = await this.db
|
||||
.select({ id: agentDocuments.id })
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import type {
|
||||
AgentDocumentPolicy,
|
||||
DocumentLoadFormat,
|
||||
DocumentLoadPosition as DocumentLoadPositionType,
|
||||
DocumentLoadRules,
|
||||
PolicyLoad,
|
||||
} from '@lobechat/agent-templates';
|
||||
@@ -24,7 +23,6 @@ export {
|
||||
export type { AgentDocumentPolicy, DocumentLoadRules } from '@lobechat/agent-templates';
|
||||
|
||||
export type AgentDocumentSourceType = 'file' | 'web' | 'api' | 'topic' | 'agent' | 'agent-signal';
|
||||
export type AgentDocumentListSourceType = 'all' | 'file' | 'web';
|
||||
|
||||
/**
|
||||
* UI-facing tab grouping for an agent document. Derived from `fileType` +
|
||||
@@ -83,20 +81,6 @@ export interface AgentDocumentWithRules extends AgentDocument, AgentDocumentDeri
|
||||
loadRules: DocumentLoadRules;
|
||||
}
|
||||
|
||||
export interface AgentDocumentListItem extends AgentDocumentDerivedFields {
|
||||
description: string | null;
|
||||
documentId: string;
|
||||
filename: string;
|
||||
fileType: string;
|
||||
id: string;
|
||||
loadPosition: DocumentLoadPositionType | undefined;
|
||||
parentId: string | null;
|
||||
sourceType: AgentDocumentSourceType;
|
||||
templateId: string | null;
|
||||
title: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AgentDocumentContextRow extends AgentDocumentDerivedFields {
|
||||
content: string;
|
||||
contentCharCount?: number;
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
messageTranslates,
|
||||
messageTTS,
|
||||
threads,
|
||||
topics,
|
||||
} from '../schemas';
|
||||
import type { LobeChatDatabase, Transaction } from '../type';
|
||||
import { sanitizeBm25Query } from '../utils/bm25';
|
||||
@@ -2053,19 +2054,19 @@ export class MessageModel {
|
||||
{ hasMetadata: !!metadataPatch, valueKeys: Object.keys(message) },
|
||||
);
|
||||
|
||||
if (
|
||||
updated?.topicId && // When this write carries token usage (assistant finalize / hetero
|
||||
if (updated?.topicId) {
|
||||
// When this write carries token usage (assistant finalize / hetero
|
||||
// step), recompute the topic's denormalized usage rollup from its
|
||||
// messages. Gated on the *incoming* payload so streaming
|
||||
// content-only updates don't trigger needless recomputes.
|
||||
usageToWrite
|
||||
) {
|
||||
await runTimedStage(
|
||||
timing,
|
||||
'db.message.update.topic.recomputeUsage',
|
||||
() => recomputeTopicUsage(trx, this.userId, updated.topicId!, this.workspaceId),
|
||||
{ topicCount: 1 },
|
||||
);
|
||||
if (usageToWrite) {
|
||||
await runTimedStage(
|
||||
timing,
|
||||
'db.message.update.topic.recomputeUsage',
|
||||
() => recomputeTopicUsage(trx, this.userId, updated.topicId!, this.workspaceId),
|
||||
{ topicCount: 1 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
@@ -2378,44 +2379,6 @@ export class MessageModel {
|
||||
return row?.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Latest main-thread (`threadId IS NULL`) tool message in a topic created on
|
||||
* or after `sinceMessageId`. The hetero persistence handler passes the running
|
||||
* operation's seed assistant as `sinceMessageId`, so the `createdAt` floor
|
||||
* scopes the lookup to that run (a topic runs at most one operation at a time)
|
||||
* and the chain anchor stays correct even when the in-memory current-assistant
|
||||
* pointer has regressed on a cold replica.
|
||||
*/
|
||||
getLastMainThreadToolMessageIdSince = async (
|
||||
topicId: string,
|
||||
sinceMessageId: string,
|
||||
): Promise<string | undefined> => {
|
||||
const [seed] = await this.db
|
||||
.select({ createdAt: messages.createdAt })
|
||||
.from(messages)
|
||||
.where(and(eq(messages.id, sinceMessageId), this.ownership()))
|
||||
.limit(1);
|
||||
|
||||
if (!seed?.createdAt) return undefined;
|
||||
|
||||
const [row] = await this.db
|
||||
.select({ id: messages.id })
|
||||
.from(messages)
|
||||
.where(
|
||||
and(
|
||||
eq(messages.topicId, topicId),
|
||||
eq(messages.role, 'tool'),
|
||||
isNull(messages.threadId),
|
||||
gte(messages.createdAt, seed.createdAt),
|
||||
this.ownership(),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(messages.createdAt))
|
||||
.limit(1);
|
||||
|
||||
return row?.id;
|
||||
};
|
||||
|
||||
updateTranslate = async (id: string, translate: Partial<ChatTranslate>) => {
|
||||
const result = await this.db.query.messageTranslates.findFirst({
|
||||
where: and(eq(messageTranslates.id, id), this.translatesOwnership()),
|
||||
|
||||
@@ -39,29 +39,6 @@ export interface GitWorkingTreeStatus {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GitWorktreeListItem {
|
||||
/** True for bare repositories, which cannot be opened as a normal cwd. */
|
||||
bare?: boolean;
|
||||
/** Branch short name, absent for detached HEAD or bare entries. */
|
||||
branch?: string;
|
||||
/** True when this worktree is the one containing the queried cwd. */
|
||||
current: boolean;
|
||||
/** True when HEAD is detached. */
|
||||
detached?: boolean;
|
||||
/** Full HEAD SHA reported by `git worktree list --porcelain`. */
|
||||
head?: string;
|
||||
/** True when git marks this worktree as locked. */
|
||||
locked?: boolean;
|
||||
lockReason?: string;
|
||||
/** Absolute worktree path. */
|
||||
path: string;
|
||||
/** True when git marks this worktree as prunable. */
|
||||
prunable?: boolean;
|
||||
pruneReason?: string;
|
||||
/** Dirty-file counts for non-bare, non-prunable worktrees. */
|
||||
status?: GitWorkingTreeStatus;
|
||||
}
|
||||
|
||||
export interface GitWorkingTreeFiles {
|
||||
/** Repo-relative paths for untracked + staged-as-added files */
|
||||
added: string[];
|
||||
|
||||
@@ -115,11 +115,6 @@ export type LocalFilePreviewAccept = 'image';
|
||||
|
||||
export interface LocalFilePreviewUrlParams {
|
||||
accept?: LocalFilePreviewAccept;
|
||||
/**
|
||||
* Allows previewing one user-selected file outside approved workspace roots.
|
||||
* This is only for renderer previews and must not expand agent file access.
|
||||
*/
|
||||
allowExternalFile?: boolean;
|
||||
path: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from '../branches';
|
||||
import { getGitAheadBehind, getGitBranch } from '../info';
|
||||
import { getGitBranchDiff, getGitWorkingTreeFiles, getGitWorkingTreePatches } from '../workingTree';
|
||||
import { listGitWorktrees, parseGitWorktreeList } from '../worktrees';
|
||||
|
||||
const git = (cwd: string, ...args: string[]): string =>
|
||||
execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
||||
@@ -63,67 +62,6 @@ describe('branch read operations', () => {
|
||||
it('listGitRemoteBranches returns [] when there is no origin', async () => {
|
||||
expect(await listGitRemoteBranches(repo)).toEqual([]);
|
||||
});
|
||||
|
||||
it('parseGitWorktreeList handles branch, detached, and locked records', () => {
|
||||
expect(
|
||||
parseGitWorktreeList(
|
||||
[
|
||||
'worktree /repo',
|
||||
'HEAD 1111111111111111111111111111111111111111',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /repo-linked',
|
||||
'HEAD 2222222222222222222222222222222222222222',
|
||||
'detached',
|
||||
'locked moving patch',
|
||||
'',
|
||||
].join('\0'),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
branch: 'main',
|
||||
head: '1111111111111111111111111111111111111111',
|
||||
path: '/repo',
|
||||
},
|
||||
{
|
||||
detached: true,
|
||||
head: '2222222222222222222222222222222222222222',
|
||||
locked: true,
|
||||
lockReason: 'moving patch',
|
||||
path: '/repo-linked',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('listGitWorktrees marks the current worktree and includes dirty status', async () => {
|
||||
git(repo, 'branch', 'feature');
|
||||
const worktreeParent = await mkdtemp(path.join(tmpdir(), 'lfs-worktree-parent-'));
|
||||
cleanup.push(worktreeParent);
|
||||
const linked = path.join(worktreeParent, 'linked');
|
||||
git(repo, 'worktree', 'add', linked, 'feature');
|
||||
await writeFile(path.join(linked, 'new.txt'), 'new\n');
|
||||
|
||||
const worktrees = await listGitWorktrees(repo);
|
||||
|
||||
// `git worktree list` reports paths with symlinks resolved (e.g. macOS
|
||||
// /var -> /private/var), so compare against the realpath of each temp dir.
|
||||
expect(worktrees).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
branch: 'main',
|
||||
current: true,
|
||||
path: await realpath(repo),
|
||||
status: expect.objectContaining({ clean: true }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
branch: 'feature',
|
||||
current: false,
|
||||
path: await realpath(linked),
|
||||
status: expect.objectContaining({ added: 1, clean: false, total: 1 }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkoutGitBranch', () => {
|
||||
|
||||
@@ -3,4 +3,3 @@ export * from './info';
|
||||
export * from './repoType';
|
||||
export * from './types';
|
||||
export * from './workingTree';
|
||||
export * from './worktrees';
|
||||
|
||||
@@ -28,20 +28,6 @@ export interface GitWorkingTreeStatus {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GitWorktreeListItem {
|
||||
bare?: boolean;
|
||||
branch?: string;
|
||||
current: boolean;
|
||||
detached?: boolean;
|
||||
head?: string;
|
||||
locked?: boolean;
|
||||
lockReason?: string;
|
||||
path: string;
|
||||
prunable?: boolean;
|
||||
pruneReason?: string;
|
||||
status?: GitWorkingTreeStatus;
|
||||
}
|
||||
|
||||
export interface GitAheadBehind {
|
||||
ahead: number;
|
||||
behind: number;
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { getGitWorkingTreeStatus } from './info';
|
||||
import type { GitWorkingTreeStatus, GitWorktreeListItem } from './types';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const normalizeBranchRef = (ref: string): string =>
|
||||
ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref;
|
||||
|
||||
const safeRealpath = async (target: string): Promise<string> => {
|
||||
try {
|
||||
return await realpath(target);
|
||||
} catch {
|
||||
return path.resolve(target);
|
||||
}
|
||||
};
|
||||
|
||||
interface ParsedWorktree {
|
||||
bare?: boolean;
|
||||
branch?: string;
|
||||
detached?: boolean;
|
||||
head?: string;
|
||||
locked?: boolean;
|
||||
lockReason?: string;
|
||||
path: string;
|
||||
prunable?: boolean;
|
||||
pruneReason?: string;
|
||||
}
|
||||
|
||||
export const parseGitWorktreeList = (stdout: string): ParsedWorktree[] => {
|
||||
const records: ParsedWorktree[] = [];
|
||||
let current: ParsedWorktree | undefined;
|
||||
|
||||
for (const token of stdout.split('\0')) {
|
||||
if (!token) {
|
||||
if (current) {
|
||||
records.push(current);
|
||||
current = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const [key, ...rest] = token.split(' ');
|
||||
const value = rest.join(' ');
|
||||
|
||||
if (key === 'worktree') {
|
||||
if (current) records.push(current);
|
||||
current = { path: value };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'HEAD': {
|
||||
current.head = value;
|
||||
break;
|
||||
}
|
||||
case 'bare': {
|
||||
current.bare = true;
|
||||
break;
|
||||
}
|
||||
case 'branch': {
|
||||
current.branch = normalizeBranchRef(value);
|
||||
break;
|
||||
}
|
||||
case 'detached': {
|
||||
current.detached = true;
|
||||
break;
|
||||
}
|
||||
case 'locked': {
|
||||
current.locked = true;
|
||||
current.lockReason = value || undefined;
|
||||
break;
|
||||
}
|
||||
case 'prunable': {
|
||||
current.prunable = true;
|
||||
current.pruneReason = value || undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current) records.push(current);
|
||||
return records;
|
||||
};
|
||||
|
||||
const readStatus = async (worktree: ParsedWorktree): Promise<GitWorkingTreeStatus | undefined> => {
|
||||
if (worktree.bare || worktree.prunable) return undefined;
|
||||
return getGitWorkingTreeStatus(worktree.path);
|
||||
};
|
||||
|
||||
export const listGitWorktrees = async (dirPath: string): Promise<GitWorktreeListItem[]> => {
|
||||
try {
|
||||
const [{ stdout: rootStdout }, { stdout }] = await Promise.all([
|
||||
execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: dirPath, timeout: 5000 }),
|
||||
execFileAsync('git', ['worktree', 'list', '--porcelain', '-z'], {
|
||||
cwd: dirPath,
|
||||
timeout: 5000,
|
||||
}),
|
||||
]);
|
||||
|
||||
const currentRoot = await safeRealpath(rootStdout.trim());
|
||||
const parsed = parseGitWorktreeList(stdout);
|
||||
const statuses = await Promise.all(parsed.map(readStatus));
|
||||
|
||||
return Promise.all(
|
||||
parsed.map(async (worktree, index): Promise<GitWorktreeListItem> => {
|
||||
const worktreePath = await safeRealpath(worktree.path);
|
||||
return {
|
||||
...worktree,
|
||||
current: worktreePath === currentRoot,
|
||||
path: worktree.path,
|
||||
status: statuses[index],
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -304,21 +304,21 @@ export default {
|
||||
'channel.statusQueued': 'Queued',
|
||||
'channel.statusStarting': 'Starting',
|
||||
|
||||
'transfer.title': 'Move',
|
||||
'transfer.copyTo': 'Copy to...',
|
||||
'transfer.desc': 'Move this Agent to another Workspace or your personal account.',
|
||||
'transfer.button': 'Move',
|
||||
'transfer.selectTarget': 'Move Agent to',
|
||||
'transfer.title': 'Transfer',
|
||||
'transfer.copyTo': 'Copy To',
|
||||
'transfer.desc': 'Transfer this agent to another workspace or your personal account.',
|
||||
'transfer.button': 'Transfer',
|
||||
'transfer.selectTarget': 'Transfer Agent To',
|
||||
'transfer.searchWorkspace': 'Search workspaces...',
|
||||
'transfer.personalAccount': 'Personal Account',
|
||||
'transfer.confirm.title': 'Move Agent',
|
||||
'transfer.confirm.title': 'Transfer Agent',
|
||||
'transfer.confirm.desc':
|
||||
'This will move the agent and all associated data (topics, messages, files, etc.) to the target workspace.',
|
||||
'transfer.confirm.warning': "Some links won't move:",
|
||||
'transfer.confirm.warning': "Some features don't transfer:",
|
||||
'transfer.confirm.plugins': 'Custom plugins may not be available in the target workspace',
|
||||
'transfer.confirm.chatGroups': 'Multi-agent group associations will be removed',
|
||||
'transfer.confirm.botChannels': 'Bot channel connections may need to be refreshed after moving',
|
||||
'transfer.success': 'Agent moved successfully',
|
||||
'transfer.transferTo': 'Move to...',
|
||||
'transfer.error': 'Failed to move agent',
|
||||
'transfer.confirm.botChannels': 'Bot channel connections may need to be refreshed after transfer',
|
||||
'transfer.success': 'Agent transferred successfully',
|
||||
'transfer.transferTo': 'Transfer To',
|
||||
'transfer.error': 'Failed to transfer agent',
|
||||
} as const;
|
||||
|
||||
@@ -53,7 +53,6 @@ export default {
|
||||
'builtinCopilot': 'Built-in Copilot',
|
||||
'chatList.expandMessage': 'Expand Message',
|
||||
'chatList.longMessageDetail': 'View Details',
|
||||
'chatList.refreshing': 'Fetching latest messages...',
|
||||
'clearCurrentMessages': 'Clear current session messages',
|
||||
'compressedHistory': 'Compressed History',
|
||||
'compression.cancel': 'Uncompress',
|
||||
|
||||
@@ -17,7 +17,6 @@ export default {
|
||||
'navigation.memoryIdentities': 'Memory - Identities',
|
||||
'navigation.memoryPreferences': 'Memory - Preferences',
|
||||
'navigation.noPages': 'No pages yet',
|
||||
'navigation.observation': 'Observation Mode',
|
||||
'navigation.onboarding': 'Onboarding',
|
||||
'navigation.page': 'Page',
|
||||
'navigation.pages': 'Pages',
|
||||
@@ -30,22 +29,6 @@ export default {
|
||||
'navigation.task': 'Task',
|
||||
'navigation.tasks': 'Tasks',
|
||||
'navigation.unpin': 'Unpin',
|
||||
'fleet.addColumn': 'Add column',
|
||||
'fleet.createTask': 'Create task',
|
||||
'fleet.reply': 'Reply',
|
||||
'fleet.allShown': 'All running tasks are shown',
|
||||
'fleet.backToHome': 'Back to home',
|
||||
'fleet.closeColumn': 'Close column',
|
||||
'fleet.empty': 'No tasks open',
|
||||
'fleet.emptyDesc': 'Pick a running task on the left, or use + to add a column.',
|
||||
'fleet.noRunningTasks': 'No running tasks',
|
||||
'fleet.openInChat': 'Open in chat',
|
||||
'fleet.runningTasks': 'Running Tasks',
|
||||
'fleet.status.idle': 'Idle',
|
||||
'fleet.status.paused': 'Paused',
|
||||
'fleet.status.running': 'Running',
|
||||
'fleet.status.scheduled': 'Scheduled',
|
||||
'fleet.tooltip': 'View all agents side by side',
|
||||
'notification.finishChatGeneration': 'AI message generation completed',
|
||||
'tab.closeCurrentTab': 'Close Tab',
|
||||
'tab.closeLeftTabs': 'Close Tabs to the Left',
|
||||
|
||||
@@ -8,9 +8,6 @@ export default {
|
||||
'features.assistantMessageGroup.desc':
|
||||
'Group agent messages and their tool call results together for display',
|
||||
'features.assistantMessageGroup.title': 'Agent Message Grouping',
|
||||
'features.fleet.desc':
|
||||
'Show the Fleet entry in the title bar — a side-by-side dashboard of all running tasks across your agents.',
|
||||
'features.fleet.title': 'Fleet View',
|
||||
'features.imessage.desc':
|
||||
'Connect agents to iMessage through the local LobeHub Desktop BlueBubbles bridge.',
|
||||
'features.imessage.title': 'iMessage Channel',
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export default {
|
||||
'_cloud.officialProvider': '{{name}} Official Model Service',
|
||||
'about.title': 'About',
|
||||
'agentImport.action': 'Copy to Workspace...',
|
||||
'agentImport.action': 'Import to workspace…',
|
||||
'agentImport.description':
|
||||
'Create an independent copy in a Workspace. The original Agent stays in your personal account.',
|
||||
'agentImport.failed': 'Failed to copy agent.',
|
||||
'Fork a copy of this agent into one of your workspaces. The original stays in your personal space — no sync after import.',
|
||||
'agentImport.failed': 'Failed to import agent.',
|
||||
'agentImport.modal.configIncluded': 'Agent configuration is copied by default.',
|
||||
'agentImport.modal.confirm': 'Copy',
|
||||
'agentImport.modal.confirm': 'Import',
|
||||
'agentImport.modal.includeHistory': 'Copy topics and messages',
|
||||
'agentImport.modal.includeHistoryDesc':
|
||||
'Optional. Copies this agent’s conversation history into the new agent.',
|
||||
'agentImport.modal.knowledgeNotice': 'Knowledge bindings and files are not copied yet.',
|
||||
'agentImport.success': 'Agent copied to {{name}}.',
|
||||
'agentImport.title': 'Copy to Workspace',
|
||||
'agentImport.success': 'Agent imported to {{name}}.',
|
||||
'agentImport.title': 'Import to workspace',
|
||||
'accountDeletion.cancelButton': 'Cancel Deletion',
|
||||
'accountDeletion.cancelConfirmTitle': 'Cancel account deletion request?',
|
||||
'accountDeletion.cancelFailed': 'Failed to cancel deletion request',
|
||||
@@ -1033,19 +1033,19 @@ When I am ___, I need ___
|
||||
'[Skill Request] Summarize the skill you need in one sentence',
|
||||
'skillStore.wantMore.reachedEnd': "You've reached the end. Can't find what you need?",
|
||||
'startConversation': 'Start Conversation',
|
||||
'storage.actions.transfer.button': 'Move to...',
|
||||
'storage.actions.transfer.button': 'Transfer To',
|
||||
'storage.actions.transfer.desc':
|
||||
'Move agents and their data to another Workspace or your personal account. The originals leave the current space. LobeAI cannot be moved; copy it instead.',
|
||||
'storage.actions.transfer.title': 'Move Agents',
|
||||
'storage.actions.transferAgentGroups.button': 'Move to...',
|
||||
'Move agents and their data to a workspace you have access to. LobeAI, the default inbox Agent, cannot be transferred; use Copy Agents to copy it to a workspace or personal account instead.',
|
||||
'storage.actions.transfer.title': 'Agents Migration',
|
||||
'storage.actions.transferAgentGroups.button': 'Transfer To',
|
||||
'storage.actions.transferAgentGroups.desc':
|
||||
'Move groups, member Agents, and group conversation data to another Workspace or your personal account.',
|
||||
'storage.actions.transferAgentGroups.title': 'Move Groups',
|
||||
'storage.actions.copyLobeAI.button': 'Copy to...',
|
||||
'Move agent groups, their members, and group conversation data to a workspace you have access to.',
|
||||
'storage.actions.transferAgentGroups.title': 'Agent Groups Migration',
|
||||
'storage.actions.copyLobeAI.button': 'Copy To',
|
||||
'storage.actions.copyLobeAI.desc':
|
||||
'Keep the originals and create independent copies in another Workspace or your personal account. Topics and messages are optional.',
|
||||
'storage.actions.copyLobeAI.title': 'Copy Agents',
|
||||
'storage.actions.copyAgentGroups.button': 'Copy to...',
|
||||
'Copy agents, including LobeAI, into another workspace or personal account. Topics and messages are optional.',
|
||||
'storage.actions.copyLobeAI.title': 'Agents Copy',
|
||||
'storage.actions.copyAgentGroups.button': 'Copy To',
|
||||
'storage.actions.copyAgentGroups.desc':
|
||||
'Copy agent groups and their member agents into another workspace or personal account.',
|
||||
'storage.actions.copyAgentGroups.title': 'Agent Groups Copy',
|
||||
@@ -1648,20 +1648,19 @@ When I am ___, I need ___
|
||||
'You will lose access to "{{name}}" immediately. You can rejoin only if you are invited again.',
|
||||
'workspace.general.transferAgents.modal.back': 'Back',
|
||||
'workspace.general.transferAgents.modal.continue': 'Continue',
|
||||
'workspace.general.transferAgents.modal.failed': 'Failed to move agents',
|
||||
'workspace.general.transferAgents.modal.failed': 'Failed to transfer agents',
|
||||
'workspace.general.transferAgents.modal.loadFailed': 'Failed to load agents',
|
||||
'workspace.general.transferAgents.modal.noAgents': 'No agents in this workspace',
|
||||
'workspace.general.transferAgents.modal.selectAgents': 'Select Agents to move to {{target}}.',
|
||||
'workspace.general.transferAgents.modal.selectAgents': 'Select agents to transfer to {{target}}.',
|
||||
'workspace.general.transferAgents.modal.selectPlaceholder':
|
||||
'Select workspace or personal account...',
|
||||
'workspace.general.transferAgents.modal.selectTarget':
|
||||
'Choose where to move the Agents. They will leave the current space.',
|
||||
'Choose a workspace or personal account to transfer agents to.',
|
||||
'workspace.general.transferAgents.modal.selected': 'selected',
|
||||
'workspace.general.transferAgents.modal.selectedAgent':
|
||||
'This Agent will move to {{target}} and leave the current space.',
|
||||
'workspace.general.transferAgents.modal.success': '{{count}} agent(s) moved',
|
||||
'workspace.general.transferAgents.modal.title': 'Move Agents',
|
||||
'workspace.general.transferAgents.modal.transfer': 'Move {{count}} agent(s)',
|
||||
'workspace.general.transferAgents.modal.selectedAgent': 'Agent to transfer to {{target}}.',
|
||||
'workspace.general.transferAgents.modal.success': '{{count}} agent(s) transferred successfully',
|
||||
'workspace.general.transferAgents.modal.title': 'Transfer Agents',
|
||||
'workspace.general.transferAgents.modal.transfer': 'Transfer {{count}} agent(s)',
|
||||
'workspace.general.transferAgents.modal.warning':
|
||||
'Custom plugins may not be available and multi-agent group associations will be removed.',
|
||||
'workspace.general.transferAgents.personalAccount': 'Personal Account',
|
||||
@@ -1685,10 +1684,10 @@ When I am ___, I need ___
|
||||
'workspace.general.copyLobeAI.modal.back': 'Back',
|
||||
'workspace.general.copyLobeAI.modal.continue': 'Continue',
|
||||
'workspace.general.copyLobeAI.modal.copyOptions.config.desc':
|
||||
'Required. Copies the model, prompt, tools, and Agent profile into a new Agent.',
|
||||
'Required. Copies the model, prompt, tools, and Agent profile.',
|
||||
'workspace.general.copyLobeAI.modal.copyOptions.config.title': 'Agent configuration',
|
||||
'workspace.general.copyLobeAI.modal.copyOptions.history.desc':
|
||||
'Optional. Copies selected Agents’ topics and messages into the new Agents.',
|
||||
'Optional. Copies selected agents’ topics and messages into the new agents.',
|
||||
'workspace.general.copyLobeAI.modal.copyOptions.history.title': 'Topics and messages',
|
||||
'workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.reason':
|
||||
'Not supported yet. Reconnect them in the target workspace or personal account after copying.',
|
||||
@@ -1702,17 +1701,15 @@ When I am ___, I need ___
|
||||
'workspace.general.copyLobeAI.modal.failed': 'Failed to copy agents',
|
||||
'workspace.general.copyLobeAI.modal.includeHistory': 'Copy topics and messages',
|
||||
'workspace.general.copyLobeAI.modal.includeHistoryDesc':
|
||||
'Optional. Copies selected Agents’ conversation history into the new Agents.',
|
||||
'Optional. Copies selected agents’ conversation history into the new agents.',
|
||||
'workspace.general.copyLobeAI.modal.loadFailed': 'Failed to load agents',
|
||||
'workspace.general.copyLobeAI.modal.noAgents': 'No agents available to copy',
|
||||
'workspace.general.copyLobeAI.modal.selected': 'selected',
|
||||
'workspace.general.copyLobeAI.modal.selectedAgent':
|
||||
'This Agent will be copied. The original stays where it is.',
|
||||
'workspace.general.copyLobeAI.modal.selectAgents':
|
||||
'Select Agents to copy. Originals stay where they are.',
|
||||
'workspace.general.copyLobeAI.modal.selectedAgent': 'Agent to copy.',
|
||||
'workspace.general.copyLobeAI.modal.selectAgents': 'Select agents to copy.',
|
||||
'workspace.general.copyLobeAI.modal.selectPlaceholder': 'Select workspace or personal account...',
|
||||
'workspace.general.copyLobeAI.modal.selectTarget':
|
||||
'Choose where to create the copies. The originals stay where they are.',
|
||||
'Choose the target workspace or personal account. Agent configuration is copied by default.',
|
||||
'workspace.general.copyLobeAI.modal.success': '{{count}} agent(s) copied',
|
||||
'workspace.general.copyLobeAI.modal.title': 'Copy Agents',
|
||||
'workspace.general.copyLobeAI.modal.untitledAgent': 'Untitled Agent',
|
||||
|
||||
@@ -79,10 +79,4 @@ export { consumeStreamUntilDone } from './utils/consumeStream';
|
||||
export { AgentRuntimeError } from './utils/createError';
|
||||
export { getModelPropertyWithFallback } from './utils/getFallbackModelProperty';
|
||||
export { getModelPricing } from './utils/getModelPricing';
|
||||
export {
|
||||
applyModelExtendParams,
|
||||
type ApplyModelExtendParamsContext,
|
||||
type ModelExtendParams,
|
||||
resolveDefaultThinkingLevelForModel,
|
||||
} from './utils/modelExtendParams';
|
||||
export { parseDataUri } from './utils/uriParser';
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { LobeAgentChatConfig } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyModelExtendParams, resolveDefaultThinkingLevelForModel } from './modelExtendParams';
|
||||
|
||||
const chatConfig = (config: Partial<LobeAgentChatConfig> = {}): LobeAgentChatConfig =>
|
||||
({ ...config }) as LobeAgentChatConfig;
|
||||
|
||||
describe('applyModelExtendParams', () => {
|
||||
it('returns empty when the model has no extend params', () => {
|
||||
expect(
|
||||
applyModelExtendParams({
|
||||
chatConfig: chatConfig({ enableReasoning: true }),
|
||||
extendParams: undefined,
|
||||
model: 'gpt-4',
|
||||
}),
|
||||
).toEqual({});
|
||||
|
||||
expect(
|
||||
applyModelExtendParams({
|
||||
chatConfig: chatConfig({ thinkingLevel3: 'high' }),
|
||||
extendParams: [],
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
// Gemini 3 Pro via the agent path (provider=lobehub) billed reasoning tokens but
|
||||
// returned empty thinking summaries because thinkingLevel never reached the
|
||||
// request. With the model's extendParams present, thinkingLevel must default
|
||||
// to 'high' even when the chat config does not set thinkingLevel3.
|
||||
it('defaults thinkingLevel to high for gemini-3.1-pro-preview (thinkingLevel3)', () => {
|
||||
const result = applyModelExtendParams({
|
||||
chatConfig: chatConfig({}),
|
||||
extendParams: ['thinkingLevel3', 'urlContext'],
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
});
|
||||
|
||||
expect(result.thinkingLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('honors an explicit thinkingLevel3 config value', () => {
|
||||
const result = applyModelExtendParams({
|
||||
chatConfig: chatConfig({ thinkingLevel3: 'medium' }),
|
||||
extendParams: ['thinkingLevel3'],
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
});
|
||||
|
||||
expect(result.thinkingLevel).toBe('medium');
|
||||
});
|
||||
|
||||
it('forwards urlContext only when enabled in the chat config', () => {
|
||||
expect(
|
||||
applyModelExtendParams({
|
||||
chatConfig: chatConfig({ urlContext: true }),
|
||||
extendParams: ['thinkingLevel3', 'urlContext'],
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
}).urlContext,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
applyModelExtendParams({
|
||||
chatConfig: chatConfig({}),
|
||||
extendParams: ['thinkingLevel3', 'urlContext'],
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
}).urlContext,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves reasoning effort variants', () => {
|
||||
expect(
|
||||
applyModelExtendParams({
|
||||
chatConfig: chatConfig({ reasoningEffort: 'high' }),
|
||||
extendParams: ['reasoningEffort'],
|
||||
model: 'some-model',
|
||||
}).reasoning_effort,
|
||||
).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDefaultThinkingLevelForModel', () => {
|
||||
it('falls back to high without a model', () => {
|
||||
expect(resolveDefaultThinkingLevelForModel()).toBe('high');
|
||||
});
|
||||
|
||||
it('uses per-model defaults', () => {
|
||||
expect(resolveDefaultThinkingLevelForModel('gemini-3.5-flash')).toBe('medium');
|
||||
expect(resolveDefaultThinkingLevelForModel('gemini-3.1-flash-lite')).toBe('minimal');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user