mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
♻️ refactor(agent): run callAgent as deferred tool
This commit is contained in:
@@ -111,7 +111,7 @@ First check the repo root for `.env`:
|
||||
Do not start the standalone e2e server as the product under test.
|
||||
|
||||
Use `scripts/init-dev-env.sh`. It follows the e2e setup pattern — Postgres,
|
||||
migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
|
||||
Redis, migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
|
||||
skill and starts the repo's dev server (`pnpm run dev:next` / `bun run dev`),
|
||||
not `e2e/scripts/setup.ts --start`. The script hard-blocks when root `.env`
|
||||
exists, so it cannot accidentally override a user's local config. When `.env`
|
||||
@@ -132,19 +132,19 @@ fi
|
||||
Bootstrap flow when no `.env` exists:
|
||||
|
||||
```bash
|
||||
# From repo root. Managed DB flow requires Docker Desktop.
|
||||
# From repo root. Managed Postgres/Redis flow requires Docker Desktop.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
```
|
||||
|
||||
If using an existing Postgres instead of the managed Docker DB, set
|
||||
`DATABASE_URL` and skip `setup-db`:
|
||||
`DATABASE_URL` and `REDIS_URL`, then skip `setup-db`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
|
||||
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
|
||||
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
```
|
||||
|
||||
For backend-only checks, `dev-next` is available, but Web smoke needs the
|
||||
@@ -170,6 +170,9 @@ Default script env:
|
||||
- `APP_URL=http://localhost:3010`
|
||||
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
|
||||
- `DATABASE_DRIVER=node`
|
||||
- `AGENT_RUNTIME_MODE=queue` so backend-only agent runtime checks use the
|
||||
same queued execution path as production
|
||||
- `REDIS_URL=redis://localhost:6380` for queue-mode agent runtime state
|
||||
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
|
||||
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
|
||||
run `init-dev-env.sh qstash` in a separate terminal when the path under test
|
||||
@@ -177,6 +180,7 @@ Default script env:
|
||||
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
|
||||
- S3 mock vars
|
||||
- Managed DB container: `lobehub-agent-testing-postgres`
|
||||
- Managed Redis container: `lobehub-agent-testing-redis`
|
||||
|
||||
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
|
||||
onboarding already completed, plus a local API key in
|
||||
|
||||
@@ -48,14 +48,15 @@ curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
|
||||
```bash
|
||||
# Start backend only.
|
||||
# With root .env: use the existing local config.
|
||||
pnpm run dev:next
|
||||
# Agent runtime queue mode is required to mirror production async execution.
|
||||
AGENT_RUNTIME_MODE=queue pnpm run dev:next
|
||||
|
||||
# Without root .env: use the self-contained agent-testing env.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
|
||||
|
||||
# Full-stack SPA + backend. Required for Web smoke.
|
||||
# With root .env:
|
||||
bun run dev
|
||||
AGENT_RUNTIME_MODE=queue bun run dev
|
||||
|
||||
# Without root .env:
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
@@ -91,6 +92,8 @@ in doubt.
|
||||
| `ECONNREFUSED` | Server not running — start it |
|
||||
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
|
||||
| Stale data / old behavior | Server needs a restart to pick up code changes |
|
||||
| Agent call runs inline | Set `AGENT_RUNTIME_MODE=queue`, make sure `REDIS_URL` is configured, then restart the server |
|
||||
| Queue mode needs Redis | Run `init-dev-env.sh setup-db`, or provide `REDIS_URL=redis://...` for an existing Redis |
|
||||
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
|
||||
|
||||
Marketplace/community endpoints are not part of the local agent-testing auth
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
# Usage:
|
||||
# init-dev-env.sh env # print shell exports
|
||||
# init-dev-env.sh write [file] # write a source-able env file
|
||||
# init-dev-env.sh setup-db # start local Postgres and run migrations
|
||||
# init-dev-env.sh setup-db # start local Postgres/Redis and run migrations
|
||||
# init-dev-env.sh migrate # run DB migrations against the configured DB
|
||||
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
|
||||
# init-dev-env.sh qstash # run local Upstash QStash dev server
|
||||
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
|
||||
# init-dev-env.sh dev # exec `bun run dev` with this env
|
||||
# init-dev-env.sh clean-db # remove the managed Postgres container
|
||||
# init-dev-env.sh clean-db # remove the managed Postgres/Redis containers
|
||||
#
|
||||
# Overrides:
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres REDIS_PORT=6380 REDIS_CONTAINER=lobehub-agent-testing-redis QSTASH_DEV_PORT=8080
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -32,6 +32,9 @@ SERVER_PORT="${SERVER_PORT:-3010}"
|
||||
DB_PORT="${DB_PORT:-5433}"
|
||||
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
|
||||
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
|
||||
REDIS_PORT="${REDIS_PORT:-6380}"
|
||||
REDIS_CONTAINER="${REDIS_CONTAINER:-lobehub-agent-testing-redis}"
|
||||
REDIS_URL="${REDIS_URL:-redis://localhost:${REDIS_PORT}}"
|
||||
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
|
||||
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
|
||||
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
|
||||
@@ -54,6 +57,7 @@ guard_no_root_env() {
|
||||
}
|
||||
|
||||
apply_env() {
|
||||
export AGENT_RUNTIME_MODE="${AGENT_RUNTIME_MODE:-queue}"
|
||||
export APP_URL="${APP_URL:-http://localhost:${SERVER_PORT}}"
|
||||
export AUTH_EMAIL_VERIFICATION="${AUTH_EMAIL_VERIFICATION:-0}"
|
||||
export AUTH_SECRET="${AUTH_SECRET:-agent-testing-local-auth-secret-32chars}"
|
||||
@@ -69,6 +73,7 @@ apply_env() {
|
||||
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
|
||||
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
|
||||
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
|
||||
export REDIS_URL
|
||||
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
|
||||
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
|
||||
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
|
||||
@@ -78,6 +83,7 @@ apply_env() {
|
||||
env_keys() {
|
||||
printf '%s\n' \
|
||||
APP_URL \
|
||||
AGENT_RUNTIME_MODE \
|
||||
AUTH_EMAIL_VERIFICATION \
|
||||
AUTH_SECRET \
|
||||
DATABASE_DRIVER \
|
||||
@@ -92,6 +98,7 @@ env_keys() {
|
||||
QSTASH_NEXT_SIGNING_KEY \
|
||||
QSTASH_TOKEN \
|
||||
QSTASH_URL \
|
||||
REDIS_URL \
|
||||
S3_ACCESS_KEY_ID \
|
||||
S3_BUCKET \
|
||||
S3_ENDPOINT \
|
||||
@@ -137,6 +144,15 @@ wait_for_db() {
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
wait_for_redis() {
|
||||
printf ' waiting for Redis'
|
||||
until docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; do
|
||||
printf '.'
|
||||
sleep 1
|
||||
done
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
start_db() {
|
||||
require_docker
|
||||
|
||||
@@ -157,6 +173,25 @@ start_db() {
|
||||
wait_for_db
|
||||
}
|
||||
|
||||
start_redis() {
|
||||
require_docker
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
ok "Redis container already running: $REDIS_CONTAINER"
|
||||
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
docker start "$REDIS_CONTAINER" > /dev/null
|
||||
ok "started existing Redis container: $REDIS_CONTAINER"
|
||||
else
|
||||
docker run -d \
|
||||
--name "$REDIS_CONTAINER" \
|
||||
-p "${REDIS_PORT}:6379" \
|
||||
redis:7-alpine > /dev/null
|
||||
ok "created Redis container: $REDIS_CONTAINER"
|
||||
fi
|
||||
|
||||
wait_for_redis
|
||||
}
|
||||
|
||||
migrate_db() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
@@ -327,9 +362,11 @@ cmd_status() {
|
||||
apply_env
|
||||
echo "agent-testing local dev env:"
|
||||
note "APP_URL=$APP_URL"
|
||||
note "AGENT_RUNTIME_MODE=$AGENT_RUNTIME_MODE"
|
||||
note "DATABASE_URL=$DATABASE_URL"
|
||||
note "PORT=$PORT"
|
||||
note "QSTASH_URL=$QSTASH_URL"
|
||||
note "REDIS_URL=$REDIS_URL"
|
||||
if command -v docker > /dev/null 2>&1; then
|
||||
ok "docker CLI available"
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
|
||||
@@ -337,6 +374,11 @@ cmd_status() {
|
||||
else
|
||||
note "managed Postgres is not running: $DB_CONTAINER"
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
ok "managed Redis running: $REDIS_CONTAINER"
|
||||
else
|
||||
note "managed Redis is not running: $REDIS_CONTAINER"
|
||||
fi
|
||||
else
|
||||
bad "docker CLI is not available"
|
||||
fi
|
||||
@@ -373,6 +415,15 @@ cmd_clean_db() {
|
||||
else
|
||||
note "Postgres container not found: $DB_CONTAINER"
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
docker stop "$REDIS_CONTAINER" > /dev/null
|
||||
fi
|
||||
if docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
docker rm "$REDIS_CONTAINER" > /dev/null
|
||||
ok "removed Redis container: $REDIS_CONTAINER"
|
||||
else
|
||||
note "Redis container not found: $REDIS_CONTAINER"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
@@ -391,6 +442,7 @@ case "$COMMAND" in
|
||||
write) shift; write_env "${1:-}" ;;
|
||||
setup-db)
|
||||
start_db
|
||||
start_redis
|
||||
migrate_db
|
||||
;;
|
||||
migrate) migrate_db ;;
|
||||
|
||||
@@ -89,7 +89,6 @@ import { FileService } from '@/server/services/file';
|
||||
import { MessageService } from '@/server/services/message';
|
||||
import { OnboardingService } from '@/server/services/onboarding';
|
||||
import {
|
||||
type ServerAgentDelegationRunner,
|
||||
type ServerSubAgentRunner,
|
||||
type ToolExecutionResultResponse,
|
||||
type ToolExecutionService,
|
||||
@@ -406,97 +405,6 @@ const buildServerVirtualSubAgentRunner = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the per-tool-call delegated-agent runner used by the server
|
||||
* `agent-management.callAgent` runtime. It creates a parent-visible task card,
|
||||
* then starts the target agent in an isolation thread. Unlike `callSubAgent`,
|
||||
* this runner does not park the parent operation for async completion.
|
||||
*/
|
||||
const buildServerAgentDelegationRunner = (
|
||||
ctx: RuntimeExecutorContext,
|
||||
state: AgentState,
|
||||
parentMessageId: string,
|
||||
): ServerAgentDelegationRunner | undefined => {
|
||||
const execSubAgent = ctx.execSubAgent;
|
||||
if (!execSubAgent) return undefined;
|
||||
|
||||
const parentAgentId = state.metadata?.agentId;
|
||||
const topicId = ctx.topicId ?? state.metadata?.topicId;
|
||||
if (!parentAgentId || !topicId) return undefined;
|
||||
|
||||
return {
|
||||
run: async ({ agentId: targetAgentId, description, instruction, timeout }) => {
|
||||
if (state.metadata?.isSubAgent === true) {
|
||||
return {
|
||||
error: 'Agent delegation cannot be triggered from within a sub-agent run.',
|
||||
started: false,
|
||||
};
|
||||
}
|
||||
|
||||
const taskMessage = await ctx.messageModel.create({
|
||||
agentId: parentAgentId,
|
||||
content: '',
|
||||
groupId: state.metadata?.groupId ?? undefined,
|
||||
metadata: {
|
||||
instruction,
|
||||
subAgentId: targetAgentId,
|
||||
taskTitle: description,
|
||||
},
|
||||
parentId: parentMessageId,
|
||||
role: 'task',
|
||||
threadId: state.metadata?.threadId ?? undefined,
|
||||
topicId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = (await execSubAgent({
|
||||
agentId: targetAgentId,
|
||||
groupId: state.metadata?.groupId ?? undefined,
|
||||
instruction,
|
||||
parentMessageId: taskMessage.id,
|
||||
parentOperationId: ctx.operationId,
|
||||
timeout,
|
||||
title: description,
|
||||
topicId,
|
||||
})) as
|
||||
| { error?: string; operationId?: string; success?: boolean; threadId?: string }
|
||||
| undefined;
|
||||
|
||||
if (!result?.success) {
|
||||
const error = result?.error || 'Delegated agent run failed to start.';
|
||||
await ctx.messageModel.update(taskMessage.id, { content: error });
|
||||
|
||||
return {
|
||||
error,
|
||||
operationId: result?.operationId,
|
||||
started: false,
|
||||
taskMessageId: taskMessage.id,
|
||||
threadId: result?.threadId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
operationId: result.operationId,
|
||||
started: true,
|
||||
taskMessageId: taskMessage.id,
|
||||
threadId: result.threadId,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await ctx.messageModel.update(taskMessage.id, {
|
||||
content: `Task failed to start: ${message}`,
|
||||
});
|
||||
|
||||
return {
|
||||
error: message,
|
||||
started: false,
|
||||
taskMessageId: taskMessage.id,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
|
||||
kind === 'retry' && attempt <= maxRetries;
|
||||
|
||||
@@ -2555,11 +2463,6 @@ export const createRuntimeExecutors = (
|
||||
toolExecutionService.executeTool(chatToolPayload, {
|
||||
activeDeviceId: state.metadata?.activeDeviceId,
|
||||
agentId: state.metadata?.agentId,
|
||||
agentDelegation: buildServerAgentDelegationRunner(
|
||||
ctx,
|
||||
state,
|
||||
payload.parentMessageId,
|
||||
),
|
||||
documentId: state.metadata?.documentId,
|
||||
execSubAgent: ctx.execSubAgent,
|
||||
executionTimeoutMs: timeoutMs,
|
||||
@@ -3142,11 +3045,6 @@ export const createRuntimeExecutors = (
|
||||
toolExecutionService.executeTool(chatToolPayload, {
|
||||
activeDeviceId: state.metadata?.activeDeviceId,
|
||||
agentId: state.metadata?.agentId,
|
||||
agentDelegation: buildServerAgentDelegationRunner(
|
||||
ctx,
|
||||
state,
|
||||
payload.parentMessageId,
|
||||
),
|
||||
documentId: state.metadata?.documentId,
|
||||
execSubAgent: ctx.execSubAgent,
|
||||
executionTimeoutMs: timeoutMs,
|
||||
@@ -3567,7 +3465,7 @@ export const createRuntimeExecutors = (
|
||||
metadata: {
|
||||
instruction: task.instruction,
|
||||
taskTitle: task.description,
|
||||
...(targetAgentId && targetAgentId !== agentId && { subAgentId: targetAgentId }),
|
||||
...(targetAgentId && targetAgentId !== agentId && { targetAgentId }),
|
||||
},
|
||||
parentId: parentMessageId,
|
||||
role: 'task',
|
||||
@@ -3695,7 +3593,7 @@ export const createRuntimeExecutors = (
|
||||
metadata: {
|
||||
instruction: task.instruction,
|
||||
taskTitle: task.description,
|
||||
...(targetAgentId && targetAgentId !== agentId && { subAgentId: targetAgentId }),
|
||||
...(targetAgentId && targetAgentId !== agentId && { targetAgentId }),
|
||||
},
|
||||
parentId: parentMessageId,
|
||||
role: 'task',
|
||||
|
||||
@@ -125,6 +125,7 @@ describe('RuntimeExecutors', () => {
|
||||
|
||||
mockMessageModel = {
|
||||
create: vi.fn().mockResolvedValue({ id: 'msg-123' }),
|
||||
deleteMessage: vi.fn().mockResolvedValue({ success: true }),
|
||||
// call_llm does a parent existence preflight; return a truthy row by
|
||||
// default so existing tests don't have to stub it.
|
||||
findById: vi.fn().mockResolvedValue({ id: 'msg-existing' }),
|
||||
@@ -4893,22 +4894,20 @@ describe('RuntimeExecutors', () => {
|
||||
expect((result.nextContext?.payload as any).stop).toBe(true);
|
||||
});
|
||||
|
||||
it('call_tool injects agentDelegation runner for server callAgent delegation', async () => {
|
||||
const mockExecSubAgent = vi
|
||||
it('call_tool lets server callAgent run as a deferred tool via the subAgent runner', async () => {
|
||||
const mockExecVirtualSubAgent = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
|
||||
const ctxWithCallback = {
|
||||
...ctx,
|
||||
execSubAgent: mockExecSubAgent,
|
||||
execVirtualSubAgent: mockExecVirtualSubAgent,
|
||||
topicId: 'topic-123',
|
||||
};
|
||||
|
||||
mockMessageModel.create
|
||||
.mockResolvedValueOnce({ id: 'task-msg-id' })
|
||||
.mockResolvedValueOnce({ id: 'tool-msg-id' });
|
||||
mockMessageModel.create.mockResolvedValueOnce({ id: 'tool-msg-id' });
|
||||
mockToolExecutionService.executeTool.mockImplementation(
|
||||
async (_payload: any, context: any) => {
|
||||
const delegation = await context.agentDelegation.run({
|
||||
const subAgent = await context.subAgent.run({
|
||||
agentId: 'target-agent-id',
|
||||
description: 'Call agent target-agent',
|
||||
instruction: 'Do something useful',
|
||||
@@ -4916,15 +4915,16 @@ describe('RuntimeExecutors', () => {
|
||||
});
|
||||
|
||||
return {
|
||||
content: 'Delegated work to agent "target-agent-id"',
|
||||
content: '',
|
||||
deferred: true,
|
||||
executionTime: 10,
|
||||
state: {
|
||||
status: 'pending',
|
||||
subOperationId: subAgent.subOperationId,
|
||||
targetAgentId: 'target-agent-id',
|
||||
taskMessageId: delegation.taskMessageId,
|
||||
threadId: delegation.threadId,
|
||||
type: 'agentDelegation',
|
||||
threadId: subAgent.threadId,
|
||||
},
|
||||
success: delegation.started,
|
||||
success: subAgent.started,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -4954,97 +4954,43 @@ describe('RuntimeExecutors', () => {
|
||||
expect(mockMessageModel.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'parent-agent-id',
|
||||
metadata: expect.objectContaining({
|
||||
subAgentId: 'target-agent-id',
|
||||
plugin: expect.objectContaining({
|
||||
apiName: 'callAgent',
|
||||
identifier: 'lobe-agent-management',
|
||||
}),
|
||||
pluginState: { status: 'pending' },
|
||||
parentId: 'assistant-msg-id',
|
||||
role: 'task',
|
||||
role: 'tool',
|
||||
tool_call_id: 'tool-call-1',
|
||||
topicId: 'topic-123',
|
||||
}),
|
||||
);
|
||||
expect(mockExecSubAgent).toHaveBeenCalledWith(
|
||||
expect(mockExecVirtualSubAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'target-agent-id',
|
||||
instruction: 'Do something useful',
|
||||
parentMessageId: 'task-msg-id',
|
||||
parentMessageId: 'tool-msg-id',
|
||||
parentOperationId: 'op-123',
|
||||
title: 'Call agent target-agent',
|
||||
topicId: 'topic-123',
|
||||
}),
|
||||
);
|
||||
expect(result.nextContext?.phase).toBe('tool_result');
|
||||
expect((result.nextContext?.payload as any).stop).toBeUndefined();
|
||||
});
|
||||
|
||||
it('call_tool marks delegated task message when server callAgent delegation fails to start', async () => {
|
||||
const mockExecSubAgent = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ error: 'queue unavailable', operationId: 'child-op', success: false });
|
||||
const ctxWithCallback = {
|
||||
...ctx,
|
||||
execSubAgent: mockExecSubAgent,
|
||||
topicId: 'topic-123',
|
||||
};
|
||||
|
||||
mockMessageModel.create
|
||||
.mockResolvedValueOnce({ id: 'task-msg-id' })
|
||||
.mockResolvedValueOnce({ id: 'tool-msg-id' });
|
||||
mockToolExecutionService.executeTool.mockImplementation(
|
||||
async (_payload: any, context: any) => {
|
||||
const delegation = await context.agentDelegation.run({
|
||||
agentId: 'target-agent-id',
|
||||
description: 'Call agent target-agent',
|
||||
instruction: 'Do something useful',
|
||||
timeout: 1_800_000,
|
||||
});
|
||||
|
||||
return {
|
||||
content: delegation.error || 'Delegation failed',
|
||||
error: {
|
||||
code: 'AGENT_DELEGATION_START_FAILED',
|
||||
message: delegation.error,
|
||||
},
|
||||
executionTime: 10,
|
||||
state: {
|
||||
operationId: delegation.operationId,
|
||||
targetAgentId: 'target-agent-id',
|
||||
taskMessageId: delegation.taskMessageId,
|
||||
threadId: delegation.threadId,
|
||||
type: 'agentDelegation',
|
||||
},
|
||||
success: delegation.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.update).toHaveBeenCalledWith('task-msg-id', {
|
||||
content: 'queue unavailable',
|
||||
});
|
||||
expect(result.nextContext?.phase).toBe('tool_result');
|
||||
expect((result.nextContext?.payload as any).isSuccess).toBe(false);
|
||||
expect((result.nextContext?.payload as any).stop).toBeUndefined();
|
||||
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 () => {
|
||||
@@ -5080,7 +5026,7 @@ describe('RuntimeExecutors', () => {
|
||||
expect.objectContaining({
|
||||
agentId: 'parent-agent-id',
|
||||
metadata: expect.objectContaining({
|
||||
subAgentId: 'target-agent-id',
|
||||
targetAgentId: 'target-agent-id',
|
||||
}),
|
||||
role: 'task',
|
||||
parentId: 'tool-msg-id',
|
||||
|
||||
@@ -2941,12 +2941,12 @@ export class AiAgentService {
|
||||
});
|
||||
|
||||
// 3. Create hooks for updating Thread metadata and source message
|
||||
const threadHooks = this.createThreadHooks({
|
||||
logScope: options.logScope,
|
||||
sourceMessageId: parentMessageId,
|
||||
const threadHooks = this.createThreadHooks(
|
||||
thread.id,
|
||||
startedAt,
|
||||
threadId: thread.id,
|
||||
});
|
||||
parentMessageId,
|
||||
options.logScope,
|
||||
);
|
||||
// For the virtual sub-agent path, also register the completion bridge that
|
||||
// backfills the parent's placeholder tool message and resumes the parked
|
||||
// parent op once the child run is done. Registered last so its tool-message
|
||||
@@ -3185,13 +3185,12 @@ export class AiAgentService {
|
||||
* Create hooks for tracking Thread metadata updates during SubAgent execution.
|
||||
* Replaces the legacy createThreadMetadataCallbacks with the hooks system.
|
||||
*/
|
||||
private createThreadHooks(params: {
|
||||
logScope: 'execSubAgent' | 'execVirtualSubAgent';
|
||||
sourceMessageId: string;
|
||||
startedAt: string;
|
||||
threadId: string;
|
||||
}): AgentHook[] {
|
||||
const { logScope, sourceMessageId, startedAt, threadId } = params;
|
||||
private createThreadHooks(
|
||||
threadId: string,
|
||||
startedAt: string,
|
||||
sourceMessageId: string,
|
||||
logScope: 'execSubAgent' | 'execVirtualSubAgent',
|
||||
): AgentHook[] {
|
||||
let accumulatedToolCalls = 0;
|
||||
|
||||
return [
|
||||
|
||||
+38
-23
@@ -75,7 +75,7 @@ describe('agentManagementRuntime', () => {
|
||||
});
|
||||
|
||||
describe('callAgent', () => {
|
||||
it('fails when server agent delegation is unavailable', async () => {
|
||||
it('fails when the server sub-agent runner is unavailable', async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await runtime.callAgent(
|
||||
@@ -88,14 +88,13 @@ describe('agentManagementRuntime', () => {
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatchObject({ code: 'AGENT_DELEGATION_UNAVAILABLE' });
|
||||
expect(result.error).toMatchObject({ code: 'AGENT_CALL_UNAVAILABLE' });
|
||||
});
|
||||
|
||||
it('delegates server callAgent runs through the injected runner', async () => {
|
||||
it('returns a deferred tool result and forks the target agent through the sub-agent runner', async () => {
|
||||
const run = vi.fn().mockResolvedValue({
|
||||
operationId: 'op-child',
|
||||
started: true,
|
||||
taskMessageId: 'task-msg',
|
||||
subOperationId: 'op-child',
|
||||
threadId: 'thread-child',
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
@@ -109,7 +108,7 @@ describe('agentManagementRuntime', () => {
|
||||
timeout: 1234,
|
||||
},
|
||||
{
|
||||
agentDelegation: { run },
|
||||
subAgent: { run },
|
||||
toolManifestMap: {},
|
||||
},
|
||||
);
|
||||
@@ -120,22 +119,23 @@ describe('agentManagementRuntime', () => {
|
||||
instruction: 'Do delegated work',
|
||||
timeout: 1234,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result).toMatchObject({
|
||||
content: '',
|
||||
deferred: true,
|
||||
success: true,
|
||||
});
|
||||
expect(result.state).toMatchObject({
|
||||
operationId: 'op-child',
|
||||
status: 'pending',
|
||||
subOperationId: 'op-child',
|
||||
targetAgentId: 'agent-target',
|
||||
taskMessageId: 'task-msg',
|
||||
threadId: 'thread-child',
|
||||
type: 'agentDelegation',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns failure state when delegated run cannot start', async () => {
|
||||
it('returns a non-deferred failure when the target agent cannot start', async () => {
|
||||
const run = vi.fn().mockResolvedValue({
|
||||
error: 'queue unavailable',
|
||||
operationId: 'op-child',
|
||||
started: false,
|
||||
taskMessageId: 'task-msg',
|
||||
threadId: '',
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
|
||||
@@ -146,22 +146,37 @@ describe('agentManagementRuntime', () => {
|
||||
runAsTask: true,
|
||||
},
|
||||
{
|
||||
agentDelegation: { run },
|
||||
subAgent: { run },
|
||||
toolManifestMap: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatchObject({
|
||||
code: 'AGENT_DELEGATION_START_FAILED',
|
||||
message: 'queue unavailable',
|
||||
});
|
||||
expect(result.state).toMatchObject({
|
||||
operationId: 'op-child',
|
||||
targetAgentId: 'agent-target',
|
||||
taskMessageId: 'task-msg',
|
||||
type: 'agentDelegation',
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,48 +43,60 @@ export const agentManagementRuntime: ServerRuntimeRegistration = {
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const { agentId, instruction, taskTitle, timeout } = params;
|
||||
|
||||
// Server runtime delegates agent calls asynchronously because there is
|
||||
// no client-side `registerAfterCompletion` callback available to run a
|
||||
// synchronous agent handoff.
|
||||
if (!ctx.agentDelegation) {
|
||||
if (ctx.isSubAgent) {
|
||||
return {
|
||||
content: 'Agent delegation is not available in this runtime.',
|
||||
error: { code: 'AGENT_DELEGATION_UNAVAILABLE' },
|
||||
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 result = await ctx.agentDelegation.run({
|
||||
const { started, subOperationId, threadId } = await ctx.subAgent.run({
|
||||
agentId,
|
||||
description,
|
||||
instruction,
|
||||
timeout: timeout || 1_800_000,
|
||||
});
|
||||
|
||||
if (!result.started) {
|
||||
if (!started) {
|
||||
return {
|
||||
content: result.error || `Failed to delegate work to agent "${agentId}".`,
|
||||
error: { code: 'AGENT_DELEGATION_START_FAILED', message: result.error },
|
||||
state: {
|
||||
operationId: result.operationId,
|
||||
targetAgentId: agentId,
|
||||
taskMessageId: result.taskMessageId,
|
||||
threadId: result.threadId,
|
||||
type: 'agentDelegation',
|
||||
content: `Agent "${agentId}" failed to start.`,
|
||||
error: {
|
||||
code: 'AGENT_CALL_START_FAILED',
|
||||
message: `Agent "${agentId}" failed to start.`,
|
||||
},
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: `Delegated work to agent "${agentId}"${taskTitle ? `: ${taskTitle}` : ''}`,
|
||||
content: '',
|
||||
deferred: true,
|
||||
state: {
|
||||
operationId: result.operationId,
|
||||
status: 'pending',
|
||||
subOperationId,
|
||||
targetAgentId: agentId,
|
||||
taskMessageId: result.taskMessageId,
|
||||
threadId: result.threadId,
|
||||
type: 'agentDelegation',
|
||||
threadId,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
|
||||
@@ -53,50 +53,9 @@ export interface ServerSubAgentRunner {
|
||||
run: (params: ServerSubAgentRunParams) => Promise<ServerSubAgentRunResult>;
|
||||
}
|
||||
|
||||
export interface ServerAgentDelegationRunParams {
|
||||
/** Target agent id to execute the delegated work. */
|
||||
agentId: string;
|
||||
/** Short label shown in the parent conversation task card and thread title. */
|
||||
description: string;
|
||||
/** Detailed instruction/prompt for the delegated agent run. */
|
||||
instruction: string;
|
||||
/** Optional per-run timeout in milliseconds. */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ServerAgentDelegationRunResult {
|
||||
/** Human-readable failure detail when the delegated run could not start. */
|
||||
error?: string;
|
||||
/** The spawned child operation id. */
|
||||
operationId?: string;
|
||||
/** Whether the delegated run was actually started. */
|
||||
started: boolean;
|
||||
/** Parent conversation task-card message id. */
|
||||
taskMessageId?: string;
|
||||
/** Isolation thread holding the delegated agent's execution trace. */
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side runner for `lobe-agent-management.callAgent`.
|
||||
*
|
||||
* It creates a parent-conversation `role: task` card, then starts an isolated
|
||||
* run using the target agent. Unlike `subAgent`, it does not park/resume the
|
||||
* parent operation: this is a background delegation.
|
||||
*/
|
||||
export interface ServerAgentDelegationRunner {
|
||||
run: (params: ServerAgentDelegationRunParams) => Promise<ServerAgentDelegationRunResult>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionContext {
|
||||
/** Target device ID for device proxy tool calls */
|
||||
activeDeviceId?: string;
|
||||
/**
|
||||
* Server-side delegated-agent runner. Used by agent-management `callAgent`
|
||||
* to create a visible task card and run the target agent in an isolation
|
||||
* thread without relying on legacy exec_sub_agent tool-result states.
|
||||
*/
|
||||
agentDelegation?: ServerAgentDelegationRunner;
|
||||
/** Agent ID executing the tool call */
|
||||
agentId?: string;
|
||||
/** Current page document ID for page-scoped conversations */
|
||||
|
||||
@@ -28,7 +28,7 @@ interface TaskMessageProps {
|
||||
isLatestItem?: boolean;
|
||||
}
|
||||
|
||||
const TaskMessage = memo<TaskMessageProps>(({ id, index: _index, disableEditing }) => {
|
||||
const TaskMessage = memo<TaskMessageProps>(({ id, index, disableEditing }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
// Get message and actionsConfig from ConversationStore
|
||||
@@ -37,9 +37,7 @@ const TaskMessage = memo<TaskMessageProps>(({ id, index: _index, disableEditing
|
||||
|
||||
const { agentId, groupId, error, role, content, createdAt, metadata, taskDetail } = item;
|
||||
|
||||
const legacyTargetAgentId = (metadata as { targetAgentId?: string } | undefined)?.targetAgentId;
|
||||
const displayAgentId = metadata?.subAgentId || legacyTargetAgentId || agentId;
|
||||
const avatar = useAgentMeta(displayAgentId);
|
||||
const avatar = useAgentMeta(agentId);
|
||||
|
||||
// Get editing and generating state from ConversationStore
|
||||
const editing = useConversationStore(messageStateSelectors.isMessageEditing(id));
|
||||
@@ -60,7 +58,7 @@ const TaskMessage = memo<TaskMessageProps>(({ id, index: _index, disableEditing
|
||||
} else {
|
||||
openChatSettings();
|
||||
}
|
||||
}, [isInbox, openChatSettings, toggleSystemRole]);
|
||||
}, [isInbox]);
|
||||
|
||||
const onDoubleClick = useDoubleClickEdit({ disableEditing, error, id, role });
|
||||
|
||||
@@ -90,7 +88,7 @@ const TaskMessage = memo<TaskMessageProps>(({ id, index: _index, disableEditing
|
||||
>
|
||||
{taskDetail?.clientMode ? (
|
||||
<ClientTaskDetail
|
||||
agentId={displayAgentId !== 'supervisor' ? displayAgentId : undefined}
|
||||
agentId={agentId !== 'supervisor' ? agentId : undefined}
|
||||
groupId={groupId}
|
||||
messageId={id}
|
||||
taskDetail={taskDetail}
|
||||
|
||||
@@ -24,12 +24,8 @@ const TasksMessage = memo<TasksMessageProps>(({ id }) => {
|
||||
const actionsConfig = useConversationStore((s) => s.actionsBar?.assistant);
|
||||
const tasks = (item as UIChatMessage)?.tasks?.filter(Boolean) as UIChatMessage[] | undefined;
|
||||
|
||||
// Use first task's delegated target agent when available.
|
||||
const firstTaskMetadata = tasks?.[0]?.metadata;
|
||||
const firstTaskLegacyTargetAgentId = (firstTaskMetadata as { targetAgentId?: string } | undefined)
|
||||
?.targetAgentId;
|
||||
const firstTaskAgentId =
|
||||
firstTaskMetadata?.subAgentId || firstTaskLegacyTargetAgentId || tasks?.[0]?.agentId;
|
||||
// Use first task's agentId for avatar, or fallback to undefined
|
||||
const firstTaskAgentId = tasks?.[0]?.agentId;
|
||||
const avatar = useAgentMeta(firstTaskAgentId);
|
||||
|
||||
if (!tasks || tasks.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user