♻️ refactor(agent): run callAgent as deferred tool

This commit is contained in:
Arvin Xu
2026-06-14 02:17:02 +08:00
parent 568701ea77
commit 807cc8bd87
11 changed files with 197 additions and 315 deletions
+10 -6
View File
@@ -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',
+11 -12
View File
@@ -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 [
@@ -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) {