From 436399494501016a436359ecee39916913cb68c9 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Mon, 9 Mar 2026 01:17:56 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20support=20use=20remote=20de?= =?UTF-8?q?vice=20in=20IM=20integration=20(#12798)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * support timezone in system prompt refactor to improve user prompts refactor tool engine refactor tools map mode add bot callback service clean improve cli update agentic tracing refactor cli login refactor cli add device auth improve device gateway implement implement gateway pipeline support device Gateway connect support gateway * revert electron device * inject builtins agent prompts * update tracing * add testing * refactor the activeDeviceId * refactor BotCallbackService * fix test and lint * fix test and lint * add tests * fix tests * fix lint --- .agents/skills/agent-tracing/SKILL.md | 81 +- .agents/skills/testing/SKILL.md | 28 + .../main/controllers/RemoteServerConfigCtr.ts | 7 +- .../__tests__/RemoteServerConfigCtr.test.ts | 2 +- package.json | 8 +- packages/agent-runtime/package.json | 1 + packages/agent-runtime/src/types/state.ts | 19 +- packages/agent-runtime/src/utils/index.ts | 1 + .../src/utils/messageSelectors.test.ts | 97 +++ .../src/utils/messageSelectors.ts | 82 ++ packages/agent-tracing/package.json | 3 +- packages/agent-tracing/src/cli/index.ts | 6 +- packages/agent-tracing/src/cli/inspect.ts | 191 ++++- packages/agent-tracing/src/cli/partial.ts | 107 +++ packages/agent-tracing/src/cli/trace.ts | 24 - .../agent-tracing/src/store/file-store.ts | 32 + packages/agent-tracing/src/store/types.ts | 2 + packages/agent-tracing/src/viewer/index.ts | 530 +++++++++++- .../builtin-tool-remote-device/package.json | 12 + .../src/ExecutionRuntime/index.ts | 66 ++ .../src/ExecutionRuntime/types.ts | 7 + .../builtin-tool-remote-device/src/index.ts | 5 + .../src/manifest.ts | 44 + .../src/systemRole.ts | 34 + .../builtin-tool-remote-device/src/types.ts | 9 + packages/builtin-tools/package.json | 1 + packages/builtin-tools/src/index.ts | 7 + .../src/engine/messages/MessagesEngine.ts | 3 +- .../src/engine/messages/types.ts | 2 + .../src/engine/tools/ManifestLoader.ts | 13 + .../src/engine/tools/ToolResolver.ts | 121 +++ .../tools/__tests__/ManifestLoader.test.ts | 131 +++ .../tools/__tests__/ToolResolver.test.ts | 368 ++++++++ .../__tests__/buildStepToolDelta.test.ts | 153 ++++ .../__tests__/enableCheckerFactory.test.ts | 174 ++++ .../src/engine/tools/buildStepToolDelta.ts | 64 ++ .../src/engine/tools/enableCheckerFactory.ts | 52 ++ .../context-engine/src/engine/tools/index.ts | 18 +- .../context-engine/src/engine/tools/types.ts | 52 ++ .../context-engine/src/engine/tools/utils.ts | 16 +- .../src/providers/SystemDateProvider.ts | 11 +- .../__tests__/SystemDateProvider.test.ts | 105 +++ .../src/core/contextBuilders/anthropic.ts | 18 +- packages/types/src/agent/agencyConfig.ts | 1 + .../api/agent/webhooks/bot-callback/route.ts | 337 +------- src/envs/gateway.ts | 18 + src/helpers/toolEngineering/index.ts | 65 +- .../modules/AgentRuntime/RuntimeExecutors.ts | 152 +++- .../__tests__/RuntimeExecutors.test.ts | 107 +-- .../AgentToolsEngine/__tests__/index.test.ts | 132 +++ .../modules/Mecha/AgentToolsEngine/index.ts | 40 +- .../modules/Mecha/AgentToolsEngine/types.ts | 8 + .../__tests__/serverMessagesEngine.test.ts | 170 +++- .../modules/Mecha/ContextEngineering/index.ts | 41 +- .../modules/Mecha/ContextEngineering/types.ts | 4 + src/server/routers/lambda/aiAgent.ts | 6 +- src/server/services/agent/index.ts | 2 +- .../agentRuntime/AgentRuntimeService.test.ts | 2 +- .../agentRuntime/AgentRuntimeService.ts | 79 +- .../__tests__/completionWebhook.test.ts | 15 +- .../__tests__/executeSync.test.ts | 6 +- .../__tests__/stepLifecycleCallbacks.test.ts | 6 +- src/server/services/agentRuntime/types.ts | 18 +- .../execAgent.builtinRuntime.test.ts | 212 +++++ .../__tests__/execAgent.device.test.ts | 340 ++++++++ .../execAgent.deviceToolPipeline.test.ts | 301 +++++++ .../aiAgent/__tests__/execAgent.files.test.ts | 7 + .../__tests__/execAgent.threadId.test.ts | 15 + .../__tests__/execAgent.topicHistory.test.ts | 13 + src/server/services/aiAgent/index.ts | 205 ++++- src/server/services/bot/AgentBridgeService.ts | 56 +- src/server/services/bot/BotCallbackService.ts | 353 ++++++++ .../bot/__tests__/BotCallbackService.test.ts | 794 ++++++++++++++++++ .../__tests__/deviceProxy.test.ts | 294 +++++++ .../services/toolExecution/deviceProxy.ts | 115 +++ .../__tests__/localSystem.test.ts | 110 +++ .../__tests__/remoteDevice.test.ts | 73 ++ .../toolExecution/serverRuntimes/index.ts | 4 + .../serverRuntimes/localSystem.ts | 33 + .../serverRuntimes/remoteDevice.ts | 22 + src/server/services/toolExecution/types.ts | 2 + src/services/chat/chat.test.ts | 9 +- .../chat/mecha/contextEngineering.test.ts | 9 +- .../aiChat/actions/streamingExecutor.ts | 10 +- 84 files changed, 6212 insertions(+), 681 deletions(-) create mode 100644 packages/agent-runtime/src/utils/messageSelectors.test.ts create mode 100644 packages/agent-runtime/src/utils/messageSelectors.ts create mode 100644 packages/agent-tracing/src/cli/partial.ts delete mode 100644 packages/agent-tracing/src/cli/trace.ts create mode 100644 packages/builtin-tool-remote-device/package.json create mode 100644 packages/builtin-tool-remote-device/src/ExecutionRuntime/index.ts create mode 100644 packages/builtin-tool-remote-device/src/ExecutionRuntime/types.ts create mode 100644 packages/builtin-tool-remote-device/src/index.ts create mode 100644 packages/builtin-tool-remote-device/src/manifest.ts create mode 100644 packages/builtin-tool-remote-device/src/systemRole.ts create mode 100644 packages/builtin-tool-remote-device/src/types.ts create mode 100644 packages/context-engine/src/engine/tools/ManifestLoader.ts create mode 100644 packages/context-engine/src/engine/tools/ToolResolver.ts create mode 100644 packages/context-engine/src/engine/tools/__tests__/ManifestLoader.test.ts create mode 100644 packages/context-engine/src/engine/tools/__tests__/ToolResolver.test.ts create mode 100644 packages/context-engine/src/engine/tools/__tests__/buildStepToolDelta.test.ts create mode 100644 packages/context-engine/src/engine/tools/__tests__/enableCheckerFactory.test.ts create mode 100644 packages/context-engine/src/engine/tools/buildStepToolDelta.ts create mode 100644 packages/context-engine/src/engine/tools/enableCheckerFactory.ts create mode 100644 packages/context-engine/src/providers/__tests__/SystemDateProvider.test.ts create mode 100644 src/envs/gateway.ts create mode 100644 src/server/services/aiAgent/__tests__/execAgent.builtinRuntime.test.ts create mode 100644 src/server/services/aiAgent/__tests__/execAgent.device.test.ts create mode 100644 src/server/services/aiAgent/__tests__/execAgent.deviceToolPipeline.test.ts create mode 100644 src/server/services/bot/BotCallbackService.ts create mode 100644 src/server/services/bot/__tests__/BotCallbackService.test.ts create mode 100644 src/server/services/toolExecution/__tests__/deviceProxy.test.ts create mode 100644 src/server/services/toolExecution/deviceProxy.ts create mode 100644 src/server/services/toolExecution/serverRuntimes/__tests__/localSystem.test.ts create mode 100644 src/server/services/toolExecution/serverRuntimes/__tests__/remoteDevice.test.ts create mode 100644 src/server/services/toolExecution/serverRuntimes/localSystem.ts create mode 100644 src/server/services/toolExecution/serverRuntimes/remoteDevice.ts diff --git a/.agents/skills/agent-tracing/SKILL.md b/.agents/skills/agent-tracing/SKILL.md index d890514d95..49867b3fe2 100644 --- a/.agents/skills/agent-tracing/SKILL.md +++ b/.agents/skills/agent-tracing/SKILL.md @@ -28,9 +28,11 @@ packages/agent-tracing/ recorder/ index.ts # appendStepToPartial(), finalizeSnapshot() viewer/ - index.ts # Terminal rendering: renderSnapshot, renderStepDetail, renderMessageDetail, renderSummaryTable + index.ts # Terminal rendering: renderSnapshot, renderStepDetail, renderMessageDetail, renderSummaryTable, renderPayload, renderPayloadTools, renderMemory cli/ index.ts # CLI entry point (#!/usr/bin/env bun) + inspect.ts # Inspect command (default) + partial.ts # Partial snapshot commands (list, inspect, clean) index.ts # Barrel exports ``` @@ -46,19 +48,16 @@ packages/agent-tracing/ All commands run from the **repo root**: ```bash -# View latest trace (tree overview) -agent-tracing trace - -# View specific trace -agent-tracing trace +# View latest trace (tree overview, `inspect` is the default command) +agent-tracing +agent-tracing inspect +agent-tracing inspect +agent-tracing inspect latest # List recent snapshots agent-tracing list agent-tracing list -l 20 -# Inspect trace detail (overview) -agent-tracing inspect - # Inspect specific step (-s is short for --step) agent-tracing inspect -s 0 @@ -78,30 +77,84 @@ agent-tracing inspect -s 0 -e # View runtime context (-c is short for --context) agent-tracing inspect -s 0 -c +# View context engine input overview (-p is short for --payload) +agent-tracing inspect -p +agent-tracing inspect -s 0 -p + +# View available tools in payload (-T is short for --payload-tools) +agent-tracing inspect -T +agent-tracing inspect -s 0 -T + +# View user memory (-M is short for --memory) +agent-tracing inspect -M +agent-tracing inspect -s 0 -M + # Raw JSON output (-j is short for --json) agent-tracing inspect -j agent-tracing inspect -s 0 -j + +# List in-progress partial snapshots +agent-tracing partial list + +# Inspect a partial snapshot (defaults to latest) +agent-tracing partial inspect +agent-tracing partial inspect +agent-tracing partial inspect -j + +# Clean up stale partial snapshots +agent-tracing partial clean ``` +## Inspect Flag Reference + +| Flag | Short | Description | Default Step | +| ----------------- | ----- | ------------------------------------------------------------------------------------------------- | ------------ | +| `--step ` | `-s` | Target a specific step | — | +| `--messages` | `-m` | Messages context (CE input → params → LLM payload) | — | +| `--tools` | `-t` | Tool calls & results (what agent invoked) | — | +| `--events` | `-e` | Raw events (llm_start, llm_result, etc.) | — | +| `--context` | `-c` | Runtime context & payload (raw) | — | +| `--system-role` | `-r` | Full system role content | 0 | +| `--env` | | Environment context | 0 | +| `--payload` | `-p` | Context engine input overview (model, knowledge, tools summary, memory summary, platform context) | 0 | +| `--payload-tools` | `-T` | Available tools detail (plugin manifests + LLM function definitions) | 0 | +| `--memory` | `-M` | Full user memory (persona, identity, contexts, preferences, experiences) | 0 | +| `--diff ` | `-d` | Diff against step N (use with `-r` or `--env`) | — | +| `--msg ` | | Full content of message N from Final LLM Payload | — | +| `--msg-input ` | | Full content of message N from Context Engine Input | — | +| `--json` | `-j` | Output as JSON (combinable with any flag above) | — | + +Flags marked "Default Step: 0" auto-select step 0 if `--step` is not provided. All flags support `latest` or omitted traceId. + ## Typical Debug Workflow ```bash # 1. Trigger an agent operation in the dev UI # 2. See the overview -agent-tracing trace +agent-tracing inspect # 3. List all traces, get traceId agent-tracing list -# 4. Inspect a specific step's messages to see what was sent to the LLM +# 4. Quick overview of what was fed into context engine +agent-tracing inspect -p + +# 5. Inspect a specific step's messages to see what was sent to the LLM agent-tracing inspect TRACE_ID -s 0 -m -# 5. Drill into a truncated message for full content +# 6. Drill into a truncated message for full content agent-tracing inspect TRACE_ID -s 0 --msg 2 -# 6. Check tool calls and results -agent-tracing inspect 1 -t TRACE_ID -s +# 7. Check available tools vs actual tool calls +agent-tracing inspect -T # available tools +agent-tracing inspect -s 1 -t # actual tool calls & results + +# 8. Inspect user memory injected into the conversation +agent-tracing inspect -M + +# 9. Diff system role between steps (multi-step agents) +agent-tracing inspect TRACE_ID -r -d 2 ``` ## Key Types diff --git a/.agents/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md index 052291573c..886aa5862a 100644 --- a/.agents/skills/testing/SKILL.md +++ b/.agents/skills/testing/SKILL.md @@ -83,6 +83,34 @@ See `references/` for specific testing scenarios: - **Agent Runtime E2E testing**: `references/agent-runtime-e2e.md` - **Desktop Controller testing**: `references/desktop-controller-test.md` +## Fixing Failing Tests — Optimize or Delete? + +When tests fail due to implementation changes (not bugs), evaluate before blindly fixing: + +### Keep & Fix (update test data/assertions) + +- **Behavior tests**: Tests that verify _what_ the code does (output, side effects, user-visible behavior). Just update mock data formats or expected values. + - Example: Tool data structure changed from `{ name }` to `{ function: { name } }` → update mock data + - Example: Output format changed from `Current date: YYYY-MM-DD` to `Current date: YYYY-MM-DD (TZ)` → update expected string + +### Delete (over-specified, low value) + +- **Param-forwarding tests**: Tests that assert exact internal function call arguments (e.g., `expect(internalFn).toHaveBeenCalledWith(expect.objectContaining({ exact params }))`) — these break on every refactor and duplicate what behavior tests already cover. +- **Implementation-coupled tests**: Tests that verify _how_ the code works internally rather than _what_ it produces. If a higher-level test already covers the same behavior, the low-level test adds maintenance cost without coverage gain. + +### Decision Checklist + +1. Does the test verify **externally observable behavior** (API response, DB write, rendered output)? → **Keep** +2. Does the test only verify **internal wiring** (which function receives which params)? → Check if a behavior test already covers it. If yes → **Delete** +3. Is the same behavior already tested at a **higher integration level**? → Delete the lower-level duplicate +4. Would the test break again on the **next routine refactor**? → Consider raising to integration level or deleting + +### When Writing New Tests + +- Prefer **integration-level assertions** (verify final output) over **white-box assertions** (verify internal calls) +- Use `expect.objectContaining` only for stable, public-facing contracts — not for internal param shapes that change with refactors +- Mock at boundaries (DB, network, external services), not between internal modules + ## Common Issues 1. **Module pollution**: Use `vi.resetModules()` when tests fail mysteriously diff --git a/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts b/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts index 48807a3a62..b24d7924f7 100644 --- a/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +++ b/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts @@ -1,9 +1,10 @@ -import { DataSyncConfig } from '@lobechat/electron-client-ipc'; -import retry from 'async-retry'; -import { session as electronSession, safeStorage } from 'electron'; import querystring from 'node:querystring'; import { URL } from 'node:url'; +import type { DataSyncConfig } from '@lobechat/electron-client-ipc'; +import retry from 'async-retry'; +import { safeStorage, session as electronSession } from 'electron'; + import { OFFICIAL_CLOUD_SERVER } from '@/const/env'; import { appendVercelCookie } from '@/utils/http-headers'; import { createLogger } from '@/utils/logger'; diff --git a/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts index 1161401e67..509fe3646f 100644 --- a/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts @@ -1,4 +1,4 @@ -import { DataSyncConfig } from '@lobechat/electron-client-ipc'; +import type { DataSyncConfig } from '@lobechat/electron-client-ipc'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { App } from '@/core/App'; diff --git a/package.json b/package.json index f6864bce39..4bb460d5fe 100644 --- a/package.json +++ b/package.json @@ -169,9 +169,9 @@ "@better-auth/expo": "1.4.6", "@better-auth/passkey": "1.4.6", "@cfworker/json-schema": "^4.1.1", - "@chat-adapter/discord": "^4.14.0", - "@chat-adapter/state-ioredis": "^4.14.0", - "@chat-adapter/telegram": "^4.15.0", + "@chat-adapter/discord": "^4.17.0", + "@chat-adapter/state-ioredis": "^4.17.0", + "@chat-adapter/telegram": "^4.17.0", "@codesandbox/sandpack-react": "^2.20.0", "@discordjs/rest": "^2.6.0", "@dnd-kit/core": "^6.3.1", @@ -203,6 +203,7 @@ "@lobechat/builtin-tool-memory": "workspace:*", "@lobechat/builtin-tool-notebook": "workspace:*", "@lobechat/builtin-tool-page-agent": "workspace:*", + "@lobechat/builtin-tool-remote-device": "workspace:*", "@lobechat/builtin-tool-skill-store": "workspace:*", "@lobechat/builtin-tool-skills": "workspace:*", "@lobechat/builtin-tool-tools": "workspace:*", @@ -216,6 +217,7 @@ "@lobechat/conversation-flow": "workspace:*", "@lobechat/database": "workspace:*", "@lobechat/desktop-bridge": "workspace:*", + "@lobechat/device-gateway-client": "workspace:*", "@lobechat/edge-config": "workspace:*", "@lobechat/editor-runtime": "workspace:*", "@lobechat/electron-client-ipc": "workspace:*", diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json index 76bb80afbb..7ff32f8f19 100644 --- a/packages/agent-runtime/package.json +++ b/packages/agent-runtime/package.json @@ -12,6 +12,7 @@ "p-map": "^7.0.4" }, "devDependencies": { + "@lobechat/context-engine": "workspace:*", "@lobechat/types": "workspace:*", "openai": "^4.104.0" } diff --git a/packages/agent-runtime/src/types/state.ts b/packages/agent-runtime/src/types/state.ts index 9065f97ef4..857f8efb07 100644 --- a/packages/agent-runtime/src/types/state.ts +++ b/packages/agent-runtime/src/types/state.ts @@ -1,3 +1,4 @@ +import type { ActivatedStepTool, OperationToolSet, ToolSource } from '@lobechat/context-engine'; import type { ChatToolPayload, SecurityBlacklistConfig, @@ -11,17 +12,19 @@ import type { Cost, CostLimit, Usage } from './usage'; * This is the "passport" that can be persisted and transferred. */ export interface AgentState { + /** Cumulative record of tools activated at step level */ + activatedStepTools?: ActivatedStepTool[]; /** * Current calculated cost for this session. * Updated after each billable operation. */ cost: Cost; + /** * Optional cost limits configuration. * If set, execution will stop when limits are exceeded. */ costLimit?: CostLimit; - // --- Metadata --- createdAt: string; error?: any; @@ -47,6 +50,7 @@ export interface AgentState { canResume: boolean; }; lastModified: string; + /** * Optional maximum number of steps allowed. * If set, execution will stop with error when exceeded. @@ -75,16 +79,18 @@ export interface AgentState { provider: string; }; }; - operationId: string; - pendingHumanPrompt?: { metadata?: Record; prompt: string }; + /** Operation-level tool set snapshot (immutable after creation) */ + operationToolSet?: OperationToolSet; + pendingHumanPrompt?: { metadata?: Record; prompt: string }; pendingHumanSelect?: { metadata?: Record; multi?: boolean; options: Array<{ label: string; value: string }>; prompt?: string; }; + // --- HIL --- /** * When status is 'waiting_for_human', this stores pending requests @@ -98,22 +104,23 @@ export interface AgentState { * If not provided, DEFAULT_SECURITY_BLACKLIST will be used. */ securityBlacklist?: SecurityBlacklistConfig; - // --- State Machine --- status: 'idle' | 'running' | 'waiting_for_human' | 'done' | 'error' | 'interrupted'; + // --- Execution Tracking --- /** * Number of execution steps in this session. * Incremented on each runtime.step() call. */ stepCount: number; - systemRole?: string; + systemRole?: string; toolManifestMap: Record; tools?: any[]; + /** Tool source map for routing tool execution to correct handler */ - toolSourceMap?: Record; + toolSourceMap?: Record; // --- Usage and Cost Tracking --- /** * Accumulated usage statistics for this session. diff --git a/packages/agent-runtime/src/utils/index.ts b/packages/agent-runtime/src/utils/index.ts index 0d7f762889..bf56f1b180 100644 --- a/packages/agent-runtime/src/utils/index.ts +++ b/packages/agent-runtime/src/utils/index.ts @@ -1,2 +1,3 @@ +export * from './messageSelectors'; export * from './stepContextComputer'; export * from './tokenCounter'; diff --git a/packages/agent-runtime/src/utils/messageSelectors.test.ts b/packages/agent-runtime/src/utils/messageSelectors.test.ts new file mode 100644 index 0000000000..ab0a466a37 --- /dev/null +++ b/packages/agent-runtime/src/utils/messageSelectors.test.ts @@ -0,0 +1,97 @@ +import type { UIChatMessage } from '@lobechat/types'; +import { describe, expect, it } from 'vitest'; + +import { collectFromMessages, findInMessages } from './messageSelectors'; + +const createMessage = (overrides: Partial = {}): UIChatMessage => + ({ + content: '', + createdAt: Date.now(), + id: 'msg-1', + role: 'assistant', + updatedAt: Date.now(), + ...overrides, + }) as UIChatMessage; + +const createToolMessage = (overrides: Partial = {}): UIChatMessage => + createMessage({ role: 'tool', ...overrides }); + +describe('findInMessages', () => { + it('should return undefined for empty messages', () => { + const result = findInMessages([], () => 'found'); + expect(result).toBeUndefined(); + }); + + it('should return first match scanning from newest', () => { + const messages = [ + createMessage({ content: 'old', id: '1' }), + createMessage({ content: 'new', id: '2' }), + ]; + + const result = findInMessages(messages, (msg) => { + if (msg.content) return msg.content; + }); + + expect(result).toBe('new'); + }); + + it('should filter by role', () => { + const messages = [ + createMessage({ content: 'assistant-msg', role: 'assistant' } as any), + createToolMessage({ content: 'tool-msg' }), + ]; + + const result = findInMessages(messages, (msg) => msg.content || undefined, { role: 'tool' }); + + expect(result).toBe('tool-msg'); + }); + + it('should skip messages where visitor returns undefined', () => { + const messages = [ + createToolMessage({ id: '1', pluginState: undefined }), + createToolMessage({ id: '2', pluginState: { value: 42 } }), + ]; + + const result = findInMessages(messages, (msg) => msg.pluginState?.value as number | undefined, { + role: 'tool', + }); + + expect(result).toBe(42); + }); +}); + +describe('collectFromMessages', () => { + it('should return empty array for no matches', () => { + const result = collectFromMessages([], () => 'found'); + expect(result).toEqual([]); + }); + + it('should collect all matches in forward order', () => { + const messages = [ + createToolMessage({ id: '1', pluginState: { v: 'a' } }), + createToolMessage({ id: '2', pluginState: { v: 'b' } }), + createToolMessage({ id: '3', pluginState: undefined }), + ]; + + const result = collectFromMessages( + messages, + (msg) => msg.pluginState?.v as string | undefined, + { role: 'tool' }, + ); + + expect(result).toEqual(['a', 'b']); + }); + + it('should filter by role', () => { + const messages = [ + createMessage({ content: 'user', role: 'user' } as any), + createToolMessage({ content: 'tool' }), + ]; + + const result = collectFromMessages(messages, (msg) => msg.content || undefined, { + role: 'tool', + }); + + expect(result).toEqual(['tool']); + }); +}); diff --git a/packages/agent-runtime/src/utils/messageSelectors.ts b/packages/agent-runtime/src/utils/messageSelectors.ts new file mode 100644 index 0000000000..90c34cc9ba --- /dev/null +++ b/packages/agent-runtime/src/utils/messageSelectors.ts @@ -0,0 +1,82 @@ +import type { UIChatMessage } from '@lobechat/types'; + +/** + * Options for message visitor traversal + */ +export interface MessageVisitorOptions { + /** + * Filter by message role (e.g. 'tool', 'user', 'assistant') + */ + role?: UIChatMessage['role']; +} + +/** + * Find the first matching result by visiting messages in reverse order (newest first). + * + * A generic message traversal utility following the AST visitor pattern. + * The visitor function is called for each message that passes the filter. + * Returns immediately when the visitor returns a non-undefined value. + * + * @example + * ```typescript + * // Extract device context from most recent tool message + * const device = findInMessages(messages, (msg) => { + * const id = msg.pluginState?.metadata?.activeDeviceId; + * if (id) return { activeDeviceId: id }; + * }, { role: 'tool' }); + * + * // Find latest GTD todos + * const todos = findInMessages(messages, (msg) => { + * if (msg.plugin?.identifier === GTDIdentifier) return msg.pluginState?.todos; + * }, { role: 'tool' }); + * ``` + */ +export const findInMessages = ( + messages: UIChatMessage[], + visitor: (msg: UIChatMessage) => T | undefined, + options?: MessageVisitorOptions, +): T | undefined => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (options?.role && msg.role !== options.role) continue; + + const result = visitor(msg); + if (result !== undefined) return result; + } + + return undefined; +}; + +/** + * Collect all matching results by visiting messages in forward order. + * + * Unlike `findInMessages` which returns the first match, this function + * collects all non-undefined visitor results. Useful for cumulative + * state like activated tool IDs. + * + * @example + * ```typescript + * // Accumulate activated tool identifiers + * const tools = collectFromMessages(messages, (msg) => { + * if (msg.plugin?.identifier === LobeToolIdentifier) { + * return msg.pluginState?.activatedTools; + * } + * }, { role: 'tool' }); + * ``` + */ +export const collectFromMessages = ( + messages: UIChatMessage[], + visitor: (msg: UIChatMessage) => T | undefined, + options?: MessageVisitorOptions, +): T[] => { + const results: T[] = []; + + for (const msg of messages) { + if (options?.role && msg.role !== options.role) continue; + + const result = visitor(msg); + if (result !== undefined) results.push(result); + } + + return results; +}; diff --git a/packages/agent-tracing/package.json b/packages/agent-tracing/package.json index 4aea841625..c341ca45f8 100644 --- a/packages/agent-tracing/package.json +++ b/packages/agent-tracing/package.json @@ -12,6 +12,7 @@ "agent-tracing": "./src/cli/index.ts" }, "dependencies": { - "commander": "^13.1.0" + "commander": "^13.1.0", + "gpt-tokenizer": "^3.4.0" } } diff --git a/packages/agent-tracing/src/cli/index.ts b/packages/agent-tracing/src/cli/index.ts index f25dcd1928..25c4d129d4 100755 --- a/packages/agent-tracing/src/cli/index.ts +++ b/packages/agent-tracing/src/cli/index.ts @@ -4,14 +4,14 @@ import { Command } from 'commander'; import { registerInspectCommand } from './inspect'; import { registerListCommand } from './list'; -import { registerTraceCommand } from './trace'; +import { registerPartialCommand } from './partial'; const program = new Command(); program.name('agent-tracing').description('Local agent execution snapshot viewer').version('1.0.0'); -registerTraceCommand(program); -registerListCommand(program); registerInspectCommand(program); +registerListCommand(program); +registerPartialCommand(program); program.parse(); diff --git a/packages/agent-tracing/src/cli/inspect.ts b/packages/agent-tracing/src/cli/inspect.ts index a421fedb56..1d5c0aa950 100644 --- a/packages/agent-tracing/src/cli/inspect.ts +++ b/packages/agent-tracing/src/cli/inspect.ts @@ -1,14 +1,58 @@ import type { Command } from 'commander'; import { FileSnapshotStore } from '../store/file-store'; -import { renderMessageDetail, renderSnapshot, renderStepDetail } from '../viewer'; +import type { ExecutionSnapshot, StepSnapshot } from '../types'; +import { + renderDiff, + renderEnvContext, + renderMemory, + renderMessageDetail, + renderPayload, + renderPayloadTools, + renderSnapshot, + renderStepDetail, + renderSystemRole, +} from '../viewer'; + +function findStep(snapshot: ExecutionSnapshot, stepIndex: number): StepSnapshot { + const step = snapshot.steps.find((s) => s.stepIndex === stepIndex); + if (!step) { + console.error( + `Step ${stepIndex} not found. Available: ${snapshot.steps.map((s) => s.stepIndex).join(', ')}`, + ); + process.exit(1); + } + return step; +} + +function getSystemRole(step: StepSnapshot): string | undefined { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + const inputRole = ceEvent?.input?.systemRole; + if (inputRole) return inputRole; + const outputMsgs = ceEvent?.output as any[] | undefined; + const systemMsg = outputMsgs?.find((m: any) => m.role === 'system'); + if (!systemMsg) return undefined; + return typeof systemMsg.content === 'string' + ? systemMsg.content + : JSON.stringify(systemMsg.content, null, 2); +} + +function getEnvContent(step: StepSnapshot): string | undefined { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + const outputMsgs = ceEvent?.output as any[] | undefined; + const envMsg = outputMsgs?.find((m: any) => m.role === 'user'); + if (!envMsg) return undefined; + return typeof envMsg.content === 'string' + ? envMsg.content + : JSON.stringify(envMsg.content, null, 2); +} export function registerInspectCommand(program: Command) { program - .command('inspect') + .command('inspect', { isDefault: true }) .description('Inspect trace details') - .argument('', 'Trace ID to inspect') - .option('-s, --step ', 'View specific step') + .argument('[traceId]', 'Trace ID to inspect (defaults to latest)') + .option('-s, --step ', 'View specific step (default: 0 for -r/--env)') .option('-m, --messages', 'Show messages context') .option('-t, --tools', 'Show tool call details') .option('-e, --events', 'Show raw events (llm_start, llm_result, etc.)') @@ -21,34 +65,139 @@ export function registerInspectCommand(program: Command) { '--msg-input ', 'Show full content of message [N] from Context Engine Input (use with --step)', ) + .option('-r, --system-role', 'Show full system role content (default step 0)') + .option('--env', 'Show environment context (default step 0)') + .option('-d, --diff ', 'Diff against step N (use with -r or --env)') + .option('-T, --payload-tools', 'List available tools registered in LLM payload') + .option('-M, --memory', 'Show full user memory content (default step 0)') + .option( + '-p, --payload', + 'Show context engine input overview (knowledge, memory, capabilities, etc.)', + ) .option('-j, --json', 'Output as JSON') .action( async ( - traceId: string, + traceId: string | undefined, opts: { context?: boolean; + diff?: string; + env?: boolean; events?: boolean; json?: boolean; messages?: boolean; msg?: string; msgInput?: string; + memory?: boolean; + payload?: boolean; + payloadTools?: boolean; step?: string; + systemRole?: boolean; tools?: boolean; }, ) => { const store = new FileSnapshotStore(); - const snapshot = await store.get(traceId); + const snapshot = traceId ? await store.get(traceId) : await store.getLatest(); if (!snapshot) { - console.error(`Snapshot not found: ${traceId}`); + console.error( + traceId + ? `Snapshot not found: ${traceId}` + : 'No snapshots found. Run an agent operation first.', + ); process.exit(1); } const stepIndex = opts.step !== undefined ? Number.parseInt(opts.step, 10) : undefined; + // -r / --env / -T / -p default to step 0 + const effectiveStepIndex = + stepIndex ?? + (opts.systemRole || opts.env || opts.payloadTools || opts.payload || opts.memory + ? 0 + : undefined); + + // --diff requires -r or --env + if (opts.diff !== undefined && !opts.systemRole && !opts.env) { + console.error('--diff requires -r or --env.'); + process.exit(1); + } + + // --diff mode + if (opts.diff !== undefined && effectiveStepIndex !== undefined) { + const diffStepIndex = Number.parseInt(opts.diff, 10); + const stepA = findStep(snapshot, effectiveStepIndex); + const stepB = findStep(snapshot, diffStepIndex); + const label = opts.systemRole ? 'System Role' : 'Environment Context'; + const contentA = opts.systemRole ? getSystemRole(stepA) : getEnvContent(stepA); + const contentB = opts.systemRole ? getSystemRole(stepB) : getEnvContent(stepB); + console.log( + renderDiff(contentA ?? '', contentB ?? '', { + labelA: `Step ${effectiveStepIndex}`, + labelB: `Step ${diffStepIndex}`, + title: label, + }), + ); + return; + } + + // -r / --env view + if ((opts.systemRole || opts.env) && effectiveStepIndex !== undefined) { + const step = findStep(snapshot, effectiveStepIndex); + if (opts.json) { + if (opts.systemRole) { + console.log(JSON.stringify(getSystemRole(step) ?? null, null, 2)); + } else { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + const envMsg = (ceEvent?.output as any[])?.find((m: any) => m.role === 'user'); + console.log(JSON.stringify(envMsg ?? null, null, 2)); + } + } else { + console.log(opts.systemRole ? renderSystemRole(step) : renderEnvContext(step)); + } + return; + } + + // -T / --payload-tools view + if (opts.payloadTools && effectiveStepIndex !== undefined) { + const step = findStep(snapshot, effectiveStepIndex); + if (opts.json) { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + const toolsConfig = ceEvent?.input?.toolsConfig; + const payloadTools = (step.context?.payload as any)?.tools; + console.log(JSON.stringify({ payloadTools, toolsConfig }, null, 2)); + } else { + console.log(renderPayloadTools(step)); + } + return; + } + + // -p / --payload view + if (opts.payload && effectiveStepIndex !== undefined) { + const step = findStep(snapshot, effectiveStepIndex); + if (opts.json) { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + console.log(JSON.stringify(ceEvent?.input ?? null, null, 2)); + } else { + console.log(renderPayload(step)); + } + return; + } + + // -M / --memory view + if (opts.memory && effectiveStepIndex !== undefined) { + const step = findStep(snapshot, effectiveStepIndex); + if (opts.json) { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + console.log(JSON.stringify(ceEvent?.input?.userMemory ?? null, null, 2)); + } else { + console.log(renderMemory(step)); + } + return; + } + if (opts.json) { if (stepIndex !== undefined) { - const step = snapshot.steps.find((s) => s.stepIndex === stepIndex); - console.log(JSON.stringify(step ?? null, null, 2)); + const step = findStep(snapshot, stepIndex); + console.log(JSON.stringify(step, null, 2)); } else { console.log(JSON.stringify(snapshot, null, 2)); } @@ -64,26 +213,14 @@ export function registerInspectCommand(program: Command) { : undefined; const msgSource: 'input' | 'output' = opts.msgInput !== undefined ? 'input' : 'output'; - if (msgIndex !== undefined && stepIndex !== undefined) { - const step = snapshot.steps.find((s) => s.stepIndex === stepIndex); - if (!step) { - console.error( - `Step ${stepIndex} not found. Available: ${snapshot.steps.map((s) => s.stepIndex).join(', ')}`, - ); - process.exit(1); - } - console.log(renderMessageDetail(step, msgIndex, msgSource)); - return; - } - if (stepIndex !== undefined) { - const step = snapshot.steps.find((s) => s.stepIndex === stepIndex); - if (!step) { - console.error( - `Step ${stepIndex} not found. Available: ${snapshot.steps.map((s) => s.stepIndex).join(', ')}`, - ); - process.exit(1); + const step = findStep(snapshot, stepIndex); + + if (msgIndex !== undefined) { + console.log(renderMessageDetail(step, msgIndex, msgSource)); + return; } + console.log( renderStepDetail(step, { context: opts.context, diff --git a/packages/agent-tracing/src/cli/partial.ts b/packages/agent-tracing/src/cli/partial.ts new file mode 100644 index 0000000000..a91e1a9fde --- /dev/null +++ b/packages/agent-tracing/src/cli/partial.ts @@ -0,0 +1,107 @@ +import type { Command } from 'commander'; + +import { FileSnapshotStore } from '../store/file-store'; +import type { ExecutionSnapshot } from '../types'; +import { renderSnapshot } from '../viewer'; + +export function registerPartialCommand(program: Command) { + const partial = program.command('partial').description('Inspect in-progress (partial) snapshots'); + + partial + .command('list') + .alias('ls') + .description('List partial snapshots') + .action(async () => { + const store = new FileSnapshotStore(); + const files = await store.listPartials(); + + if (files.length === 0) { + console.log('No partial snapshots found.'); + return; + } + + console.log(`${files.length} partial snapshot(s):\n`); + for (const file of files) { + const partial = await store.getPartial(file); + if (partial) { + const steps = partial.steps?.length ?? 0; + const model = partial.model ?? '-'; + const opId = partial.operationId ?? file.replace('.json', ''); + const elapsed = partial.startedAt + ? `${((Date.now() - partial.startedAt) / 1000).toFixed(0)}s ago` + : '-'; + console.log(` ${opId}`); + console.log(` model=${model} steps=${steps} started=${elapsed}`); + } else { + console.log(` ${file}`); + } + } + }); + + partial + .command('inspect') + .alias('view') + .description('Inspect a partial snapshot') + .argument('[id]', 'Partial operation ID or filename (defaults to latest)') + .option('-j, --json', 'Output as JSON') + .action(async (id: string | undefined, opts: { json?: boolean }) => { + const store = new FileSnapshotStore(); + const files = await store.listPartials(); + + if (files.length === 0) { + console.error('No partial snapshots found.'); + process.exit(1); + } + + const data = id ? await store.getPartial(id) : await store.getPartial(files[0]); + + if (!data) { + console.error(id ? `Partial not found: ${id}` : 'No partial snapshots found.'); + process.exit(1); + } + + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + // Render as a snapshot (fill in defaults for missing fields) + const snapshot: ExecutionSnapshot = { + completedAt: undefined, + completionReason: undefined, + error: undefined, + model: data.model, + operationId: data.operationId ?? '?', + provider: data.provider, + startedAt: data.startedAt ?? Date.now(), + steps: data.steps ?? [], + totalCost: data.totalCost ?? 0, + totalSteps: data.steps?.length ?? 0, + totalTokens: data.totalTokens ?? 0, + traceId: data.traceId ?? '?', + ...data, + }; + + console.log('[PARTIAL - in progress]\n'); + console.log(renderSnapshot(snapshot)); + }); + + partial + .command('clean') + .description('Remove all partial snapshots') + .action(async () => { + const store = new FileSnapshotStore(); + const files = await store.listPartials(); + + if (files.length === 0) { + console.log('No partial snapshots to clean.'); + return; + } + + for (const file of files) { + const opId = file.replace('.json', ''); + await store.removePartial(opId); + } + console.log(`Removed ${files.length} partial snapshot(s).`); + }); +} diff --git a/packages/agent-tracing/src/cli/trace.ts b/packages/agent-tracing/src/cli/trace.ts deleted file mode 100644 index b8c7a71c6a..0000000000 --- a/packages/agent-tracing/src/cli/trace.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Command } from 'commander'; - -import { FileSnapshotStore } from '../store/file-store'; -import { renderSnapshot } from '../viewer'; - -export function registerTraceCommand(program: Command) { - program - .command('trace') - .description('View latest or specific trace') - .argument('[traceId]', 'Trace ID to view (defaults to latest)') - .action(async (traceId?: string) => { - const store = new FileSnapshotStore(); - const snapshot = traceId ? await store.get(traceId) : await store.getLatest(); - if (!snapshot) { - console.error( - traceId - ? `Snapshot not found: ${traceId}` - : 'No snapshots found. Run an agent operation first.', - ); - process.exit(1); - } - console.log(renderSnapshot(snapshot)); - }); -} diff --git a/packages/agent-tracing/src/store/file-store.ts b/packages/agent-tracing/src/store/file-store.ts index f980b705f7..7b498dcb4a 100644 --- a/packages/agent-tracing/src/store/file-store.ts +++ b/packages/agent-tracing/src/store/file-store.ts @@ -37,6 +37,8 @@ export class FileSnapshotStore implements ISnapshotStore { } async get(traceId: string): Promise { + if (traceId === 'latest') return this.getLatest(); + const files = await this.listFiles(); const match = files.find((f) => f.includes(traceId.slice(0, 12))); if (!match) return null; @@ -92,6 +94,36 @@ export class FileSnapshotStore implements ISnapshotStore { return path.join(this.partialDir(), `${safe}.json`); } + async listPartials(): Promise { + try { + const entries = await fs.readdir(this.partialDir()); + return entries + .filter((f) => f.endsWith('.json')) + .sort() + .reverse(); + } catch { + return []; + } + } + + async getPartial(idOrFilename: string): Promise | null> { + // Try exact filename first + try { + const filePath = idOrFilename.endsWith('.json') + ? path.join(this.partialDir(), idOrFilename) + : this.partialPath(idOrFilename); + const content = await fs.readFile(filePath, 'utf8'); + return JSON.parse(content) as Partial; + } catch { + // Fall back to substring match + const files = await this.listPartials(); + const match = files.find((f) => f.includes(idOrFilename)); + if (!match) return null; + const content = await fs.readFile(path.join(this.partialDir(), match), 'utf8'); + return JSON.parse(content) as Partial; + } + } + async loadPartial(operationId: string): Promise | null> { try { const content = await fs.readFile(this.partialPath(operationId), 'utf8'); diff --git a/packages/agent-tracing/src/store/types.ts b/packages/agent-tracing/src/store/types.ts index 4ccc0b723b..7a3c392d5b 100644 --- a/packages/agent-tracing/src/store/types.ts +++ b/packages/agent-tracing/src/store/types.ts @@ -4,6 +4,8 @@ export interface ISnapshotStore { get: (traceId: string) => Promise; getLatest: () => Promise; list: (options?: { limit?: number }) => Promise; + /** List in-progress partial snapshot filenames */ + listPartials: () => Promise; /** Load in-progress partial snapshot */ loadPartial: (operationId: string) => Promise | null>; diff --git a/packages/agent-tracing/src/viewer/index.ts b/packages/agent-tracing/src/viewer/index.ts index 73ef989cf7..e55dfff53e 100644 --- a/packages/agent-tracing/src/viewer/index.ts +++ b/packages/agent-tracing/src/viewer/index.ts @@ -1,3 +1,5 @@ +import { encode } from 'gpt-tokenizer'; + import type { ExecutionSnapshot, SnapshotSummary, StepSnapshot } from '../types'; // ANSI color helpers @@ -8,6 +10,8 @@ const red = (s: string) => `\x1B[31m${s}\x1B[39m`; const yellow = (s: string) => `\x1B[33m${s}\x1B[39m`; const cyan = (s: string) => `\x1B[36m${s}\x1B[39m`; const magenta = (s: string) => `\x1B[35m${s}\x1B[39m`; +const blue = (s: string) => `\x1B[34m${s}\x1B[39m`; +const white = (s: string) => `\x1B[37m${s}\x1B[39m`; function formatMs(ms: number): string { if (ms < 1000) return `${ms}ms`; @@ -25,6 +29,33 @@ function formatCost(cost: number): string { return `$${cost.toFixed(4)}`; } +interface CacheStats { + cached: number; + miss: number; + rate: number; // 0-1 + total: number; +} + +function getStepCacheStats(step: StepSnapshot): CacheStats | null { + const ev = step.events?.find((e) => e.type === 'llm_result') as any; + const usage = ev?.result?.usage; + if (!usage) return null; + + const cached = usage.inputCachedTokens ?? 0; + const total = usage.inputTextTokens ?? usage.totalInputTokens ?? step.inputTokens ?? 0; + if (total === 0) return null; + + const miss = usage.inputCacheMissTokens ?? total - cached; + const rate = cached / total; + return { cached, miss, rate, total }; +} + +function formatCacheRate(rate: number): string { + const pct = (rate * 100).toFixed(0); + const colorFn = rate >= 0.8 ? green : rate >= 0.4 ? yellow : rate > 0 ? red : dim; + return colorFn(`${pct}%`); +} + function truncate(s: string, maxLen: number): string { const single = s.replaceAll('\n', ' '); if (single.length <= maxLen) return single; @@ -36,6 +67,89 @@ function padEnd(s: string, len: number): string { return s + ' '.repeat(len - s.length); } +// Application-defined structural XML tags — rendered in blue+bold +const STRUCTURAL_TAGS = new Set([ + 'plugins', + 'collection', + 'collection.instructions', + 'available_tools', + 'api', + 'user_context', + 'session_context', + 'user_memory', + 'persona', + 'instruction', + 'online-devices', + 'device', + 'memory_effort_policy', +]); + +/** + * Extract tag name from an XML tag string like ``, ``, ``. + */ +function extractTagName(tag: string): string { + const m = tag.match(/^<\/?(\w[\w.:-]*)/); + return m ? m[1] : ''; +} + +/** + * Format XML-structured content: + * - Structural tags (app-defined) → blue + bold + * - Other XML tags → white + bold + * - Text inside XML elements → dim + */ +function formatXmlContent(text: string): string { + const xmlTagRe = /<\/?[\w.:-]+(?:\s[^>]*)?\/?>/g; + const lines = text.split('\n'); + let depth = 0; + + return lines + .map((line) => { + const tags = [...line.matchAll(xmlTagRe)]; + + if (tags.length === 0) { + return depth > 0 ? dim(line) : line; + } + + let result = ''; + let lastIndex = 0; + + for (const match of tags) { + const tag = match[0]; + const idx = match.index!; + + // Text before this tag + if (idx > lastIndex) { + const textBefore = line.slice(lastIndex, idx); + result += depth > 0 ? dim(textBefore) : textBefore; + } + + const tagName = extractTagName(tag); + const colorFn = STRUCTURAL_TAGS.has(tagName) ? white : blue; + + if (tag.endsWith('/>')) { + result += bold(colorFn(tag)); + } else if (tag.startsWith(' 0 ? dim(textAfter) : textAfter; + } + + return result; + }) + .join('\n'); +} + export function renderSnapshot(snapshot: ExecutionSnapshot): string { const lines: string[] = []; const durationMs = (snapshot.completedAt ?? Date.now()) - snapshot.startedAt; @@ -69,12 +183,32 @@ export function renderSnapshot(snapshot: ExecutionSnapshot): string { } } + // Aggregate cache stats + let totalCached = 0; + let totalInput = 0; + for (const step of snapshot.steps) { + const cache = getStepCacheStats(step); + if (cache) { + totalCached += cache.cached; + totalInput += cache.total; + } + } + const totalCacheParts: string[] = []; + if (totalInput > 0) { + totalCacheParts.push(`cache:${formatCacheRate(totalCached / totalInput)}`); + if (totalCached > 0) totalCacheParts.push(dim(`hit:${formatTokens(totalCached)}`)); + const totalMiss = totalInput - totalCached; + if (totalMiss > 0) totalCacheParts.push(dim(`miss:${formatTokens(totalMiss)}`)); + } + const totalCacheLabel = totalCacheParts.length > 0 ? ` ${totalCacheParts.join(' ')}` : ''; + // Footer const reasonColor = snapshot.completionReason === 'done' ? green : snapshot.error ? red : yellow; lines.push( `${dim('└─')} ${reasonColor(snapshot.completionReason ?? 'unknown')}` + ` tokens=${formatTokens(snapshot.totalTokens)}` + - ` cost=${formatCost(snapshot.totalCost)}`, + ` cost=${formatCost(snapshot.totalCost)}` + + totalCacheLabel, ); if (snapshot.error) { @@ -89,8 +223,17 @@ function renderLlmStep(lines: string[], step: StepSnapshot, prefix: string): voi if (step.inputTokens) tokenInfo.push(`in:${formatTokens(step.inputTokens)}`); if (step.outputTokens) tokenInfo.push(`out:${formatTokens(step.outputTokens)}`); + const cache = getStepCacheStats(step); + const cacheParts: string[] = []; + if (cache) { + cacheParts.push(`cache:${formatCacheRate(cache.rate)}`); + if (cache.cached > 0) cacheParts.push(dim(`hit:${formatTokens(cache.cached)}`)); + if (cache.miss > 0) cacheParts.push(dim(`miss:${formatTokens(cache.miss)}`)); + } + const cacheLabel = cacheParts.length > 0 ? ` ${cacheParts.join(' ')}` : ''; + if (tokenInfo.length > 0) { - lines.push(`${prefix}${dim('├─')} LLM ${tokenInfo.join(' ')} tokens`); + lines.push(`${prefix}${dim('├─')} LLM ${tokenInfo.join(' ')} tokens${cacheLabel}`); } if (step.toolsCalling && step.toolsCalling.length > 0) { @@ -116,12 +259,14 @@ function renderMessageList(lines: string[], messages: any[], maxContentLen: numb const roleColor = role === 'user' ? green : role === 'assistant' ? cyan : role === 'system' ? magenta : yellow; const rawContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); + const tokenCount = rawContent ? encode(rawContent).length : 0; + const tokenLabel = tokenCount > 0 ? dim(` ~${formatTokens(tokenCount)} tokens`) : ''; const preview = rawContent && rawContent.length > maxContentLen ? rawContent.slice(0, maxContentLen) + '...' : rawContent; lines.push( - ` ${dim(`[${i}]`)} ${roleColor(role)}${msg.tool_call_id ? dim(` [${msg.tool_call_id}]`) : ''}`, + ` ${dim(`[${i}]`)} ${roleColor(role)}${tokenLabel}${msg.tool_call_id ? dim(` [${msg.tool_call_id}]`) : ''}`, ); if (preview) lines.push(` ${dim(preview)}`); if (msg.tool_calls) { @@ -234,6 +379,385 @@ export function renderMessageDetail( return lines.join('\n'); } +export function renderSystemRole(step: StepSnapshot): string { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + + // Try input.systemRole first (user-configured agent prompt) + const inputSystemRole = ceEvent?.input?.systemRole; + + // Fall back to the first system message in the final LLM payload (assembled system prompt) + const outputMsgs = ceEvent?.output as any[] | undefined; + const systemMsg = outputMsgs?.find((m: any) => m.role === 'system'); + const outputSystemRole = + systemMsg && + (typeof systemMsg.content === 'string' + ? systemMsg.content + : JSON.stringify(systemMsg.content, null, 2)); + + const systemRole = inputSystemRole || outputSystemRole; + + if (!systemRole) { + return red('No system role found in this step.'); + } + + const lines: string[] = []; + const source = inputSystemRole ? 'input' : 'output'; + lines.push( + bold('System Role') + ` ${dim(`Step ${step.stepIndex}`)} ${dim(`(from ${source})`)}`, + ); + lines.push(dim('─'.repeat(60))); + lines.push(formatXmlContent(systemRole)); + + return lines.join('\n'); +} + +export function renderEnvContext(step: StepSnapshot): string { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + const outputMsgs: any[] | undefined = ceEvent?.output; + + if (!outputMsgs || outputMsgs.length === 0) { + return red('No context engine output found in this step.'); + } + + const envMsg = outputMsgs.find((m: any) => m.role === 'user'); + + if (!envMsg) { + return red('No user environment message found in LLM payload.'); + } + + const content = + typeof envMsg.content === 'string' ? envMsg.content : JSON.stringify(envMsg.content, null, 2); + + const lines: string[] = [ + bold('Environment Context') + ` ${dim(`Step ${step.stepIndex}`)}`, + dim('─'.repeat(60)), + formatXmlContent(content), + ]; + + return lines.join('\n'); +} + +export function renderPayloadTools(step: StepSnapshot): string { + const lines: string[] = []; + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + + // Section 1: Plugin manifests from context engine input + const toolsConfig = ceEvent?.input?.toolsConfig; + if (toolsConfig) { + const enabledPlugins: string[] = toolsConfig.tools ?? []; + const manifests: any[] = toolsConfig.manifests ?? []; + + lines.push(bold('Enabled Plugins') + ` ${dim(`(${enabledPlugins.length} enabled)`)}`); + lines.push(dim('─'.repeat(60))); + + for (const manifest of manifests) { + const id = manifest.identifier ?? '?'; + const apis: any[] = manifest.api ?? []; + const isEnabled = enabledPlugins.includes(id); + const statusIcon = isEnabled ? green('●') : dim('○'); + const name = manifest.meta?.title || id; + + lines.push(`${statusIcon} ${cyan(id)}${name !== id ? dim(` (${name})`) : ''}`); + for (const api of apis) { + lines.push( + ` ${dim('─')} ${api.name ?? '?'}${api.description ? dim(` — ${truncate(api.description, 60)}`) : ''}`, + ); + } + } + } + + // Section 2: Actual function definitions in LLM payload + const payloadTools = (step.context?.payload as any)?.tools as any[] | undefined; + if (payloadTools && payloadTools.length > 0) { + if (lines.length > 0) lines.push(''); + lines.push(bold('LLM Payload Functions') + ` ${dim(`(${payloadTools.length} functions)`)}`); + lines.push(dim('─'.repeat(60))); + + for (const tool of payloadTools) { + const fn = tool.function; + if (fn) { + lines.push( + ` ${fn.name}${fn.description ? dim(` — ${truncate(fn.description, 60)}`) : ''}`, + ); + } + } + } + + if (lines.length === 0) { + return red('No tools data found in this step.'); + } + + lines.unshift(bold('Payload Tools') + ` ${dim(`Step ${step.stepIndex}`)}`); + lines.splice(1, 0, ''); + + return lines.join('\n'); +} + +export function renderPayload(step: StepSnapshot): string { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + if (!ceEvent?.input) { + return red('No context engine data found in this step.'); + } + + const input = ceEvent.input; + const lines: string[] = [ + bold('Context Engine Input') + ` ${dim(`Step ${step.stepIndex}`)}`, + dim('─'.repeat(60)), + ]; + + // Model & Provider + if (input.model) lines.push(`${bold('Model:')} ${cyan(input.model)}`); + if (input.provider) lines.push(`${bold('Provider:')} ${input.provider}`); + + // History config + lines.push( + `${bold('History:')} enableHistoryCount=${input.enableHistoryCount ?? '-'} historyCount=${input.historyCount ?? '-'}`, + ); + if (input.forceFinish) lines.push(`${bold('Force Finish:')} ${input.forceFinish}`); + + // System Role (summary) + if (input.systemRole) { + lines.push(''); + lines.push( + `${bold('System Role:')} ${dim(`${input.systemRole.length} chars`)} ${dim('(use -r to view full)')}`, + ); + } + + // Capabilities + if (input.capabilities && Object.keys(input.capabilities).length > 0) { + lines.push(''); + lines.push(bold('Capabilities:')); + for (const [key, value] of Object.entries(input.capabilities)) { + lines.push(` ${key}: ${JSON.stringify(value)}`); + } + } + + // Knowledge + const knowledge = input.knowledge; + if (knowledge) { + const fileContents: any[] = knowledge.fileContents ?? []; + const knowledgeBases: any[] = knowledge.knowledgeBases ?? []; + if (fileContents.length > 0 || knowledgeBases.length > 0) { + lines.push(''); + lines.push(bold('Knowledge:')); + if (fileContents.length > 0) { + lines.push(` Files: ${fileContents.length}`); + for (const f of fileContents) { + const name = f.name ?? f.filename ?? f.id ?? '?'; + lines.push(` ${dim('─')} ${name}`); + } + } + if (knowledgeBases.length > 0) { + lines.push(` Knowledge Bases: ${knowledgeBases.length}`); + for (const kb of knowledgeBases) { + lines.push(` ${dim('─')} ${kb.name ?? kb.id ?? '?'}`); + } + } + } + } + + // Tools (summary) + const toolsConfig = input.toolsConfig; + if (toolsConfig) { + const plugins: string[] = toolsConfig.tools ?? []; + const manifests: any[] = toolsConfig.manifests ?? []; + lines.push(''); + lines.push( + `${bold('Tools:')} ${plugins.length} enabled, ${manifests.length} manifests ${dim('(use -T to view full)')}`, + ); + if (plugins.length > 0) { + lines.push(` Enabled: ${plugins.map((p: string) => cyan(p)).join(', ')}`); + } + } + + // User Memory + const userMemory = input.userMemory; + if (userMemory) { + const memories = userMemory.memories; + lines.push(''); + lines.push(bold('User Memory:')); + if (userMemory.fetchedAt) { + lines.push(` Fetched: ${dim(new Date(userMemory.fetchedAt).toLocaleString())}`); + } + if (memories && typeof memories === 'object') { + if (Array.isArray(memories)) { + lines.push(` ${memories.length} memories`); + } else { + for (const [category, items] of Object.entries(memories)) { + const arr = items as any[]; + if (arr.length > 0) { + lines.push(` ${category}: ${green(String(arr.length))} items`); + for (const item of arr.slice(0, 3)) { + const content = + typeof item === 'string' + ? item + : (item.content ?? item.text ?? JSON.stringify(item)); + lines.push(` ${dim('─')} ${truncate(String(content), 80)}`); + } + if (arr.length > 3) lines.push(` ${dim(`... +${arr.length - 3} more`)}`); + } + } + } + } + } + + // Platform context (discord, etc.) + for (const key of Object.keys(input)) { + if (key.endsWith('Context') && key !== 'stepContext') { + const ctx = input[key]; + if (ctx && typeof ctx === 'object' && Object.keys(ctx).length > 0) { + lines.push(''); + lines.push(bold(`${key}:`)); + const json = JSON.stringify(ctx, null, 2); + for (const line of json.split('\n').slice(0, 20)) { + lines.push(` ${dim(line)}`); + } + } + } + } + + // Messages summary + const messages = input.messages; + if (messages && Array.isArray(messages)) { + lines.push(''); + lines.push( + `${bold('Input Messages:')} ${messages.length} messages ${dim('(use -m to view full)')}`, + ); + } + + return lines.join('\n'); +} + +export function renderMemory(step: StepSnapshot): string { + const ceEvent = step.events?.find((e) => e.type === 'context_engine_result') as any; + const userMemory = ceEvent?.input?.userMemory; + + if (!userMemory) { + return red('No user memory found in this step.'); + } + + const lines: string[] = [ + bold('User Memory') + ` ${dim(`Step ${step.stepIndex}`)}`, + dim('─'.repeat(60)), + ]; + + if (userMemory.fetchedAt) { + lines.push(`Fetched: ${dim(new Date(userMemory.fetchedAt).toLocaleString())}`); + lines.push(''); + } + + const memories = userMemory.memories; + if (!memories) { + lines.push(dim('No memories present.')); + return lines.join('\n'); + } + + if (Array.isArray(memories)) { + lines.push(`${memories.length} memories`); + for (const item of memories) { + lines.push(''); + lines.push(dim('─'.repeat(40))); + lines.push(typeof item === 'string' ? item : JSON.stringify(item, null, 2)); + } + return lines.join('\n'); + } + + // Dict format: { contexts, experiences, persona, preferences, ... } + // Render in priority order: persona first, then identity, contexts, preferences, experiences, rest + const categoryOrder = ['persona', 'identity', 'contexts', 'preferences', 'experiences']; + const allKeys = Object.keys(memories); + const sortedKeys = [ + ...categoryOrder.filter((k) => allKeys.includes(k)), + ...allKeys.filter((k) => !categoryOrder.includes(k)), + ]; + + for (const category of sortedKeys) { + const items = (memories as Record)[category]; + + if (category === 'persona') { + const persona = items as any; + lines.push(bold('persona')); + if (persona.tagline) { + lines.push(` ${cyan('tagline:')} ${persona.tagline}`); + } + if (persona.narrative) { + lines.push(` ${cyan('narrative:')}`); + for (const line of persona.narrative.split('\n')) { + lines.push(` ${line}`); + } + } + lines.push(''); + continue; + } + + const arr = items as any[]; + if (!Array.isArray(arr)) { + lines.push(`${bold(category)}: ${JSON.stringify(items)}`); + lines.push(''); + continue; + } + + lines.push(bold(category) + ` ${dim(`(${arr.length} items)`)}`); + if (arr.length === 0) { + lines.push(dim(' (empty)')); + } else { + for (const item of arr) { + const content = + typeof item === 'string' + ? item + : (item.content ?? item.text ?? JSON.stringify(item, null, 2)); + lines.push(` ${dim('─')} ${String(content)}`); + } + } + lines.push(''); + } + + return lines.join('\n'); +} + +export function renderDiff( + contentA: string, + contentB: string, + options: { labelA: string; labelB: string; title: string }, +): string { + const linesA = contentA.split('\n'); + const linesB = contentB.split('\n'); + const lines: string[] = [ + bold(`${options.title} Diff`) + ` ${cyan(options.labelA)} → ${cyan(options.labelB)}`, + dim('─'.repeat(60)), + ]; + + // Simple line-by-line diff + const maxLen = Math.max(linesA.length, linesB.length); + let hasChanges = false; + + for (let i = 0; i < maxLen; i++) { + const a = linesA[i]; + const b = linesB[i]; + + if (a === b) { + lines.push(` ${a ?? ''}`); + } else { + hasChanges = true; + if (a !== undefined && b === undefined) { + lines.push(red(`- ${a}`)); + } else if (a === undefined && b !== undefined) { + lines.push(green(`+ ${b}`)); + } else { + lines.push(red(`- ${a}`)); + lines.push(green(`+ ${b}`)); + } + } + } + + if (!hasChanges) { + lines.push(''); + lines.push(dim('No differences found.')); + } + + return lines.join('\n'); +} + export function renderStepDetail( step: StepSnapshot, options?: { context?: boolean; events?: boolean; messages?: boolean; tools?: boolean }, diff --git a/packages/builtin-tool-remote-device/package.json b/packages/builtin-tool-remote-device/package.json new file mode 100644 index 0000000000..a4a2a51b5f --- /dev/null +++ b/packages/builtin-tool-remote-device/package.json @@ -0,0 +1,12 @@ +{ + "name": "@lobechat/builtin-tool-remote-device", + "version": "1.0.0", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "main": "./src/index.ts", + "devDependencies": { + "@lobechat/types": "workspace:*" + } +} diff --git a/packages/builtin-tool-remote-device/src/ExecutionRuntime/index.ts b/packages/builtin-tool-remote-device/src/ExecutionRuntime/index.ts new file mode 100644 index 0000000000..553570cfac --- /dev/null +++ b/packages/builtin-tool-remote-device/src/ExecutionRuntime/index.ts @@ -0,0 +1,66 @@ +import { type BuiltinServerRuntimeOutput } from '@lobechat/types'; + +import { type DeviceAttachment } from './types'; + +export interface RemoteDeviceRuntimeService { + queryDeviceList: () => Promise; +} + +export class RemoteDeviceExecutionRuntime { + private service: RemoteDeviceRuntimeService; + + constructor(service: RemoteDeviceRuntimeService) { + this.service = service; + } + + async listOnlineDevices(): Promise { + try { + const devices = await this.service.queryDeviceList(); + const onlineDevices = devices.filter((d) => d.online); + + return { + content: + onlineDevices.length > 0 + ? JSON.stringify(onlineDevices) + : 'No online devices found. Please make sure your desktop application is running and connected.', + state: { devices: onlineDevices }, + success: true, + }; + } catch (error) { + return { + content: `Failed to list devices: ${error instanceof Error ? error.message : String(error)}`, + error, + success: false, + }; + } + } + + async activateDevice(args: { deviceId: string }): Promise { + try { + const devices = await this.service.queryDeviceList(); + const target = devices.find((d) => d.deviceId === args.deviceId && d.online); + + if (!target) { + return { + content: `Device "${args.deviceId}" is not online or does not exist.`, + success: false, + }; + } + + return { + content: `Device "${target.hostname}" (${target.platform}) activated successfully. Local System tools are now available.`, + state: { + activatedDevice: target, + metadata: { activeDeviceId: args.deviceId }, + }, + success: true, + }; + } catch (error) { + return { + content: `Failed to activate device: ${error instanceof Error ? error.message : String(error)}`, + error, + success: false, + }; + } + } +} diff --git a/packages/builtin-tool-remote-device/src/ExecutionRuntime/types.ts b/packages/builtin-tool-remote-device/src/ExecutionRuntime/types.ts new file mode 100644 index 0000000000..7d253a6e78 --- /dev/null +++ b/packages/builtin-tool-remote-device/src/ExecutionRuntime/types.ts @@ -0,0 +1,7 @@ +export interface DeviceAttachment { + deviceId: string; + hostname: string; + lastSeen: string; + online: boolean; + platform: string; +} diff --git a/packages/builtin-tool-remote-device/src/index.ts b/packages/builtin-tool-remote-device/src/index.ts new file mode 100644 index 0000000000..f00153e491 --- /dev/null +++ b/packages/builtin-tool-remote-device/src/index.ts @@ -0,0 +1,5 @@ +export { RemoteDeviceExecutionRuntime, type RemoteDeviceRuntimeService } from './ExecutionRuntime'; +export type { DeviceAttachment } from './ExecutionRuntime/types'; +export { RemoteDeviceManifest } from './manifest'; +export { generateSystemPrompt, systemPrompt } from './systemRole'; +export { RemoteDeviceApiName, type RemoteDeviceApiNameType, RemoteDeviceIdentifier } from './types'; diff --git a/packages/builtin-tool-remote-device/src/manifest.ts b/packages/builtin-tool-remote-device/src/manifest.ts new file mode 100644 index 0000000000..683b434a6c --- /dev/null +++ b/packages/builtin-tool-remote-device/src/manifest.ts @@ -0,0 +1,44 @@ +import { type BuiltinToolManifest } from '@lobechat/types'; + +import { systemPrompt } from './systemRole'; +import { RemoteDeviceApiName, RemoteDeviceIdentifier } from './types'; + +export const RemoteDeviceManifest: BuiltinToolManifest = { + api: [ + { + description: + 'List all online desktop devices belonging to the current user. Returns device IDs, hostnames, platform, and connection status.', + name: RemoteDeviceApiName.listOnlineDevices, + parameters: { + properties: {}, + type: 'object', + }, + }, + { + description: + 'Activate a specific desktop device by its ID. Once activated, the Local System tool becomes available for file operations and shell commands on that device.', + name: RemoteDeviceApiName.activateDevice, + parameters: { + properties: { + deviceId: { + description: 'The unique identifier of the device to activate', + type: 'string', + }, + }, + required: ['deviceId'], + type: 'object', + }, + }, + ], + humanIntervention: 'never', + identifier: RemoteDeviceIdentifier, + meta: { + avatar: '🖥️', + description: 'Discover and manage remote desktop device connections', + readme: + 'Manage connections to your desktop devices. List online devices, activate a device for remote operations, and check connection status.', + title: 'Remote Device', + }, + systemRole: systemPrompt, + type: 'builtin', +}; diff --git a/packages/builtin-tool-remote-device/src/systemRole.ts b/packages/builtin-tool-remote-device/src/systemRole.ts new file mode 100644 index 0000000000..0b5ec4be3f --- /dev/null +++ b/packages/builtin-tool-remote-device/src/systemRole.ts @@ -0,0 +1,34 @@ +import { type DeviceAttachment } from './ExecutionRuntime/types'; + +export const generateSystemPrompt = (devices?: DeviceAttachment[]): string => { + const onlineDevices = devices?.filter((d) => d.online) ?? []; + + const deviceSection = + onlineDevices.length > 0 + ? ` +${onlineDevices.map((d) => `- **${d.hostname}** (${d.platform}) — ID: \`${d.deviceId}\``).join('\n')} +` + : ` +No devices are currently online. +`; + + return `You have a Remote Device Management tool that allows you to discover and connect to the user's desktop devices. + +${deviceSection} + + +1. **listOnlineDevices**: Refresh the list of online desktop devices. Returns device IDs, hostnames, platform info, and connection status. +2. **activateDevice**: Activate a specific device by its ID. Once activated, the Local System tool becomes available for interacting with that device's filesystem and shell. + + + +- If a device is already listed above, you can activate it directly with **activateDevice** without calling **listOnlineDevices** first. +- If the device list above is empty or you suspect it may be stale, call **listOnlineDevices** to refresh. +- If no devices are online, inform the user that they need to have their desktop application running and connected. +- When only one device is online, activate it directly without asking the user to choose. +- When multiple devices are online, present the list and let the user choose which device to activate. + +`; +}; + +export const systemPrompt = generateSystemPrompt(); diff --git a/packages/builtin-tool-remote-device/src/types.ts b/packages/builtin-tool-remote-device/src/types.ts new file mode 100644 index 0000000000..997fb33564 --- /dev/null +++ b/packages/builtin-tool-remote-device/src/types.ts @@ -0,0 +1,9 @@ +export const RemoteDeviceIdentifier = 'lobe-remote-device'; + +export const RemoteDeviceApiName = { + activateDevice: 'activateDevice', + listOnlineDevices: 'listOnlineDevices', +} as const; + +export type RemoteDeviceApiNameType = + (typeof RemoteDeviceApiName)[keyof typeof RemoteDeviceApiName]; diff --git a/packages/builtin-tools/package.json b/packages/builtin-tools/package.json index f6b88828ef..1846da5909 100644 --- a/packages/builtin-tools/package.json +++ b/packages/builtin-tools/package.json @@ -26,6 +26,7 @@ "@lobechat/builtin-tool-memory": "workspace:*", "@lobechat/builtin-tool-notebook": "workspace:*", "@lobechat/builtin-tool-page-agent": "workspace:*", + "@lobechat/builtin-tool-remote-device": "workspace:*", "@lobechat/builtin-tool-skill-store": "workspace:*", "@lobechat/builtin-tool-skills": "workspace:*", "@lobechat/builtin-tool-tools": "workspace:*", diff --git a/packages/builtin-tools/src/index.ts b/packages/builtin-tools/src/index.ts index 6bbb0d4b9b..eee1abc401 100644 --- a/packages/builtin-tools/src/index.ts +++ b/packages/builtin-tools/src/index.ts @@ -10,6 +10,7 @@ import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; import { MemoryManifest } from '@lobechat/builtin-tool-memory'; import { NotebookManifest } from '@lobechat/builtin-tool-notebook'; import { PageAgentManifest } from '@lobechat/builtin-tool-page-agent'; +import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device'; import { SkillStoreManifest } from '@lobechat/builtin-tool-skill-store'; import { SkillsManifest } from '@lobechat/builtin-tool-skills'; import { LobeToolsManifest } from '@lobechat/builtin-tool-tools'; @@ -131,4 +132,10 @@ export const builtinTools: LobeBuiltinTool[] = [ manifest: CalculatorManifest, type: 'builtin', }, + { + hidden: true, + identifier: RemoteDeviceManifest.identifier, + manifest: RemoteDeviceManifest, + type: 'builtin', + }, ]; diff --git a/packages/context-engine/src/engine/messages/MessagesEngine.ts b/packages/context-engine/src/engine/messages/MessagesEngine.ts index 491efd73ef..6dd17267af 100644 --- a/packages/context-engine/src/engine/messages/MessagesEngine.ts +++ b/packages/context-engine/src/engine/messages/MessagesEngine.ts @@ -143,6 +143,7 @@ export class MessagesEngine { stepContext, pageContentContext, enableSystemDate, + timezone, } = this.params; const isAgentBuilderEnabled = !!agentBuilderContext; @@ -178,7 +179,7 @@ export class MessagesEngine { new EvalContextSystemInjector({ enabled: !!evalContext?.envPrompt, evalContext }), // 3. System date injection (appends current date to system message) - new SystemDateProvider({ enabled: isSystemDateEnabled }), + new SystemDateProvider({ enabled: isSystemDateEnabled, timezone }), // ============================================= // Phase 2: First User Message Context Injection diff --git a/packages/context-engine/src/engine/messages/types.ts b/packages/context-engine/src/engine/messages/types.ts index 31446ddb98..7fa7181814 100644 --- a/packages/context-engine/src/engine/messages/types.ts +++ b/packages/context-engine/src/engine/messages/types.ts @@ -207,6 +207,8 @@ export interface MessagesEngineParams { // ========== System date ========== /** Whether to inject current date into system message (default: true) */ enableSystemDate?: boolean; + /** User timezone for system date formatting (e.g. 'Asia/Shanghai') */ + timezone?: string | null; // ========== Agent configuration ========== /** Whether to enable history message count limit */ diff --git a/packages/context-engine/src/engine/tools/ManifestLoader.ts b/packages/context-engine/src/engine/tools/ManifestLoader.ts new file mode 100644 index 0000000000..ba9d9c21ea --- /dev/null +++ b/packages/context-engine/src/engine/tools/ManifestLoader.ts @@ -0,0 +1,13 @@ +import type { LobeToolManifest } from './types'; + +/** + * Interface for loading tool manifests on demand. + * + * Used when step-level tool activations reference tools not present in + * the operation-level manifest map. Implementations can fetch manifests + * from databases, market services, or other sources. + */ +export interface ManifestLoader { + loadManifest: (toolId: string) => Promise; + loadManifests: (toolIds: string[]) => Promise>; +} diff --git a/packages/context-engine/src/engine/tools/ToolResolver.ts b/packages/context-engine/src/engine/tools/ToolResolver.ts new file mode 100644 index 0000000000..8035517fff --- /dev/null +++ b/packages/context-engine/src/engine/tools/ToolResolver.ts @@ -0,0 +1,121 @@ +import type { + ActivatedStepTool, + LobeToolManifest, + OperationToolSet, + ResolvedToolSet, + StepToolDelta, + ToolSource, + UniformTool, +} from './types'; +import { generateToolsFromManifest } from './utils'; + +/** + * Unified tool resolution engine. + * + * Single entry-point that merges operation-level tools with step-level + * dynamic activations (device, @tool mentions, LLM discovery, etc.) + * and produces the final `ResolvedToolSet` consumed by `call_llm`. + */ +export class ToolResolver { + /** + * Resolve the final tool set for an LLM call. + * + * @param operationToolSet Immutable tools determined at operation creation + * @param stepDelta Declarative tool changes for the current step + * @param accumulatedActivations Tools activated in previous steps (cumulative) + */ + resolve( + operationToolSet: OperationToolSet, + stepDelta: StepToolDelta, + accumulatedActivations: ActivatedStepTool[] = [], + ): ResolvedToolSet { + // Start from operation-level snapshot (shallow copies, with safe defaults) + const tools: UniformTool[] = [...(operationToolSet.tools ?? [])]; + const sourceMap: Record = { ...operationToolSet.sourceMap }; + const enabledToolIds: string[] = [...(operationToolSet.enabledToolIds ?? [])]; + + // Only include manifests for enabled tools to prevent injecting + // systemRole for disabled tools (e.g. web-browsing when search is off) + const manifestMap: Record = {}; + for (const id of enabledToolIds) { + if (operationToolSet.manifestMap[id]) { + manifestMap[id] = operationToolSet.manifestMap[id]; + } + } + + // Apply accumulated step-level activations from previous steps + for (const activation of accumulatedActivations) { + this.applyActivation(activation, tools, manifestMap, sourceMap, enabledToolIds); + } + + // Apply current step delta activations + for (const activation of stepDelta.activatedTools) { + this.applyActivation(activation, tools, manifestMap, sourceMap, enabledToolIds); + } + + // Handle deactivation (e.g. forceFinish strips all tools) + if (stepDelta.deactivatedToolIds?.includes('*')) { + return { + enabledToolIds: [], + manifestMap, // keep manifests for ToolNameResolver + sourceMap, + tools: [], + }; + } + + // Deduplicate tools by function name + const seen = new Set(); + const dedupedTools: UniformTool[] = []; + for (const tool of tools) { + if (!seen.has(tool.function.name)) { + seen.add(tool.function.name); + dedupedTools.push(tool); + } + } + + return { + enabledToolIds: [...new Set(enabledToolIds)], + manifestMap, + sourceMap, + tools: dedupedTools, + }; + } + + private applyActivation( + activation: { id: string; manifest?: LobeToolManifest; source?: string }, + tools: UniformTool[], + manifestMap: Record, + sourceMap: Record, + enabledToolIds: string[], + ): void { + // Skip if already present + if (manifestMap[activation.id]) return; + + if (activation.manifest) { + manifestMap[activation.id] = activation.manifest; + const newTools = generateToolsFromManifest(activation.manifest); + tools.push(...newTools); + enabledToolIds.push(activation.id); + + if (activation.source) { + sourceMap[activation.id] = this.mapSource(activation.source); + } + } + } + + private mapSource(source: string): ToolSource { + switch (source) { + case 'device': { + return 'builtin'; + } + case 'discovery': + case 'active_tools': + case 'mention': { + return 'builtin'; + } + default: { + return 'builtin'; + } + } + } +} diff --git a/packages/context-engine/src/engine/tools/__tests__/ManifestLoader.test.ts b/packages/context-engine/src/engine/tools/__tests__/ManifestLoader.test.ts new file mode 100644 index 0000000000..a060d5dec9 --- /dev/null +++ b/packages/context-engine/src/engine/tools/__tests__/ManifestLoader.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import { ToolResolver } from '../ToolResolver'; +import type { + ActivatedStepTool, + LobeToolManifest, + OperationToolSet, + StepToolDelta, +} from '../types'; + +const mockCalcManifest: LobeToolManifest = { + api: [ + { + description: 'Calculate', + name: 'calculate', + parameters: { properties: {}, type: 'object' }, + }, + ], + identifier: 'calculator', + meta: { title: 'Calculator' }, + type: 'default', +}; + +const mockSearchManifest: LobeToolManifest = { + api: [ + { + description: 'Search', + name: 'search', + parameters: { properties: {}, type: 'object' }, + }, + ], + identifier: 'web-search', + meta: { title: 'Web Search' }, + type: 'builtin', +}; + +const emptyOpSet: OperationToolSet = { + enabledToolIds: [], + manifestMap: {}, + sourceMap: {}, + tools: [], +}; + +describe('P6: Runtime manifest extension via ToolResolver', () => { + const resolver = new ToolResolver(); + + it('should merge step delta manifest into resolved manifestMap', () => { + const delta: StepToolDelta = { + activatedTools: [{ id: 'calculator', manifest: mockCalcManifest, source: 'discovery' }], + }; + + const result = resolver.resolve(emptyOpSet, delta); + + expect(result.manifestMap['calculator']).toBeDefined(); + expect(result.tools).toHaveLength(1); + expect(result.enabledToolIds).toContain('calculator'); + }); + + it('should merge accumulated step activations with manifests', () => { + const accumulated: ActivatedStepTool[] = [ + { + activatedAtStep: 1, + id: 'calculator', + manifest: mockCalcManifest, + source: 'discovery', + }, + { + activatedAtStep: 2, + id: 'web-search', + manifest: mockSearchManifest, + source: 'discovery', + }, + ]; + + const result = resolver.resolve(emptyOpSet, { activatedTools: [] }, accumulated); + + expect(result.manifestMap['calculator']).toBeDefined(); + expect(result.manifestMap['web-search']).toBeDefined(); + expect(result.tools).toHaveLength(2); + expect(result.enabledToolIds).toEqual(['calculator', 'web-search']); + }); + + it('should not override operation-level manifests with step-level ones', () => { + const opSet: OperationToolSet = { + enabledToolIds: ['web-search'], + manifestMap: { 'web-search': mockSearchManifest }, + sourceMap: { 'web-search': 'builtin' }, + tools: [ + { + function: { description: 'Search', name: 'web-search____search', parameters: {} }, + type: 'function', + }, + ], + }; + + const modifiedManifest = { ...mockSearchManifest, meta: { title: 'Modified' } }; + const delta: StepToolDelta = { + activatedTools: [{ id: 'web-search', manifest: modifiedManifest, source: 'discovery' }], + }; + + const result = resolver.resolve(opSet, delta); + + // Original manifest should be preserved (applyActivation skips existing) + expect(result.manifestMap['web-search'].meta.title).toBe('Web Search'); + expect(result.tools).toHaveLength(1); + }); + + it('should skip activations without manifest (manifest not loaded)', () => { + const delta: StepToolDelta = { + activatedTools: [{ id: 'unknown-tool', source: 'discovery' }], + }; + + const result = resolver.resolve(emptyOpSet, delta); + + expect(result.manifestMap['unknown-tool']).toBeUndefined(); + expect(result.tools).toHaveLength(0); + }); + + it('should preserve manifests even when deactivated (for ToolNameResolver)', () => { + const delta: StepToolDelta = { + activatedTools: [{ id: 'calculator', manifest: mockCalcManifest, source: 'discovery' }], + deactivatedToolIds: ['*'], + }; + + const result = resolver.resolve(emptyOpSet, delta); + + // Tools stripped, but manifest preserved + expect(result.tools).toHaveLength(0); + expect(result.manifestMap['calculator']).toBeDefined(); + }); +}); diff --git a/packages/context-engine/src/engine/tools/__tests__/ToolResolver.test.ts b/packages/context-engine/src/engine/tools/__tests__/ToolResolver.test.ts new file mode 100644 index 0000000000..e6c1dac62b --- /dev/null +++ b/packages/context-engine/src/engine/tools/__tests__/ToolResolver.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from 'vitest'; + +import { ToolResolver } from '../ToolResolver'; +import type { + ActivatedStepTool, + LobeToolManifest, + OperationToolSet, + StepToolDelta, +} from '../types'; + +// --- Mock manifests --- + +const mockSearchManifest: LobeToolManifest = { + api: [ + { + description: 'Search the web', + name: 'search', + parameters: { properties: {}, type: 'object' }, + }, + ], + identifier: 'web-search', + meta: { title: 'Web Search' }, + type: 'builtin', +}; + +const mockCalcManifest: LobeToolManifest = { + api: [ + { + description: 'Calculate expression', + name: 'calculate', + parameters: { properties: {}, type: 'object' }, + }, + ], + identifier: 'calculator', + meta: { title: 'Calculator' }, + type: 'default', +}; + +const mockLocalSystemManifest: LobeToolManifest = { + api: [ + { + description: 'Run local command', + name: 'run_command', + parameters: { properties: {}, type: 'object' }, + }, + { + description: 'Read file', + name: 'read_file', + parameters: { properties: {}, type: 'object' }, + }, + ], + identifier: 'local-system', + meta: { title: 'Local System' }, + type: 'builtin', +}; + +// --- Helpers --- + +function makeOperationToolSet(manifests: LobeToolManifest[]): OperationToolSet { + const manifestMap: Record = {}; + const sourceMap: Record = {}; + const enabledToolIds: string[] = []; + const tools: any[] = []; + + for (const m of manifests) { + manifestMap[m.identifier] = m; + enabledToolIds.push(m.identifier); + sourceMap[m.identifier] = 'builtin'; + for (const api of m.api) { + tools.push({ + function: { + description: api.description, + name: `${m.identifier}____${api.name}`, + parameters: api.parameters, + }, + type: 'function', + }); + } + } + + return { enabledToolIds, manifestMap, sourceMap, tools }; +} + +const emptyDelta: StepToolDelta = { activatedTools: [] }; + +describe('ToolResolver', () => { + const resolver = new ToolResolver(); + + describe('resolve with operation-only tools', () => { + it('should return operation tools when no step delta', () => { + const opSet = makeOperationToolSet([mockSearchManifest]); + + const result = resolver.resolve(opSet, emptyDelta); + + expect(result.tools).toHaveLength(1); + expect(result.enabledToolIds).toEqual(['web-search']); + expect(result.manifestMap['web-search']).toBeDefined(); + }); + + it('should return empty tools for empty operation set', () => { + const opSet = makeOperationToolSet([]); + + const result = resolver.resolve(opSet, emptyDelta); + + expect(result.tools).toHaveLength(0); + expect(result.enabledToolIds).toEqual([]); + }); + }); + + describe('resolve with step activations', () => { + it('should merge step-activated tools into result', () => { + const opSet = makeOperationToolSet([mockSearchManifest]); + const delta: StepToolDelta = { + activatedTools: [ + { id: 'local-system', manifest: mockLocalSystemManifest, source: 'device' }, + ], + }; + + const result = resolver.resolve(opSet, delta); + + expect(result.tools).toHaveLength(3); // 1 search + 2 local-system + expect(result.enabledToolIds).toContain('web-search'); + expect(result.enabledToolIds).toContain('local-system'); + expect(result.manifestMap['local-system']).toBeDefined(); + }); + + it('should skip activation if tool already in operation set', () => { + const opSet = makeOperationToolSet([mockSearchManifest]); + const delta: StepToolDelta = { + activatedTools: [{ id: 'web-search', manifest: mockSearchManifest, source: 'mention' }], + }; + + const result = resolver.resolve(opSet, delta); + + // Should not duplicate + expect(result.tools).toHaveLength(1); + expect(result.enabledToolIds).toEqual(['web-search']); + }); + + it('should skip activation without manifest', () => { + const opSet = makeOperationToolSet([mockSearchManifest]); + const delta: StepToolDelta = { + activatedTools: [{ id: 'unknown-tool', source: 'mention' }], + }; + + const result = resolver.resolve(opSet, delta); + + expect(result.tools).toHaveLength(1); + expect(result.manifestMap['unknown-tool']).toBeUndefined(); + }); + }); + + describe('resolve with accumulated activations', () => { + it('should apply accumulated activations from previous steps', () => { + const opSet = makeOperationToolSet([mockSearchManifest]); + const accumulated: ActivatedStepTool[] = [ + { + activatedAtStep: 1, + id: 'calculator', + manifest: mockCalcManifest, + source: 'active_tools', + }, + ]; + + const result = resolver.resolve(opSet, emptyDelta, accumulated); + + expect(result.tools).toHaveLength(2); + expect(result.enabledToolIds).toContain('calculator'); + expect(result.manifestMap['calculator']).toBeDefined(); + }); + + it('should not duplicate tools from accumulated + current delta', () => { + const opSet = makeOperationToolSet([]); + const accumulated: ActivatedStepTool[] = [ + { + activatedAtStep: 1, + id: 'calculator', + manifest: mockCalcManifest, + source: 'active_tools', + }, + ]; + const delta: StepToolDelta = { + activatedTools: [{ id: 'calculator', manifest: mockCalcManifest, source: 'mention' }], + }; + + const result = resolver.resolve(opSet, delta, accumulated); + + // calculator should appear only once + expect(result.tools).toHaveLength(1); + expect(result.enabledToolIds).toEqual(['calculator']); + }); + }); + + describe('deactivation', () => { + it('should strip all tools when deactivatedToolIds contains wildcard', () => { + const opSet = makeOperationToolSet([mockSearchManifest, mockCalcManifest]); + const delta: StepToolDelta = { + activatedTools: [], + deactivatedToolIds: ['*'], + }; + + const result = resolver.resolve(opSet, delta); + + expect(result.tools).toHaveLength(0); + expect(result.enabledToolIds).toHaveLength(0); + // manifests should still be preserved for ToolNameResolver + expect(result.manifestMap['web-search']).toBeDefined(); + expect(result.manifestMap['calculator']).toBeDefined(); + }); + }); + + describe('deduplication', () => { + it('should deduplicate tools with the same function name', () => { + const opSet = makeOperationToolSet([mockSearchManifest]); + // Manually push a duplicate tool + opSet.tools.push({ + function: { + description: 'Search the web (dup)', + name: opSet.tools[0].function.name, + parameters: {}, + }, + type: 'function', + }); + + const result = resolver.resolve(opSet, emptyDelta); + + expect(result.tools).toHaveLength(1); + }); + + it('should deduplicate enabledToolIds', () => { + const opSet = makeOperationToolSet([mockSearchManifest]); + opSet.enabledToolIds.push('web-search'); // duplicate + + const result = resolver.resolve(opSet, emptyDelta); + + expect(result.enabledToolIds).toEqual(['web-search']); + }); + }); + + describe('manifestMap should only contain enabled tools', () => { + it('should exclude manifests not in enabledToolIds from resolved manifestMap', () => { + // Simulate the bug: operationToolSet.manifestMap contains web-browsing, + // but enabledToolIds does NOT (e.g. enableChecker filtered it out) + const opSet = makeOperationToolSet([mockSearchManifest]); + + // Manually add a manifest that is NOT in enabledToolIds (simulating the bug) + const webBrowsingManifest: LobeToolManifest = { + api: [ + { + description: 'Search the web', + name: 'search', + parameters: { properties: {}, type: 'object' }, + }, + ], + identifier: 'lobe-web-browsing', + meta: { title: 'Web Browsing' }, + systemRole: 'You have a Web Browsing tool...', + type: 'builtin', + }; + opSet.manifestMap['lobe-web-browsing'] = webBrowsingManifest; + // Note: NOT added to enabledToolIds or tools + + const result = resolver.resolve(opSet, emptyDelta); + + // The resolved manifestMap should NOT contain lobe-web-browsing + // because it's not in enabledToolIds + expect(result.manifestMap['lobe-web-browsing']).toBeUndefined(); + expect(result.manifestMap['web-search']).toBeDefined(); + expect(result.enabledToolIds).toEqual(['web-search']); + }); + + it('should keep manifests for step-activated tools even if not in original enabledToolIds', () => { + const opSet = makeOperationToolSet([mockSearchManifest]); + const delta: StepToolDelta = { + activatedTools: [ + { id: 'local-system', manifest: mockLocalSystemManifest, source: 'device' }, + ], + }; + + const result = resolver.resolve(opSet, delta); + + // local-system was step-activated, so it should be in both enabledToolIds and manifestMap + expect(result.enabledToolIds).toContain('local-system'); + expect(result.manifestMap['local-system']).toBeDefined(); + }); + + it('should preserve deactivated manifests only in wildcard deactivation', () => { + // When forceFinish deactivates all tools, manifests are kept for ToolNameResolver + const opSet = makeOperationToolSet([mockSearchManifest]); + const delta: StepToolDelta = { + activatedTools: [], + deactivatedToolIds: ['*'], + }; + + const result = resolver.resolve(opSet, delta); + + expect(result.tools).toHaveLength(0); + expect(result.enabledToolIds).toHaveLength(0); + // Manifests preserved for ToolNameResolver in deactivation case + expect(result.manifestMap['web-search']).toBeDefined(); + }); + }); + + describe('defensive defaults for missing fields', () => { + it('should handle undefined enabledToolIds gracefully', () => { + // Simulate lambda path where enabledToolIds is not provided at runtime + const opSet = { + manifestMap: { 'web-search': mockSearchManifest }, + sourceMap: {}, + } as unknown as OperationToolSet; + + const result = resolver.resolve(opSet, emptyDelta); + + expect(result.enabledToolIds).toEqual([]); + expect(result.tools).toEqual([]); + // manifestMap should be empty since no enabledToolIds to match + expect(Object.keys(result.manifestMap)).toHaveLength(0); + }); + + it('should handle undefined tools gracefully', () => { + // Simulate partial toolSet missing tools array + const opSet = { + enabledToolIds: ['web-search'], + manifestMap: { 'web-search': mockSearchManifest }, + sourceMap: {}, + } as unknown as OperationToolSet; + + const result = resolver.resolve(opSet, emptyDelta); + + expect(result.tools).toEqual([]); + expect(result.enabledToolIds).toEqual(['web-search']); + expect(result.manifestMap['web-search']).toBeDefined(); + }); + + it('should handle both enabledToolIds and tools undefined without throwing', () => { + const opSet = { + manifestMap: {}, + sourceMap: {}, + } as unknown as OperationToolSet; + + expect(() => resolver.resolve(opSet, emptyDelta)).not.toThrow(); + + const result = resolver.resolve(opSet, emptyDelta); + expect(result.enabledToolIds).toEqual([]); + expect(result.tools).toEqual([]); + }); + }); + + describe('immutability', () => { + it('should not mutate the original operationToolSet', () => { + const opSet = makeOperationToolSet([mockSearchManifest]); + const originalToolCount = opSet.tools.length; + const originalIds = [...opSet.enabledToolIds]; + + const delta: StepToolDelta = { + activatedTools: [ + { id: 'local-system', manifest: mockLocalSystemManifest, source: 'device' }, + ], + }; + + resolver.resolve(opSet, delta); + + expect(opSet.tools).toHaveLength(originalToolCount); + expect(opSet.enabledToolIds).toEqual(originalIds); + expect(opSet.manifestMap['local-system']).toBeUndefined(); + }); + }); +}); diff --git a/packages/context-engine/src/engine/tools/__tests__/buildStepToolDelta.test.ts b/packages/context-engine/src/engine/tools/__tests__/buildStepToolDelta.test.ts new file mode 100644 index 0000000000..322a708a38 --- /dev/null +++ b/packages/context-engine/src/engine/tools/__tests__/buildStepToolDelta.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; + +import { buildStepToolDelta } from '../buildStepToolDelta'; +import type { LobeToolManifest } from '../types'; + +const mockLocalSystemManifest: LobeToolManifest = { + api: [ + { + description: 'Run command', + name: 'run_command', + parameters: { properties: {}, type: 'object' }, + }, + ], + identifier: 'local-system', + meta: { title: 'Local System' }, + type: 'builtin', +}; + +const mockSearchManifest: LobeToolManifest = { + api: [ + { + description: 'Search', + name: 'search', + parameters: { properties: {}, type: 'object' }, + }, + ], + identifier: 'web-search', + meta: { title: 'Web Search' }, + type: 'builtin', +}; + +describe('buildStepToolDelta', () => { + describe('device activation', () => { + it('should activate local-system when device is active and not in operation set', () => { + const delta = buildStepToolDelta({ + activeDeviceId: 'device-123', + localSystemManifest: mockLocalSystemManifest, + operationManifestMap: {}, + }); + + expect(delta.activatedTools).toHaveLength(1); + expect(delta.activatedTools[0]).toEqual({ + id: 'local-system', + manifest: mockLocalSystemManifest, + source: 'device', + }); + }); + + it('should not activate local-system when already in operation set', () => { + const delta = buildStepToolDelta({ + activeDeviceId: 'device-123', + localSystemManifest: mockLocalSystemManifest, + operationManifestMap: { 'local-system': mockLocalSystemManifest }, + }); + + expect(delta.activatedTools).toHaveLength(0); + }); + + it('should not activate when no activeDeviceId', () => { + const delta = buildStepToolDelta({ + localSystemManifest: mockLocalSystemManifest, + operationManifestMap: {}, + }); + + expect(delta.activatedTools).toHaveLength(0); + }); + + it('should not activate when no localSystemManifest', () => { + const delta = buildStepToolDelta({ + activeDeviceId: 'device-123', + operationManifestMap: {}, + }); + + expect(delta.activatedTools).toHaveLength(0); + }); + }); + + describe('mentioned tools', () => { + it('should add mentioned tools not in operation set', () => { + const delta = buildStepToolDelta({ + mentionedToolIds: ['tool-a', 'tool-b'], + operationManifestMap: {}, + }); + + expect(delta.activatedTools).toHaveLength(2); + expect(delta.activatedTools[0]).toEqual({ id: 'tool-a', source: 'mention' }); + expect(delta.activatedTools[1]).toEqual({ id: 'tool-b', source: 'mention' }); + }); + + it('should skip mentioned tools already in operation set', () => { + const delta = buildStepToolDelta({ + mentionedToolIds: ['web-search', 'tool-a'], + operationManifestMap: { 'web-search': mockSearchManifest }, + }); + + expect(delta.activatedTools).toHaveLength(1); + expect(delta.activatedTools[0].id).toBe('tool-a'); + }); + + it('should handle empty mentionedToolIds', () => { + const delta = buildStepToolDelta({ + mentionedToolIds: [], + operationManifestMap: {}, + }); + + expect(delta.activatedTools).toHaveLength(0); + }); + }); + + describe('forceFinish', () => { + it('should set deactivatedToolIds to wildcard when forceFinish is true', () => { + const delta = buildStepToolDelta({ + forceFinish: true, + operationManifestMap: {}, + }); + + expect(delta.deactivatedToolIds).toEqual(['*']); + }); + + it('should not set deactivatedToolIds when forceFinish is false', () => { + const delta = buildStepToolDelta({ + forceFinish: false, + operationManifestMap: {}, + }); + + expect(delta.deactivatedToolIds).toBeUndefined(); + }); + }); + + describe('combined signals', () => { + it('should handle device + mentions + forceFinish together', () => { + const delta = buildStepToolDelta({ + activeDeviceId: 'device-123', + forceFinish: true, + localSystemManifest: mockLocalSystemManifest, + mentionedToolIds: ['tool-a'], + operationManifestMap: {}, + }); + + expect(delta.activatedTools).toHaveLength(2); // local-system + tool-a + expect(delta.deactivatedToolIds).toEqual(['*']); + }); + + it('should return empty delta when no signals', () => { + const delta = buildStepToolDelta({ + operationManifestMap: {}, + }); + + expect(delta.activatedTools).toHaveLength(0); + expect(delta.deactivatedToolIds).toBeUndefined(); + }); + }); +}); diff --git a/packages/context-engine/src/engine/tools/__tests__/enableCheckerFactory.test.ts b/packages/context-engine/src/engine/tools/__tests__/enableCheckerFactory.test.ts new file mode 100644 index 0000000000..81715e70b1 --- /dev/null +++ b/packages/context-engine/src/engine/tools/__tests__/enableCheckerFactory.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { createEnableChecker } from '../enableCheckerFactory'; +import type { LobeToolManifest } from '../types'; + +const makeParams = (pluginId: string, overrides: Record = {}) => ({ + manifest: { + api: [{ description: 'test', name: 'test', parameters: {} }], + identifier: pluginId, + meta: {}, + type: 'builtin' as const, + } as LobeToolManifest, + model: 'gpt-4', + pluginId, + provider: 'openai', + ...overrides, +}); + +describe('createEnableChecker', () => { + describe('default behavior', () => { + it('should enable all tools by default', () => { + const checker = createEnableChecker({}); + + expect(checker(makeParams('any-tool'))).toBe(true); + }); + }); + + describe('rules', () => { + it('should disable tools matching a false rule', () => { + const checker = createEnableChecker({ + rules: { 'web-search': false }, + }); + + expect(checker(makeParams('web-search'))).toBe(false); + expect(checker(makeParams('other-tool'))).toBe(true); + }); + + it('should enable tools matching a true rule', () => { + const checker = createEnableChecker({ + rules: { 'web-search': true }, + }); + + expect(checker(makeParams('web-search'))).toBe(true); + }); + + it('should handle multiple rules', () => { + const checker = createEnableChecker({ + rules: { + 'knowledge-base': true, + 'local-system': false, + 'web-search': false, + }, + }); + + expect(checker(makeParams('local-system'))).toBe(false); + expect(checker(makeParams('web-search'))).toBe(false); + expect(checker(makeParams('knowledge-base'))).toBe(true); + expect(checker(makeParams('unrelated-tool'))).toBe(true); + }); + }); + + describe('allowExplicitActivation', () => { + it('should bypass rules when isExplicitActivation is true', () => { + const checker = createEnableChecker({ + allowExplicitActivation: true, + rules: { 'web-search': false }, + }); + + expect(checker(makeParams('web-search', { context: { isExplicitActivation: true } }))).toBe( + true, + ); + }); + + it('should not bypass when allowExplicitActivation is false', () => { + const checker = createEnableChecker({ + allowExplicitActivation: false, + rules: { 'web-search': false }, + }); + + expect(checker(makeParams('web-search', { context: { isExplicitActivation: true } }))).toBe( + false, + ); + }); + + it('should not bypass when isExplicitActivation is not set', () => { + const checker = createEnableChecker({ + allowExplicitActivation: true, + rules: { 'web-search': false }, + }); + + expect(checker(makeParams('web-search'))).toBe(false); + }); + }); + + describe('platformFilter', () => { + it('should use platformFilter result when it returns boolean', () => { + const checker = createEnableChecker({ + platformFilter: ({ pluginId }) => { + if (pluginId === 'local-system') return false; + return undefined; + }, + rules: { 'local-system': true }, + }); + + // platformFilter takes priority over rules + expect(checker(makeParams('local-system'))).toBe(false); + }); + + it('should fall through to rules when platformFilter returns undefined', () => { + const checker = createEnableChecker({ + platformFilter: () => undefined, + rules: { 'web-search': false }, + }); + + expect(checker(makeParams('web-search'))).toBe(false); + }); + + it('should receive correct parameters', () => { + let receivedParams: any; + const checker = createEnableChecker({ + platformFilter: (params) => { + receivedParams = params; + return undefined; + }, + }); + + const manifest = { + api: [{ description: 'test', name: 'test', parameters: {} }], + identifier: 'test-tool', + meta: {}, + type: 'builtin' as const, + } as LobeToolManifest; + + checker({ + context: { environment: 'desktop' }, + manifest, + model: 'gpt-4', + pluginId: 'test-tool', + provider: 'openai', + }); + + expect(receivedParams.pluginId).toBe('test-tool'); + expect(receivedParams.manifest).toBe(manifest); + expect(receivedParams.context?.environment).toBe('desktop'); + }); + }); + + describe('priority order', () => { + it('should apply: explicitActivation > platformFilter > rules > default', () => { + const checker = createEnableChecker({ + allowExplicitActivation: true, + platformFilter: ({ pluginId }) => { + if (pluginId === 'platform-blocked') return false; + return undefined; + }, + rules: { 'rule-blocked': false }, + }); + + // Explicit activation bypasses everything + expect( + checker(makeParams('platform-blocked', { context: { isExplicitActivation: true } })), + ).toBe(true); + + // Platform filter blocks + expect(checker(makeParams('platform-blocked'))).toBe(false); + + // Rule blocks + expect(checker(makeParams('rule-blocked'))).toBe(false); + + // Default enables + expect(checker(makeParams('other-tool'))).toBe(true); + }); + }); +}); diff --git a/packages/context-engine/src/engine/tools/buildStepToolDelta.ts b/packages/context-engine/src/engine/tools/buildStepToolDelta.ts new file mode 100644 index 0000000000..65c5370744 --- /dev/null +++ b/packages/context-engine/src/engine/tools/buildStepToolDelta.ts @@ -0,0 +1,64 @@ +import type { LobeToolManifest, StepToolDelta } from './types'; + +export interface BuildStepToolDeltaParams { + /** + * Currently active device ID (triggers local-system tool injection) + */ + activeDeviceId?: string; + /** + * Force finish flag — strips all tools for pure text output + */ + forceFinish?: boolean; + /** + * The local-system manifest to inject when device is active. + * Passed in to avoid a hard dependency on @lobechat/builtin-tool-local-system. + */ + localSystemManifest?: LobeToolManifest; + /** + * Tool IDs mentioned via @tool in user messages + */ + mentionedToolIds?: string[]; + /** + * The operation-level manifest map (used to check if a tool is already present) + */ + operationManifestMap: Record; +} + +/** + * Build a declarative StepToolDelta from various activation signals. + * + * All step-level tool activation logic should be expressed here, + * keeping the call_llm executor free of ad-hoc tool injection code. + */ +export function buildStepToolDelta(params: BuildStepToolDeltaParams): StepToolDelta { + const delta: StepToolDelta = { activatedTools: [] }; + + // Device activation → inject local-system tools + if ( + params.activeDeviceId && + params.localSystemManifest && + !params.operationManifestMap[params.localSystemManifest.identifier] + ) { + delta.activatedTools.push({ + id: params.localSystemManifest.identifier, + manifest: params.localSystemManifest, + source: 'device', + }); + } + + // @tool mentions + if (params.mentionedToolIds?.length) { + for (const id of params.mentionedToolIds) { + if (!params.operationManifestMap[id]) { + delta.activatedTools.push({ id, source: 'mention' }); + } + } + } + + // forceFinish → strip all tools + if (params.forceFinish) { + delta.deactivatedToolIds = ['*']; + } + + return delta; +} diff --git a/packages/context-engine/src/engine/tools/enableCheckerFactory.ts b/packages/context-engine/src/engine/tools/enableCheckerFactory.ts new file mode 100644 index 0000000000..14e9887463 --- /dev/null +++ b/packages/context-engine/src/engine/tools/enableCheckerFactory.ts @@ -0,0 +1,52 @@ +import type { LobeToolManifest, PluginEnableChecker, ToolsGenerationContext } from './types'; + +export interface EnableCheckerConfig { + /** + * Whether to allow isExplicitActivation bypass. + * When true, tools with `context.isExplicitActivation` skip all filters. + */ + allowExplicitActivation?: boolean; + + /** + * Platform-specific filter extension point. + * Return `true` to enable, `false` to disable, or `undefined` to fall through to rules. + */ + platformFilter?: (params: { + context?: ToolsGenerationContext; + manifest: LobeToolManifest; + pluginId: string; + }) => boolean | undefined; + + /** + * Tool-specific enable rules, keyed by pluginId. + * If a pluginId is present in this map, its value determines whether the tool is enabled. + * If not present, the tool is enabled by default. + */ + rules?: Record; +} + +/** + * Create a unified PluginEnableChecker from declarative configuration. + * + * Both frontend and server should use this factory to ensure consistent + * enable/disable logic. Platform-specific filters can be injected via + * the `platformFilter` extension point. + */ +export function createEnableChecker(config: EnableCheckerConfig): PluginEnableChecker { + return ({ pluginId, context, manifest }) => { + // 1. Explicit activation bypass (e.g. tools activated via lobe-tools) + if (config.allowExplicitActivation && context?.isExplicitActivation) return true; + + // 2. Platform-specific filter (return undefined = fall through) + const platformResult = config.platformFilter?.({ context, manifest, pluginId }); + if (platformResult !== undefined) return platformResult; + + // 3. Tool-specific rules + if (config.rules && pluginId in config.rules) { + return config.rules[pluginId]; + } + + // 4. Default: enabled + return true; + }; +} diff --git a/packages/context-engine/src/engine/tools/index.ts b/packages/context-engine/src/engine/tools/index.ts index 3bbaa140b5..140ffdb2ca 100644 --- a/packages/context-engine/src/engine/tools/index.ts +++ b/packages/context-engine/src/engine/tools/index.ts @@ -7,17 +7,33 @@ export { ToolNameResolver } from './ToolNameResolver'; // Tool Arguments Repairer export { ToolArgumentsRepairer, type ToolParameterSchema } from './ToolArgumentsRepairer'; +// Enable Checker Factory +export { createEnableChecker, type EnableCheckerConfig } from './enableCheckerFactory'; + +// Manifest Loader +export type { ManifestLoader } from './ManifestLoader'; + +// Tool Resolver +export { buildStepToolDelta } from './buildStepToolDelta'; +export { ToolResolver } from './ToolResolver'; + // Types and interfaces export type { + ActivatedStepTool, + ActivationSource, FunctionCallChecker, GenerateToolsParams, LobeToolManifest, + OperationToolSet, PluginEnableChecker, + ResolvedToolSet, + StepToolDelta, ToolNameGenerator, ToolsEngineOptions, ToolsGenerationContext, ToolsGenerationResult, + ToolSource, } from './types'; // Utility functions -export { filterValidManifests, validateManifest } from './utils'; +export { filterValidManifests, generateToolsFromManifest, validateManifest } from './utils'; diff --git a/packages/context-engine/src/engine/tools/types.ts b/packages/context-engine/src/engine/tools/types.ts index 013da5683e..2d77407f6c 100644 --- a/packages/context-engine/src/engine/tools/types.ts +++ b/packages/context-engine/src/engine/tools/types.ts @@ -151,3 +151,55 @@ export interface UniformTool { */ type: 'function'; } + +// ---- Tool Lifecycle Types ---- + +export type ToolSource = 'builtin' | 'plugin' | 'mcp' | 'klavis' | 'lobehubSkill'; + +/** + * How a tool was activated at step level + */ +export type ActivationSource = 'active_tools' | 'mention' | 'device' | 'discovery'; + +/** + * Operation-level tool set: determined at createOperation time, immutable during execution. + */ +export interface OperationToolSet { + enabledToolIds: string[]; + manifestMap: Record; + sourceMap: Record; + tools: UniformTool[]; +} + +/** + * Record of a tool activated at step level. + */ +export interface ActivatedStepTool { + activatedAtStep: number; + id: string; + manifest?: LobeToolManifest; + source: ActivationSource; +} + +/** + * Declarative delta describing tool changes for a single step. + * Built by `buildStepToolDelta`, consumed by `ToolResolver.resolve`. + */ +export interface StepToolDelta { + activatedTools: Array<{ + id: string; + manifest?: LobeToolManifest; + source: ActivationSource; + }>; + deactivatedToolIds?: string[]; +} + +/** + * Final resolved tool set ready for LLM call. + */ +export interface ResolvedToolSet { + enabledToolIds: string[]; + manifestMap: Record; + sourceMap: Record; + tools: UniformTool[]; +} diff --git a/packages/context-engine/src/engine/tools/utils.ts b/packages/context-engine/src/engine/tools/utils.ts index 0a8c7042b5..4eb775a7c4 100644 --- a/packages/context-engine/src/engine/tools/utils.ts +++ b/packages/context-engine/src/engine/tools/utils.ts @@ -1,5 +1,5 @@ import { ToolNameResolver } from './ToolNameResolver'; -import type { LobeToolManifest } from './types'; +import type { LobeToolManifest, UniformTool } from './types'; // Create a singleton instance for backward compatibility const resolver = new ToolNameResolver(); @@ -16,6 +16,20 @@ export const generateToolName = ( return resolver.generate(identifier, name, type); }; +/** + * Convert a tool manifest into LLM-compatible UniformTool definitions + */ +export function generateToolsFromManifest(manifest: LobeToolManifest): UniformTool[] { + return manifest.api.map((api) => ({ + function: { + description: api.description, + name: new ToolNameResolver().generate(manifest.identifier, api.name, manifest.type), + parameters: api.parameters, + }, + type: 'function' as const, + })); +} + /** * Validate manifest schema structure */ diff --git a/packages/context-engine/src/providers/SystemDateProvider.ts b/packages/context-engine/src/providers/SystemDateProvider.ts index 46250ffb0e..b1d95ff3f8 100644 --- a/packages/context-engine/src/providers/SystemDateProvider.ts +++ b/packages/context-engine/src/providers/SystemDateProvider.ts @@ -7,6 +7,7 @@ const log = debug('context-engine:provider:SystemDateProvider'); export interface SystemDateProviderConfig { enabled?: boolean; + timezone?: string | null; } export class SystemDateProvider extends BaseProvider { @@ -27,13 +28,15 @@ export class SystemDateProvider extends BaseProvider { return this.markAsExecuted(clonedContext); } + const tz = this.config.timezone || 'UTC'; const today = new Date(); - const year = today.getFullYear(); - const month = String(today.getMonth() + 1).padStart(2, '0'); - const day = String(today.getDate()).padStart(2, '0'); + + const year = today.toLocaleString('en-US', { timeZone: tz, year: 'numeric' }); + const month = today.toLocaleString('en-US', { month: '2-digit', timeZone: tz }); + const day = today.toLocaleString('en-US', { day: '2-digit', timeZone: tz }); const dateStr = `${year}-${month}-${day}`; - const dateContent = `Current date: ${dateStr}`; + const dateContent = `Current date: ${dateStr} (${tz})`; const existingSystemMessage = clonedContext.messages.find((msg) => msg.role === 'system'); diff --git a/packages/context-engine/src/providers/__tests__/SystemDateProvider.test.ts b/packages/context-engine/src/providers/__tests__/SystemDateProvider.test.ts new file mode 100644 index 0000000000..c7fa37ffcc --- /dev/null +++ b/packages/context-engine/src/providers/__tests__/SystemDateProvider.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { SystemDateProvider } from '../SystemDateProvider'; + +const createContext = (messages: any[] = []) => ({ + initialState: { + messages: [], + model: 'gpt-4', + provider: 'openai', + systemRole: '', + tools: [], + }, + isAborted: false, + messages, + metadata: { + maxTokens: 4096, + model: 'gpt-4', + }, +}); + +describe('SystemDateProvider', () => { + it('should inject current date with UTC timezone by default', async () => { + const provider = new SystemDateProvider({}); + const context = createContext([ + { content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() }, + ]); + + const result = await provider.process(context); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0].role).toBe('system'); + expect(result.messages[0].content).toMatch(/^Current date: \d{4}-\d{2}-\d{2} \(UTC\)$/); + expect(result.metadata.systemDateInjected).toBe(true); + }); + + it('should include timezone name when timezone is provided', async () => { + const provider = new SystemDateProvider({ timezone: 'Asia/Shanghai' }); + const context = createContext([ + { content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() }, + ]); + + const result = await provider.process(context); + + expect(result.messages[0].content).toMatch( + /^Current date: \d{4}-\d{2}-\d{2} \(Asia\/Shanghai\)$/, + ); + }); + + it('should fallback to UTC when timezone is null', async () => { + const provider = new SystemDateProvider({ timezone: null }); + const context = createContext([ + { content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() }, + ]); + + const result = await provider.process(context); + + expect(result.messages[0].content).toMatch(/\(UTC\)$/); + }); + + it('should append date to existing system message', async () => { + const provider = new SystemDateProvider({ timezone: 'America/New_York' }); + const context = createContext([ + { + content: 'You are a helpful assistant.', + createdAt: Date.now(), + id: 'sys', + role: 'system', + updatedAt: Date.now(), + }, + { content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() }, + ]); + + const result = await provider.process(context); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0].content).toMatch( + /^You are a helpful assistant\.\n\nCurrent date: \d{4}-\d{2}-\d{2} \(America\/New_York\)$/, + ); + }); + + it('should skip injection when disabled', async () => { + const provider = new SystemDateProvider({ enabled: false }); + const context = createContext([ + { content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() }, + ]); + + const result = await provider.process(context); + + expect(result.messages).toHaveLength(1); + expect(result.metadata.systemDateInjected).toBeUndefined(); + }); + + it('should create system message when no messages exist', async () => { + const provider = new SystemDateProvider({ timezone: 'Europe/London' }); + const context = createContext([]); + + const result = await provider.process(context); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('system'); + expect(result.messages[0].content).toMatch( + /^Current date: \d{4}-\d{2}-\d{2} \(Europe\/London\)$/, + ); + }); +}); diff --git a/packages/model-runtime/src/core/contextBuilders/anthropic.ts b/packages/model-runtime/src/core/contextBuilders/anthropic.ts index 3b703bb0ce..27b7d2497d 100644 --- a/packages/model-runtime/src/core/contextBuilders/anthropic.ts +++ b/packages/model-runtime/src/core/contextBuilders/anthropic.ts @@ -128,12 +128,18 @@ export const buildAnthropicMessage = async ( content: [ // avoid empty text content block ...messageContent, - ...(message.tool_calls.map((tool) => ({ - id: tool.id, - input: JSON.parse(tool.function.arguments), - name: tool.function.name, - type: 'tool_use', - })) as any), + ...(message.tool_calls.map((tool) => { + let input: Record = {}; + try { + input = JSON.parse(tool.function.arguments); + } catch {} + return { + id: tool.id, + input, + name: tool.function.name, + type: 'tool_use', + }; + }) as any), ].filter(Boolean), role: 'assistant', }; diff --git a/packages/types/src/agent/agencyConfig.ts b/packages/types/src/agent/agencyConfig.ts index 670c8181da..8fb07386fc 100644 --- a/packages/types/src/agent/agencyConfig.ts +++ b/packages/types/src/agent/agencyConfig.ts @@ -22,6 +22,7 @@ export interface SlackBotConfig { * Each agent can independently configure its own bot providers. */ export interface LobeAgentAgencyConfig { + boundDeviceId?: string; discord?: DiscordBotConfig; slack?: SlackBotConfig; } diff --git a/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts b/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts index 5890d4ec8c..2ceb946694 100644 --- a/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts +++ b/src/app/(backend)/api/agent/webhooks/bot-callback/route.ts @@ -2,136 +2,16 @@ import debug from 'debug'; import { NextResponse } from 'next/server'; import { getServerDB } from '@/database/core/db-adaptor'; -import { AgentBotProviderModel } from '@/database/models/agentBotProvider'; -import { TopicModel } from '@/database/models/topic'; import { verifyQStashSignature } from '@/libs/qstash'; -import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; -import { DiscordRestApi } from '@/server/services/bot/discordRestApi'; -import { LarkRestApi } from '@/server/services/bot/larkRestApi'; -import { - renderError, - renderFinalReply, - renderStepProgress, - splitMessage, -} from '@/server/services/bot/replyTemplate'; -import { TelegramRestApi } from '@/server/services/bot/telegramRestApi'; -import { SystemAgentService } from '@/server/services/systemAgent'; +import { BotCallbackService } from '@/server/services/bot/BotCallbackService'; const log = debug('api-route:agent:bot-callback'); -// --------------- Platform-specific helpers --------------- - -/** - * Parse a Chat SDK platformThreadId (e.g. "discord:guildId:channelId[:threadId]") - * and return the actual Discord channel ID to send messages to. - */ -function extractDiscordChannelId(platformThreadId: string): string { - const parts = platformThreadId.split(':'); - // parts[0]='discord', parts[1]=guildId, parts[2]=channelId, parts[3]=threadId (optional) - // When there's a Discord thread, use threadId; otherwise use channelId - return parts[3] || parts[2]; -} - -/** - * Parse a Chat SDK platformThreadId (e.g. "telegram:chatId[:messageThreadId]") - * and return the Telegram chat ID. - */ -function extractTelegramChatId(platformThreadId: string): string { - const parts = platformThreadId.split(':'); - // parts[0]='telegram', parts[1]=chatId - return parts[1]; -} - -/** - * Detect platform from platformThreadId prefix. - */ -function detectPlatform(platformThreadId: string): string { - return platformThreadId.split(':')[0]; -} - -/** - * Extract chat ID from Lark platformThreadId (e.g. "lark:oc_xxx" or "feishu:oc_xxx"). - */ -function extractLarkChatId(platformThreadId: string): string { - const parts = platformThreadId.split(':'); - return parts[1]; -} - -/** Telegram has a 4096 char limit vs Discord's 2000 */ -const TELEGRAM_CHAR_LIMIT = 4000; -const LARK_CHAR_LIMIT = 4000; - -// --------------- Platform-agnostic message interface --------------- - -interface PlatformMessenger { - createMessage: (content: string) => Promise; - editMessage: (messageId: string, content: string) => Promise; - removeReaction: (messageId: string, emoji: string) => Promise; - triggerTyping: () => Promise; - updateThreadName?: (name: string) => Promise; -} - -function createDiscordMessenger( - discord: DiscordRestApi, - channelId: string, - platformThreadId: string, -): PlatformMessenger { - return { - createMessage: (content) => discord.createMessage(channelId, content).then(() => {}), - editMessage: (messageId, content) => discord.editMessage(channelId, messageId, content), - removeReaction: (messageId, emoji) => discord.removeOwnReaction(channelId, messageId, emoji), - triggerTyping: () => discord.triggerTyping(channelId), - updateThreadName: (name) => { - const parts = platformThreadId.split(':'); - const threadId = parts[3]; - if (threadId) { - return discord.updateChannelName(threadId, name); - } - return Promise.resolve(); - }, - }; -} - -/** - * Parse a Chat SDK composite Telegram message ID ("chatId:messageId") into - * the raw numeric message ID that the Telegram Bot API expects. - */ -function parseTelegramMessageId(compositeId: string): number { - // Format: "chatId:messageId" e.g. "-100123456:42" - const colonIdx = compositeId.lastIndexOf(':'); - if (colonIdx !== -1) { - return Number(compositeId.slice(colonIdx + 1)); - } - return Number(compositeId); -} - -function createTelegramMessenger(telegram: TelegramRestApi, chatId: string): PlatformMessenger { - return { - createMessage: (content) => telegram.sendMessage(chatId, content).then(() => {}), - editMessage: (messageId, content) => - telegram.editMessageText(chatId, parseTelegramMessageId(messageId), content), - removeReaction: (messageId) => - telegram.removeMessageReaction(chatId, parseTelegramMessageId(messageId)), - triggerTyping: () => telegram.sendChatAction(chatId, 'typing'), - }; -} - -function createLarkMessenger(lark: LarkRestApi, chatId: string): PlatformMessenger { - return { - createMessage: (content) => lark.sendMessage(chatId, content).then(() => {}), - editMessage: (messageId, content) => lark.editMessage(messageId, content), - // Lark has no reaction/typing API for bots - removeReaction: () => Promise.resolve(), - triggerTyping: () => Promise.resolve(), - }; -} - /** * Bot callback endpoint for agent step/completion webhooks. * * In queue mode, AgentRuntimeService fires webhooks (via QStash) after each step - * and on completion. This endpoint processes those callbacks and updates - * platform messages via REST API. + * and on completion. This endpoint verifies the signature and delegates to BotCallbackService. * * Route: POST /api/agent/webhooks/bot-callback */ @@ -145,11 +25,10 @@ export async function POST(request: Request): Promise { const body = JSON.parse(rawBody); - const { type, applicationId, platformThreadId, progressMessageId, userMessageId } = body; + const { type, applicationId, platformThreadId, progressMessageId } = body; log( - 'bot-callback: parsed body keys=%s, type=%s, applicationId=%s, platformThreadId=%s, progressMessageId=%s', - Object.keys(body).join(','), + 'bot-callback: type=%s, applicationId=%s, platformThreadId=%s, progressMessageId=%s', type, applicationId, platformThreadId, @@ -165,122 +44,14 @@ export async function POST(request: Request): Promise { ); } - const platform = detectPlatform(platformThreadId); - - log( - 'bot-callback: type=%s, platform=%s, appId=%s, thread=%s', - type, - platform, - applicationId, - platformThreadId, - ); + if (type !== 'step' && type !== 'completion') { + return NextResponse.json({ error: `Unknown callback type: ${type}` }, { status: 400 }); + } try { - // Look up bot token from DB const serverDB = await getServerDB(); - const row = await AgentBotProviderModel.findByPlatformAndAppId( - serverDB, - platform, - applicationId, - ); - - if (!row?.credentials) { - log('bot-callback: no bot provider found for %s appId=%s', platform, applicationId); - return NextResponse.json({ error: 'Bot provider not found' }, { status: 404 }); - } - - // Decrypt credentials - const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); - let credentials: Record; - try { - credentials = JSON.parse((await gateKeeper.decrypt(row.credentials)).plaintext); - } catch { - credentials = JSON.parse(row.credentials); - } - - // Validate required credentials exist for the platform - const isLark = platform === 'lark' || platform === 'feishu'; - if (isLark ? !credentials.appId || !credentials.appSecret : !credentials.botToken) { - log('bot-callback: missing credentials for %s appId=%s', platform, applicationId); - return NextResponse.json({ error: 'Bot credentials incomplete' }, { status: 500 }); - } - - // Create platform-specific messenger - let messenger: PlatformMessenger; - let charLimit: number | undefined; - - switch (platform) { - case 'telegram': { - const telegram = new TelegramRestApi(credentials.botToken); - const chatId = extractTelegramChatId(platformThreadId); - messenger = createTelegramMessenger(telegram, chatId); - charLimit = TELEGRAM_CHAR_LIMIT; - break; - } - case 'lark': - case 'feishu': { - const lark = new LarkRestApi(credentials.appId, credentials.appSecret, platform); - const chatId = extractLarkChatId(platformThreadId); - messenger = createLarkMessenger(lark, chatId); - charLimit = LARK_CHAR_LIMIT; - break; - } - case 'discord': - default: { - const discord = new DiscordRestApi(credentials.botToken); - const channelId = extractDiscordChannelId(platformThreadId); - messenger = createDiscordMessenger(discord, channelId, platformThreadId); - break; - } - } - - if (type === 'step') { - await handleStepCallback(body, messenger, progressMessageId, platform); - } else if (type === 'completion') { - await handleCompletionCallback(body, messenger, progressMessageId, platform, charLimit); - - // Remove eyes reaction from the original user message - if (userMessageId) { - try { - await messenger.removeReaction(userMessageId, '👀'); - } catch (error) { - log('bot-callback: failed to remove eyes reaction: %O', error); - } - } - - // Fire-and-forget: summarize topic title and update thread name - const { reason, topicId, userId, userPrompt, lastAssistantContent } = body; - if (reason !== 'error' && topicId && userId && userPrompt && lastAssistantContent) { - const topicModel = new TopicModel(serverDB, userId); - topicModel - .findById(topicId) - .then(async (topic) => { - // Only generate when topic has an empty title - if (topic?.title) return; - - const systemAgent = new SystemAgentService(serverDB, userId); - const title = await systemAgent.generateTopicTitle({ - lastAssistantContent, - userPrompt, - }); - if (!title) return; - - await topicModel.update(topicId, { title }); - - // Update thread/channel name if the platform supports it - if (messenger.updateThreadName) { - messenger.updateThreadName(title).catch((error) => { - log('bot-callback: failed to update thread name: %O', error); - }); - } - }) - .catch((error) => { - log('bot-callback: topic title summarization failed: %O', error); - }); - } - } else { - return NextResponse.json({ error: `Unknown callback type: ${type}` }, { status: 400 }); - } + const service = new BotCallbackService(serverDB); + await service.handleCallback(body); return NextResponse.json({ success: true }); } catch (error) { @@ -291,93 +62,3 @@ export async function POST(request: Request): Promise { ); } } - -async function handleStepCallback( - body: Record, - messenger: PlatformMessenger, - progressMessageId: string, - platform?: string, -): Promise { - const { shouldContinue } = body; - if (!shouldContinue) return; - - const progressText = renderStepProgress({ - content: body.content, - elapsedMs: body.elapsedMs, - executionTimeMs: body.executionTimeMs ?? 0, - lastContent: body.lastLLMContent, - lastToolsCalling: body.lastToolsCalling, - platform, - reasoning: body.reasoning, - stepType: body.stepType ?? 'call_llm', - thinking: body.thinking ?? false, - toolsCalling: body.toolsCalling, - toolsResult: body.toolsResult, - totalCost: body.totalCost ?? 0, - totalInputTokens: body.totalInputTokens ?? 0, - totalOutputTokens: body.totalOutputTokens ?? 0, - totalSteps: body.totalSteps ?? 0, - totalTokens: body.totalTokens ?? 0, - totalToolCalls: body.totalToolCalls, - }); - - // If the LLM returned text without tool calls, the next step is 'finish' — skip typing - const isLlmFinalResponse = - body.stepType === 'call_llm' && !body.toolsCalling?.length && body.content; - - try { - await messenger.editMessage(progressMessageId, progressText); - if (!isLlmFinalResponse) { - await messenger.triggerTyping(); - } - } catch (error) { - log('handleStepCallback: failed to edit progress message: %O', error); - } -} - -async function handleCompletionCallback( - body: Record, - messenger: PlatformMessenger, - progressMessageId: string, - platform?: string, - charLimit?: number, -): Promise { - const { reason, lastAssistantContent, errorMessage } = body; - - if (reason === 'error') { - const errorText = renderError(errorMessage || 'Agent execution failed'); - try { - await messenger.editMessage(progressMessageId, errorText); - } catch (error) { - log('handleCompletionCallback: failed to edit error message: %O', error); - } - return; - } - - if (!lastAssistantContent) { - log('handleCompletionCallback: no lastAssistantContent, skipping'); - return; - } - - const finalText = renderFinalReply(lastAssistantContent, { - elapsedMs: body.duration, - llmCalls: body.llmCalls ?? 0, - platform, - toolCalls: body.toolCalls ?? 0, - totalCost: body.cost ?? 0, - totalTokens: body.totalTokens ?? 0, - }); - - const chunks = splitMessage(finalText, charLimit); - - try { - await messenger.editMessage(progressMessageId, chunks[0]); - - // Post overflow chunks as follow-up messages - for (let i = 1; i < chunks.length; i++) { - await messenger.createMessage(chunks[i]); - } - } catch (error) { - log('handleCompletionCallback: failed to edit/post final message: %O', error); - } -} diff --git a/src/envs/gateway.ts b/src/envs/gateway.ts new file mode 100644 index 0000000000..75070dba63 --- /dev/null +++ b/src/envs/gateway.ts @@ -0,0 +1,18 @@ +import { createEnv } from '@t3-oss/env-core'; +import { z } from 'zod'; + +export const getGatewayConfig = () => { + return createEnv({ + runtimeEnv: { + DEVICE_GATEWAY_SERVICE_TOKEN: process.env.DEVICE_GATEWAY_SERVICE_TOKEN, + DEVICE_GATEWAY_URL: process.env.DEVICE_GATEWAY_URL, + }, + + server: { + DEVICE_GATEWAY_SERVICE_TOKEN: z.string().optional(), + DEVICE_GATEWAY_URL: z.string().url().optional(), + }, + }); +}; + +export const gatewayEnv = getGatewayConfig(); diff --git a/src/helpers/toolEngineering/index.ts b/src/helpers/toolEngineering/index.ts index 085123f562..3370957874 100644 --- a/src/helpers/toolEngineering/index.ts +++ b/src/helpers/toolEngineering/index.ts @@ -6,7 +6,7 @@ import { MemoryManifest } from '@lobechat/builtin-tool-memory'; import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing'; import { defaultToolIds } from '@lobechat/builtin-tools'; import { isDesktop } from '@lobechat/const'; -import { type PluginEnableChecker } from '@lobechat/context-engine'; +import { createEnableChecker, type PluginEnableChecker } from '@lobechat/context-engine'; import { ToolsEngine } from '@lobechat/context-engine'; import { type ChatCompletionTool, type WorkingModel } from '@lobechat/types'; import { type LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk'; @@ -81,51 +81,34 @@ export const createToolsEngine = (config: ToolsEngineConfig = {}): ToolsEngine = }); }; -export const createAgentToolsEngine = (workingModel: WorkingModel) => - createToolsEngine({ - // Add default tools based on configuration +export const createAgentToolsEngine = (workingModel: WorkingModel) => { + const searchConfig = getSearchConfig(workingModel.model, workingModel.provider); + const agentState = getAgentStoreState(); + + return createToolsEngine({ defaultToolIds, - // Create search-aware enableChecker for this request - enableChecker: ({ pluginId, context }) => { - // Explicitly activated tools (via lobe-tools activateTools) bypass all filters - if (context?.isExplicitActivation) return true; + enableChecker: createEnableChecker({ + allowExplicitActivation: true, + platformFilter: ({ pluginId }) => { + // Platform-specific constraints (e.g., LocalSystem desktop-only) + if (!shouldEnableTool(pluginId)) return false; - // Check platform-specific constraints (e.g., LocalSystem desktop-only) - if (!shouldEnableTool(pluginId)) { - return false; - } - - // Filter stdio MCP tools in non-desktop environments - // stdio transport requires Electron IPC and cannot work on web - if (!isDesktop) { - const plugin = pluginSelectors.getInstalledPluginById(pluginId)(getToolStoreState()); - if (plugin?.customParams?.mcp?.type === 'stdio') { - return false; + // Filter stdio MCP tools in non-desktop environments + if (!isDesktop) { + const plugin = pluginSelectors.getInstalledPluginById(pluginId)(getToolStoreState()); + if (plugin?.customParams?.mcp?.type === 'stdio') return false; } - } - // For WebBrowsingManifest, apply search logic - if (pluginId === WebBrowsingManifest.identifier) { - const searchConfig = getSearchConfig(workingModel.model, workingModel.provider); - return searchConfig.useApplicationBuiltinSearchTool; - } - - // For KnowledgeBaseManifest, only enable if knowledge is enabled - if (pluginId === KnowledgeBaseManifest.identifier) { - const agentState = getAgentStoreState(); - - return agentSelectors.hasEnabledKnowledgeBases(agentState); - } - - // For MemoryManifest, check per-agent memory tool toggle - if (pluginId === MemoryManifest.identifier) { - return agentChatConfigSelectors.isMemoryToolEnabled(getAgentStoreState()); - } - - // For all other plugins, enable by default - return true; - }, + return undefined; // fall through to rules + }, + rules: { + [KnowledgeBaseManifest.identifier]: agentSelectors.hasEnabledKnowledgeBases(agentState), + [MemoryManifest.identifier]: agentChatConfigSelectors.isMemoryToolEnabled(agentState), + [WebBrowsingManifest.identifier]: searchConfig.useApplicationBuiltinSearchTool, + }, + }), }); +}; /** * Provides the same functionality using ToolsEngine with enhanced capabilities diff --git a/src/server/modules/AgentRuntime/RuntimeExecutors.ts b/src/server/modules/AgentRuntime/RuntimeExecutors.ts index cefd4920f6..a90b11cd32 100644 --- a/src/server/modules/AgentRuntime/RuntimeExecutors.ts +++ b/src/server/modules/AgentRuntime/RuntimeExecutors.ts @@ -6,7 +6,15 @@ import { type InstructionExecutor, UsageCounter, } from '@lobechat/agent-runtime'; -import { ToolNameResolver } from '@lobechat/context-engine'; +import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; +import { + buildStepToolDelta, + type LobeToolManifest, + type OperationToolSet, + type ResolvedToolSet, + ToolNameResolver, + ToolResolver, +} from '@lobechat/context-engine'; import { parse } from '@lobechat/conversation-flow'; import { consumeStreamUntilDone } from '@lobechat/model-runtime'; import { type ChatToolPayload, type MessageToolCall, type UIChatMessage } from '@lobechat/types'; @@ -45,6 +53,7 @@ export interface RuntimeExecutorContext { toolExecutionService: ToolExecutionService; topicId?: string; userId?: string; + userTimezone?: string; } export const createRuntimeExecutors = ( @@ -63,9 +72,38 @@ export const createRuntimeExecutors = ( // Fallback to state's modelRuntimeConfig if not in payload const model = llmPayload.model || state.modelRuntimeConfig?.model; const provider = llmPayload.provider || state.modelRuntimeConfig?.provider; - // forceFinish: strip tools so LLM produces pure text output - // Otherwise fallback to state's tools if not in payload - const tools = state.forceFinish ? undefined : llmPayload.tools || state.tools; + // Resolve tools via ToolResolver (unified tool injection) + const activeDeviceId = state.metadata?.activeDeviceId; + const operationToolSet: OperationToolSet = state.operationToolSet ?? { + enabledToolIds: [], + manifestMap: state.toolManifestMap ?? {}, + sourceMap: state.toolSourceMap ?? {}, + tools: state.tools ?? [], + }; + + const stepDelta = buildStepToolDelta({ + activeDeviceId, + forceFinish: state.forceFinish, + localSystemManifest: LocalSystemManifest as unknown as LobeToolManifest, + operationManifestMap: operationToolSet.manifestMap, + }); + + const toolResolver = new ToolResolver(); + const resolved: ResolvedToolSet = toolResolver.resolve( + operationToolSet, + stepDelta, + state.activatedStepTools ?? [], + ); + + const tools = resolved.tools.length > 0 ? resolved.tools : undefined; + + if (stepDelta.activatedTools.length > 0) { + log( + `[${operationId}:${stepIndex}] ToolResolver injected %d step-level tools: %o`, + stepDelta.activatedTools.length, + stepDelta.activatedTools.map((t) => t.id), + ); + } if (!model || !provider) { throw new Error('Model and provider are required for call_llm instruction'); @@ -137,6 +175,8 @@ export const createRuntimeExecutors = ( const { LOBE_DEFAULT_MODEL_LIST } = await import('model-bank'); const contextEngineInput = { + additionalVariables: state.metadata?.deviceSystemInfo, + userTimezone: ctx.userTimezone, capabilities: { isCanUseFC: (m: string, p: string) => { const info = LOBE_DEFAULT_MODEL_LIST.find( @@ -182,7 +222,8 @@ export const createRuntimeExecutors = ( provider, systemRole: agentConfig.systemRole ?? undefined, toolsConfig: { - tools: agentConfig.plugins ?? [], + manifests: Object.values(resolved.manifestMap), + tools: resolved.enabledToolIds, }, userMemory: state.metadata?.userMemory, }; @@ -336,11 +377,11 @@ export const createRuntimeExecutors = ( } }, onToolsCalling: async ({ toolsCalling: raw }) => { - const resolved = new ToolNameResolver().resolve(raw, state.toolManifestMap); - // Add source field from toolSourceMap for routing tool execution - const payload = resolved.map((p) => ({ + const resolvedCalls = new ToolNameResolver().resolve(raw, resolved.manifestMap); + // Add source field from resolved sourceMap for routing tool execution + const payload = resolvedCalls.map((p) => ({ ...p, - source: state.toolSourceMap?.[p.identifier], + source: resolved.sourceMap[p.identifier], })); // log(`[${operationLogId}][toolsCalling]`, payload); toolsCalling = payload; @@ -567,12 +608,23 @@ export const createRuntimeExecutors = ( const agentConfig = state.metadata?.agentConfig; const toolResultMaxLength = agentConfig?.chatConfig?.toolResultMaxLength; + // Build effective manifest map (operation + step-level activations) + const effectiveManifestMap = { + ...(state.operationToolSet?.manifestMap ?? state.toolManifestMap), + ...Object.fromEntries( + (state.activatedStepTools ?? []) + .filter((a) => a.manifest) + .map((a) => [a.id, a.manifest!]), + ), + }; + // Execute tool using ToolExecutionService log(`[${operationLogId}] Executing tool ${toolName} ...`); const executionResult = await toolExecutionService.executeTool(chatToolPayload, { + activeDeviceId: state.metadata?.activeDeviceId, memoryToolPermission: agentConfig?.chatConfig?.memory?.toolPermission, serverDB: ctx.serverDB, - toolManifestMap: state.toolManifestMap, + toolManifestMap: effectiveManifestMap, toolResultMaxLength, topicId: ctx.topicId, userId: ctx.userId, @@ -644,6 +696,34 @@ export const createRuntimeExecutors = ( newState.usage = usage; if (cost) newState.cost = cost; + // Persist ToolsActivator discovery results to state.activatedStepTools + const discoveredTools = executionResult.state?.activatedTools as + | Array<{ identifier: string }> + | undefined; + if (discoveredTools?.length) { + const existingIds = new Set( + (newState.activatedStepTools ?? []).map((t: { id: string }) => t.id), + ); + const newActivations = discoveredTools + .filter((t) => !existingIds.has(t.identifier)) + .map((t) => ({ + activatedAtStep: state.stepCount, + id: t.identifier, + manifest: effectiveManifestMap[t.identifier], + source: 'discovery' as const, + })); + + if (newActivations.length > 0) { + newState.activatedStepTools = [...(newState.activatedStepTools ?? []), ...newActivations]; + + log( + `[${operationLogId}] Persisted %d tool activations to state: %o`, + newActivations.length, + newActivations.map((a) => a.id), + ); + } + } + // Find current tool statistics const currentToolStats = usage.tools.byTool.find((t) => t.name === toolName); @@ -746,10 +826,24 @@ export const createRuntimeExecutors = ( try { log(`[${operationLogId}] Executing tool ${toolName} ...`); + // Build effective manifest map (operation + step-level activations) + const batchManifestMap = { + ...(state.operationToolSet?.manifestMap ?? state.toolManifestMap), + ...Object.fromEntries( + (state.activatedStepTools ?? []) + .filter((a) => a.manifest) + .map((a) => [a.id, a.manifest!]), + ), + }; + + const batchAgentConfig = state.metadata?.agentConfig; + const executionResult = await toolExecutionService.executeTool(chatToolPayload, { - memoryToolPermission: state.metadata?.agentConfig?.chatConfig?.memory?.toolPermission, + activeDeviceId: state.metadata?.activeDeviceId, + memoryToolPermission: batchAgentConfig?.chatConfig?.memory?.toolPermission, serverDB: ctx.serverDB, - toolManifestMap: state.toolManifestMap, + toolManifestMap: batchManifestMap, + toolResultMaxLength: batchAgentConfig?.chatConfig?.toolResultMaxLength, topicId: ctx.topicId, userId: ctx.userId, }); @@ -851,6 +945,40 @@ export const createRuntimeExecutors = ( } } + // Persist ToolsActivator discovery results from batch tool executions + const batchEffectiveManifestMap = { + ...(state.operationToolSet?.manifestMap ?? state.toolManifestMap), + ...Object.fromEntries( + (state.activatedStepTools ?? []).filter((a) => a.manifest).map((a) => [a.id, a.manifest!]), + ), + }; + const existingActivationIds = new Set( + (newState.activatedStepTools ?? []).map((t: { id: string }) => t.id), + ); + for (const result of toolResults) { + const discovered = result.data?.state?.activatedTools as + | Array<{ identifier: string }> + | undefined; + if (discovered?.length) { + const newActivations = discovered + .filter((t) => !existingActivationIds.has(t.identifier)) + .map((t) => ({ + activatedAtStep: state.stepCount, + id: t.identifier, + manifest: batchEffectiveManifestMap[t.identifier], + source: 'discovery' as const, + })); + + for (const activation of newActivations) { + existingActivationIds.add(activation.id); + } + + if (newActivations.length > 0) { + newState.activatedStepTools = [...(newState.activatedStepTools ?? []), ...newActivations]; + } + } + } + // Refresh messages from database to ensure state is in sync // Query latest messages from database diff --git a/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts b/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts index ca8e0ebc0e..cb3c79b94f 100644 --- a/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts +++ b/src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts @@ -447,15 +447,19 @@ describe('RuntimeExecutors', () => { it('should pass tools normally when state.forceFinish is not set', async () => { const executors = createRuntimeExecutors(ctx); - const state = createMockState(); + const tools = [ + { + function: { description: 'Search the web', name: 'search' }, + type: 'function' as const, + }, + ]; + const state = createMockState({ tools: tools as any }); - const tools = [{ description: 'Search the web', name: 'search' }]; const instruction = { payload: { messages: [{ content: 'Hello', role: 'user' }], model: 'gpt-4', provider: 'openai', - tools, }, type: 'call_llm' as const, }; @@ -470,7 +474,12 @@ describe('RuntimeExecutors', () => { it('should fallback to state.tools when payload.tools is not provided', async () => { const executors = createRuntimeExecutors(ctx); - const stateTools = [{ description: 'State tool', name: 'state-tool' }]; + const stateTools = [ + { + function: { description: 'State tool', name: 'state-tool' }, + type: 'function' as const, + }, + ]; const state = createMockState({ tools: stateTools as any }); const instruction = { @@ -494,7 +503,12 @@ describe('RuntimeExecutors', () => { const executors = createRuntimeExecutors(ctx); const state = createMockState({ forceFinish: true, - tools: [{ description: 'State tool', name: 'state-tool' }] as any, + tools: [ + { + function: { description: 'State tool', name: 'state-tool' }, + type: 'function' as const, + }, + ] as any, }); const instruction = { @@ -595,48 +609,6 @@ describe('RuntimeExecutors', () => { ); }); - it('should pass correct params from agentConfig to serverMessagesEngine', async () => { - const ctxWithConfig: RuntimeExecutorContext = { - ...ctx, - agentConfig: { - chatConfig: { enableHistoryCount: true, historyCount: 10 }, - files: [{ content: 'file contents', enabled: true, id: 'file-1', name: 'doc.pdf' }], - knowledgeBases: [{ enabled: true, id: 'kb-1', name: 'My KB' }], - plugins: ['web-search', 'calculator'], - systemRole: 'You are a helpful assistant', - }, - }; - const executors = createRuntimeExecutors(ctxWithConfig); - const state = createMockState(); - - const instruction = { - payload: { - messages: [{ content: 'Hello', role: 'user' }], - model: 'gpt-4', - provider: 'openai', - }, - type: 'call_llm' as const, - }; - - await executors.call_llm!(instruction, state); - - expect(engineSpy).toHaveBeenCalledWith( - expect.objectContaining({ - enableHistoryCount: true, - historyCount: 10, - knowledge: { - fileContents: [{ content: 'file contents', fileId: 'file-1', filename: 'doc.pdf' }], - knowledgeBases: [{ id: 'kb-1', name: 'My KB' }], - }, - messages: [{ content: 'Hello', role: 'user' }], - model: 'gpt-4', - provider: 'openai', - systemRole: 'You are a helpful assistant', - toolsConfig: { tools: ['web-search', 'calculator'] }, - }), - ); - }); - it('should pass forceFinish flag to serverMessagesEngine and inject summary', async () => { const ctxWithConfig: RuntimeExecutorContext = { ...ctx, @@ -1580,6 +1552,47 @@ describe('RuntimeExecutors', () => { // Original state must not be mutated expect(state.usage.tools.totalCalls).toBe(0); }); + + it('should pass toolResultMaxLength from agentConfig to executeTool', async () => { + const executors = createRuntimeExecutors(ctx); + const state = createMockState({ + metadata: { + agentConfig: { + chatConfig: { + toolResultMaxLength: 5000, + }, + }, + agentId: 'agent-123', + threadId: 'thread-123', + topicId: 'topic-123', + }, + }); + + const instruction = { + payload: { + parentMessageId: 'assistant-msg-123', + toolsCalling: [ + { + apiName: 'search', + arguments: '{}', + id: 'tool-call-1', + identifier: 'web-search', + type: 'default' as const, + }, + ], + }, + type: 'call_tools_batch' as const, + }; + + await executors.call_tools_batch!(instruction, state); + + expect(mockToolExecutionService.executeTool).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + toolResultMaxLength: 5000, + }), + ); + }); }); describe('resolve_aborted_tools executor', () => { diff --git a/src/server/modules/Mecha/AgentToolsEngine/__tests__/index.test.ts b/src/server/modules/Mecha/AgentToolsEngine/__tests__/index.test.ts index 4859b6622c..f723370586 100644 --- a/src/server/modules/Mecha/AgentToolsEngine/__tests__/index.test.ts +++ b/src/server/modules/Mecha/AgentToolsEngine/__tests__/index.test.ts @@ -1,6 +1,8 @@ // @vitest-environment node import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base'; import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; +import { MemoryManifest } from '@lobechat/builtin-tool-memory'; +import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device'; import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing'; import { builtinTools } from '@lobechat/builtin-tools'; import { ToolsEngine } from '@lobechat/context-engine'; @@ -287,4 +289,134 @@ describe('createServerAgentToolsEngine', () => { expect(result).toBeUndefined(); }); + + describe('Memory tool enable rules', () => { + it('should disable Memory tool by default (globalMemoryEnabled = false)', () => { + const context = createMockContext(); + const engine = createServerAgentToolsEngine(context, { + agentConfig: { plugins: [MemoryManifest.identifier] }, + model: 'gpt-4', + provider: 'openai', + }); + + const result = engine.generateToolsDetailed({ + toolIds: [MemoryManifest.identifier], + model: 'gpt-4', + provider: 'openai', + }); + + expect(result.enabledToolIds).not.toContain(MemoryManifest.identifier); + }); + + it('should enable Memory tool when globalMemoryEnabled is true', () => { + const context = createMockContext(); + const engine = createServerAgentToolsEngine(context, { + agentConfig: { plugins: [MemoryManifest.identifier] }, + globalMemoryEnabled: true, + model: 'gpt-4', + provider: 'openai', + }); + + const result = engine.generateToolsDetailed({ + toolIds: [MemoryManifest.identifier], + model: 'gpt-4', + provider: 'openai', + }); + + expect(result.enabledToolIds).toContain(MemoryManifest.identifier); + }); + }); + + describe('LocalSystem tool enable rules', () => { + it('should disable LocalSystem tool when no device context is provided', () => { + const context = createMockContext(); + const engine = createServerAgentToolsEngine(context, { + agentConfig: { plugins: [LocalSystemManifest.identifier] }, + model: 'gpt-4', + provider: 'openai', + }); + + const result = engine.generateToolsDetailed({ + toolIds: [LocalSystemManifest.identifier], + model: 'gpt-4', + provider: 'openai', + }); + + expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier); + }); + + it('should enable LocalSystem tool when gateway configured AND device online', () => { + const context = createMockContext(); + const engine = createServerAgentToolsEngine(context, { + agentConfig: { plugins: [LocalSystemManifest.identifier] }, + deviceContext: { gatewayConfigured: true, deviceOnline: true }, + model: 'gpt-4', + provider: 'openai', + }); + + const result = engine.generateToolsDetailed({ + toolIds: [LocalSystemManifest.identifier], + model: 'gpt-4', + provider: 'openai', + }); + + expect(result.enabledToolIds).toContain(LocalSystemManifest.identifier); + }); + + it('should disable LocalSystem tool when gateway configured but device offline', () => { + const context = createMockContext(); + const engine = createServerAgentToolsEngine(context, { + agentConfig: { plugins: [LocalSystemManifest.identifier] }, + deviceContext: { gatewayConfigured: true, deviceOnline: false }, + model: 'gpt-4', + provider: 'openai', + }); + + const result = engine.generateToolsDetailed({ + toolIds: [LocalSystemManifest.identifier], + model: 'gpt-4', + provider: 'openai', + }); + + expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier); + }); + }); + + describe('RemoteDevice tool enable rules', () => { + it('should enable RemoteDevice tool when gateway configured', () => { + const context = createMockContext(); + const engine = createServerAgentToolsEngine(context, { + agentConfig: { plugins: [RemoteDeviceManifest.identifier] }, + deviceContext: { gatewayConfigured: true }, + model: 'gpt-4', + provider: 'openai', + }); + + const result = engine.generateToolsDetailed({ + toolIds: [RemoteDeviceManifest.identifier], + model: 'gpt-4', + provider: 'openai', + }); + + expect(result.enabledToolIds).toContain(RemoteDeviceManifest.identifier); + }); + + it('should disable RemoteDevice tool when gateway not configured', () => { + const context = createMockContext(); + const engine = createServerAgentToolsEngine(context, { + agentConfig: { plugins: [RemoteDeviceManifest.identifier] }, + deviceContext: { gatewayConfigured: false }, + model: 'gpt-4', + provider: 'openai', + }); + + const result = engine.generateToolsDetailed({ + toolIds: [RemoteDeviceManifest.identifier], + model: 'gpt-4', + provider: 'openai', + }); + + expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier); + }); + }); }); diff --git a/src/server/modules/Mecha/AgentToolsEngine/index.ts b/src/server/modules/Mecha/AgentToolsEngine/index.ts index b002e8259b..836fdec602 100644 --- a/src/server/modules/Mecha/AgentToolsEngine/index.ts +++ b/src/server/modules/Mecha/AgentToolsEngine/index.ts @@ -11,9 +11,11 @@ */ import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base'; import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; +import { MemoryManifest } from '@lobechat/builtin-tool-memory'; +import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device'; import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing'; import { builtinTools, defaultToolIds } from '@lobechat/builtin-tools'; -import { type LobeToolManifest } from '@lobechat/context-engine'; +import { createEnableChecker, type LobeToolManifest } from '@lobechat/context-engine'; import { ToolsEngine } from '@lobechat/context-engine'; import debug from 'debug'; @@ -89,6 +91,8 @@ export const createServerAgentToolsEngine = ( const { additionalManifests, agentConfig, + deviceContext, + globalMemoryEnabled = false, hasEnabledKnowledgeBases = false, model, provider, @@ -97,11 +101,12 @@ export const createServerAgentToolsEngine = ( const isSearchEnabled = searchMode !== 'off'; log( - 'Creating agent tools engine for model=%s, provider=%s, searchMode=%s, additionalManifests=%d', + 'Creating agent tools engine for model=%s, provider=%s, searchMode=%s, additionalManifests=%d, deviceGateway=%s', model, provider, searchMode, additionalManifests?.length ?? 0, + !!deviceContext?.gatewayConfigured, ); return createServerToolsEngine(context, { @@ -109,26 +114,15 @@ export const createServerAgentToolsEngine = ( additionalManifests, // Add default tools based on configuration defaultToolIds, - // Create search-aware enableChecker for this request - enableChecker: ({ pluginId }) => { - // Filter LocalSystem tool on server (it's desktop-only) - if (pluginId === LocalSystemManifest.identifier) { - return false; - } - - // For WebBrowsingManifest, apply search logic - if (pluginId === WebBrowsingManifest.identifier) { - // TODO: Check model builtin search capability when needed - return isSearchEnabled; - } - - // For KnowledgeBaseManifest, only enable if knowledge is enabled - if (pluginId === KnowledgeBaseManifest.identifier) { - return hasEnabledKnowledgeBases; - } - - // For all other plugins, enable by default - return true; - }, + enableChecker: createEnableChecker({ + rules: { + [KnowledgeBaseManifest.identifier]: hasEnabledKnowledgeBases, + [LocalSystemManifest.identifier]: + !!deviceContext?.gatewayConfigured && !!deviceContext?.deviceOnline, + [MemoryManifest.identifier]: globalMemoryEnabled, + [RemoteDeviceManifest.identifier]: !!deviceContext?.gatewayConfigured, + [WebBrowsingManifest.identifier]: isSearchEnabled, + }, + }), }); }; diff --git a/src/server/modules/Mecha/AgentToolsEngine/types.ts b/src/server/modules/Mecha/AgentToolsEngine/types.ts index 93c10e0bfe..bf6e30f5e0 100644 --- a/src/server/modules/Mecha/AgentToolsEngine/types.ts +++ b/src/server/modules/Mecha/AgentToolsEngine/types.ts @@ -43,6 +43,14 @@ export interface ServerCreateAgentToolsEngineParams { /** Plugin IDs enabled for this agent */ plugins?: string[]; }; + /** Device gateway context for remote tool calling */ + deviceContext?: { + boundDeviceId?: string; + deviceOnline?: boolean; + gatewayConfigured: boolean; + }; + /** Whether the user's global memory setting is enabled */ + globalMemoryEnabled?: boolean; /** Whether agent has enabled knowledge bases */ hasEnabledKnowledgeBases?: boolean; /** Model name for function calling compatibility check */ diff --git a/src/server/modules/Mecha/ContextEngineering/__tests__/serverMessagesEngine.test.ts b/src/server/modules/Mecha/ContextEngineering/__tests__/serverMessagesEngine.test.ts index 375064879b..d073470c81 100644 --- a/src/server/modules/Mecha/ContextEngineering/__tests__/serverMessagesEngine.test.ts +++ b/src/server/modules/Mecha/ContextEngineering/__tests__/serverMessagesEngine.test.ts @@ -1,3 +1,4 @@ +import { MessagesEngine } from '@lobechat/context-engine'; import { type UIChatMessage } from '@lobechat/types'; import { describe, expect, it, vi } from 'vitest'; @@ -5,11 +6,12 @@ import { serverMessagesEngine } from '../index'; // Helper to compute expected date content from SystemDateProvider const getCurrentDateContent = () => { + const tz = 'UTC'; const today = new Date(); - const year = today.getFullYear(); - const month = String(today.getMonth() + 1).padStart(2, '0'); - const day = String(today.getDate()).padStart(2, '0'); - return `Current date: ${year}-${month}-${day}`; + const year = today.toLocaleString('en-US', { timeZone: tz, year: 'numeric' }); + const month = today.toLocaleString('en-US', { month: '2-digit', timeZone: tz }); + const day = today.toLocaleString('en-US', { day: '2-digit', timeZone: tz }); + return `Current date: ${year}-${month}-${day} (${tz})`; }; describe('serverMessagesEngine', () => { @@ -368,4 +370,164 @@ describe('serverMessagesEngine', () => { expect(formatHistorySummary).toHaveBeenCalledWith(historySummary); }); }); + + describe('userTimezone parameter', () => { + it('should pass userTimezone as timezone to MessagesEngine', async () => { + const constructorSpy = vi.spyOn(MessagesEngine.prototype, 'process').mockResolvedValue({ + messages: [], + } as any); + + const messages = createBasicMessages(); + + await serverMessagesEngine({ + messages, + model: 'gpt-4', + provider: 'openai', + userTimezone: 'Asia/Shanghai', + }); + + expect(constructorSpy).toHaveBeenCalled(); + constructorSpy.mockRestore(); + }); + + it('should use userTimezone in variable generators for time-related values', async () => { + const messages: UIChatMessage[] = [ + { + content: 'What time is it? {{timezone}}', + createdAt: Date.now(), + id: 'msg-1', + role: 'user', + updatedAt: Date.now(), + } as UIChatMessage, + ]; + + const result = await serverMessagesEngine({ + inputTemplate: '{{text}} (tz: {{timezone}})', + messages, + model: 'gpt-4', + provider: 'openai', + userTimezone: 'America/New_York', + }); + + const userMessage = result.find((m) => m.role === 'user'); + expect(userMessage?.content).toContain('America/New_York'); + }); + }); + + describe('additionalVariables parameter', () => { + it('should merge additionalVariables into variableGenerators', async () => { + const messages: UIChatMessage[] = [ + { + content: 'test input', + createdAt: Date.now(), + id: 'msg-1', + role: 'user', + updatedAt: Date.now(), + } as UIChatMessage, + ]; + + const result = await serverMessagesEngine({ + additionalVariables: { + customVar: 'custom-value', + }, + inputTemplate: '{{text}} {{customVar}}', + messages, + model: 'gpt-4', + provider: 'openai', + }); + + const userMessage = result.find((m) => m.role === 'user'); + expect(userMessage?.content).toContain('custom-value'); + }); + + it('should handle empty additionalVariables', async () => { + const messages = createBasicMessages(); + + const result = await serverMessagesEngine({ + additionalVariables: {}, + messages, + model: 'gpt-4', + provider: 'openai', + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe('extended context params forwarding', () => { + it('should forward discordContext when provided', async () => { + const messages = createBasicMessages(); + + const result = await serverMessagesEngine({ + discordContext: { + channel: { id: 'ch-1', name: 'general' }, + guild: { id: 'guild-1', name: 'Test Guild' }, + }, + messages, + model: 'gpt-4', + provider: 'openai', + }); + + expect(result).toBeDefined(); + }); + + it('should forward evalContext when provided', async () => { + const messages = createBasicMessages(); + + const result = await serverMessagesEngine({ + evalContext: { + envPrompt: 'This is an evaluation environment', + }, + messages, + model: 'gpt-4', + provider: 'openai', + }); + + expect(result).toBeDefined(); + }); + + it('should forward agentManagementContext when provided', async () => { + const messages = createBasicMessages(); + + const result = await serverMessagesEngine({ + agentManagementContext: { + availablePlugins: [ + { identifier: 'web-browsing', name: 'Web Browsing', type: 'builtin' as const }, + ], + }, + messages, + model: 'gpt-4', + provider: 'openai', + }); + + expect(result).toBeDefined(); + }); + + it('should handle multiple extended contexts simultaneously', async () => { + const messages = createBasicMessages(); + + const result = await serverMessagesEngine({ + agentBuilderContext: { + config: { model: 'gpt-4', systemRole: 'Test role' }, + meta: { description: 'Test agent', title: 'Test' }, + }, + discordContext: { + channel: { id: 'ch-1', name: 'general' }, + guild: { id: 'guild-1', name: 'Test Guild' }, + }, + messages, + model: 'gpt-4', + pageContentContext: { + markdown: '# Doc', + metadata: { charCount: 5, lineCount: 1, title: 'Doc' }, + xml: '

Doc

', + }, + provider: 'openai', + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + }); }); diff --git a/src/server/modules/Mecha/ContextEngineering/index.ts b/src/server/modules/Mecha/ContextEngineering/index.ts index 2371631b5a..4a5eddaf64 100644 --- a/src/server/modules/Mecha/ContextEngineering/index.ts +++ b/src/server/modules/Mecha/ContextEngineering/index.ts @@ -7,15 +7,24 @@ import { type ServerMessagesEngineParams } from './types'; * Create server-side variable generators with runtime context * These are safe to use in Node.js environment */ -const createServerVariableGenerators = (model?: string, provider?: string) => ({ - // Time-related variables - date: () => new Date().toLocaleDateString('en-US', { dateStyle: 'full' }), - datetime: () => new Date().toISOString(), - time: () => new Date().toLocaleTimeString('en-US', { timeStyle: 'medium' }), - // Model-related variables - model: () => model ?? '', - provider: () => provider ?? '', -}); +const createServerVariableGenerators = (params: { + model?: string; + provider?: string; + timezone?: string; +}) => { + const { model, provider, timezone } = params; + const tz = timezone || 'UTC'; + return { + // Time-related variables (localized to user's timezone) + date: () => new Date().toLocaleDateString('en-US', { dateStyle: 'full', timeZone: tz }), + datetime: () => new Date().toLocaleString('en-US', { timeZone: tz }), + time: () => new Date().toLocaleTimeString('en-US', { timeStyle: 'medium', timeZone: tz }), + timezone: () => tz, + // Model-related variables + model: () => model ?? '', + provider: () => provider ?? '', + }; +}; /** * Server-side messages engine function @@ -58,6 +67,8 @@ export const serverMessagesEngine = async ({ evalContext, agentManagementContext, pageContentContext, + additionalVariables, + userTimezone, }: ServerMessagesEngineParams): Promise => { const engine = new MessagesEngine({ // Capability injection @@ -99,6 +110,9 @@ export const serverMessagesEngine = async ({ provider, systemRole, + // Timezone for system date provider + timezone: userTimezone, + // Tools configuration toolsConfig: { manifests: toolsConfig?.manifests, @@ -114,8 +128,13 @@ export const serverMessagesEngine = async ({ } : undefined, - // Server-side variable generators (with model/provider context) - variableGenerators: createServerVariableGenerators(model, provider), + // Server-side variable generators (with model/provider context + device paths) + variableGenerators: { + ...createServerVariableGenerators({ model, provider, timezone: userTimezone }), + ...Object.fromEntries( + Object.entries(additionalVariables ?? {}).map(([k, v]) => [k, () => v]), + ), + }, // Extended contexts ...(agentBuilderContext && { agentBuilderContext }), diff --git a/src/server/modules/Mecha/ContextEngineering/types.ts b/src/server/modules/Mecha/ContextEngineering/types.ts index ec1da755dc..a7880ba4f5 100644 --- a/src/server/modules/Mecha/ContextEngineering/types.ts +++ b/src/server/modules/Mecha/ContextEngineering/types.ts @@ -61,6 +61,10 @@ export interface ServerUserMemoryConfig { * instead of fetching from stores */ export interface ServerMessagesEngineParams { + /** Additional variable values to merge with defaults (e.g. device paths) */ + additionalVariables?: Record; + /** User's timezone for time-related variables (e.g. 'Asia/Shanghai') */ + userTimezone?: string; // ========== Extended contexts ========== /** Agent Builder context (optional, for editing agents) */ agentBuilderContext?: AgentBuilderContext; diff --git a/src/server/routers/lambda/aiAgent.ts b/src/server/routers/lambda/aiAgent.ts index 446be97f14..6e430d0b97 100644 --- a/src/server/routers/lambda/aiAgent.ts +++ b/src/server/routers/lambda/aiAgent.ts @@ -485,8 +485,10 @@ export const aiAgentRouter = router({ initialMessages: messages, modelRuntimeConfig, operationId, - toolManifestMap, - tools, + toolSet: { + manifestMap: toolManifestMap, + tools, + }, userId: ctx.userId, }); diff --git a/src/server/services/agent/index.ts b/src/server/services/agent/index.ts index f872860b64..3d86bffaa1 100644 --- a/src/server/services/agent/index.ts +++ b/src/server/services/agent/index.ts @@ -27,7 +27,7 @@ const log = debug('lobe-agent:service'); * Agent config with required id field. * Used when returning agent config from database (id is always present). */ -export type AgentConfigWithId = LobeAgentConfig & { id: string }; +export type AgentConfigWithId = LobeAgentConfig & { id: string; slug?: string | null }; interface AgentWelcomeData { openQuestions: string[]; diff --git a/src/server/services/agentRuntime/AgentRuntimeService.test.ts b/src/server/services/agentRuntime/AgentRuntimeService.test.ts index dcfc6f1618..ddb029c492 100644 --- a/src/server/services/agentRuntime/AgentRuntimeService.test.ts +++ b/src/server/services/agentRuntime/AgentRuntimeService.test.ts @@ -211,7 +211,7 @@ describe('AgentRuntimeService', () => { appContext: {}, agentConfig: { name: 'test-agent' }, modelRuntimeConfig: { model: 'gpt-4' }, - toolManifestMap: {}, + toolSet: { manifestMap: {} }, userId: 'user-123', autoStart: true, initialMessages: [], diff --git a/src/server/services/agentRuntime/AgentRuntimeService.ts b/src/server/services/agentRuntime/AgentRuntimeService.ts index 154ac088a7..74a78d8bb9 100644 --- a/src/server/services/agentRuntime/AgentRuntimeService.ts +++ b/src/server/services/agentRuntime/AgentRuntimeService.ts @@ -1,5 +1,5 @@ import type { AgentRuntimeContext, AgentState } from '@lobechat/agent-runtime'; -import { AgentRuntime, GeneralChatAgent } from '@lobechat/agent-runtime'; +import { AgentRuntime, findInMessages, GeneralChatAgent } from '@lobechat/agent-runtime'; import { dynamicInterventionAudits } from '@lobechat/builtin-tools/dynamicInterventionAudits'; import { AgentRuntimeErrorType, ChatErrorType, type ChatMessageError } from '@lobechat/types'; import debug from 'debug'; @@ -246,6 +246,7 @@ export class AgentRuntimeService { */ async createOperation(params: OperationCreationParams): Promise { const { + activeDeviceId, operationId, initialContext, agentConfig, @@ -253,11 +254,9 @@ export class AgentRuntimeService { userId, autoStart = true, stream, - tools, initialMessages = [], appContext, - toolManifestMap, - toolSourceMap, + toolSet, stepCallbacks, userInterventionConfig, completionWebhook, @@ -267,8 +266,12 @@ export class AgentRuntimeService { evalContext, maxSteps, userMemory, + deviceSystemInfo, + userTimezone, } = params; + const operationToolSet = toolSet; + try { const memories = userMemory?.memories; log( @@ -277,9 +280,9 @@ export class AgentRuntimeService { autoStart, agentConfig?.model, agentConfig?.provider, - tools?.length ?? 0, + operationToolSet.tools?.length ?? 0, initialMessages.length, - toolManifestMap ? Object.keys(toolManifestMap).length : 0, + operationToolSet.manifestMap ? Object.keys(operationToolSet.manifestMap).length : 0, memories ? `{contexts:${memories.contexts?.length ?? 0},experiences:${memories.experiences?.length ?? 0},preferences:${memories.preferences?.length ?? 0},identities:${memories.identities?.length ?? 0},activities:${memories.activities?.length ?? 0},persona:${memories.persona ? 'yes' : 'no'}}` : 'none', @@ -294,8 +297,10 @@ export class AgentRuntimeService { // Use the passed initial messages messages: initialMessages, metadata: { + activeDeviceId, agentConfig, completionWebhook, + deviceSystemInfo, discordContext, evalContext, // need be removed @@ -304,6 +309,7 @@ export class AgentRuntimeService { stream, userId, userMemory, + userTimezone, webhookDelivery, workingDirectory: agentConfig?.chatConfig?.localSystem?.workingDirectory, ...appContext, @@ -312,11 +318,13 @@ export class AgentRuntimeService { // modelRuntimeConfig at state level for executor fallback modelRuntimeConfig, operationId, + operationToolSet, status: 'idle', stepCount: 0, - toolManifestMap, - toolSourceMap, - tools, + // Backward-compat: resolved tool fields read by RuntimeExecutors + toolManifestMap: operationToolSet.manifestMap, + toolSourceMap: operationToolSet.sourceMap, + tools: operationToolSet.tools, // User intervention config for headless mode in async tasks userInterventionConfig, } as Partial; @@ -499,6 +507,23 @@ export class AgentRuntimeService { currentContext = interventionResult.nextContext; } + // 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) { + const deviceContext = await this.computeDeviceContext(currentState); + if (deviceContext && currentState.metadata) { + currentState.metadata.activeDeviceId = deviceContext.activeDeviceId; + currentState.metadata.devicePlatform = deviceContext.devicePlatform; + currentState.metadata.deviceSystemInfo = deviceContext.deviceSystemInfo; + log( + '[%s][%d] Pre-step: device context computed from messages (deviceId: %s)', + operationId, + stepIndex, + deviceContext.activeDeviceId, + ); + } + } + // Execute step const startAt = Date.now(); const stepResult = await runtime.step(currentState, currentContext); @@ -1278,6 +1303,7 @@ export class AgentRuntimeService { const executorContext: RuntimeExecutorContext = { agentConfig: metadata?.agentConfig, discordContext: metadata?.discordContext, + userTimezone: metadata?.userTimezone, evalContext: metadata?.evalContext, messageModel: this.messageModel, operationId, @@ -1298,6 +1324,41 @@ export class AgentRuntimeService { return { agent, runtime }; } + /** + * Compute device context from DB messages at step boundary. + * Uses findInMessages visitor to scan tool messages for device activation. + */ + private async computeDeviceContext(state: any) { + try { + const dbMessages = await this.messageModel.query({ + agentId: state.metadata?.agentId, + threadId: state.metadata?.threadId, + topicId: state.metadata?.topicId, + }); + + return findInMessages( + dbMessages, + (msg) => { + const activeDeviceId = msg.pluginState?.metadata?.activeDeviceId; + if (activeDeviceId) { + return { + activeDeviceId, + devicePlatform: msg.pluginState?.metadata?.devicePlatform as string | undefined, + deviceSystemInfo: msg.pluginState?.metadata?.deviceSystemInfo as + | Record + | undefined, + }; + } + }, + { role: 'tool' }, + ); + } catch (error) { + log('computeDeviceContext error: %O', error); + } + + return undefined; + } + /** * Handle human intervention logic */ diff --git a/src/server/services/agentRuntime/__tests__/completionWebhook.test.ts b/src/server/services/agentRuntime/__tests__/completionWebhook.test.ts index 410bd688c5..c638c02a3b 100644 --- a/src/server/services/agentRuntime/__tests__/completionWebhook.test.ts +++ b/src/server/services/agentRuntime/__tests__/completionWebhook.test.ts @@ -116,8 +116,7 @@ describe('AgentRuntimeService - Completion Webhook', () => { initialMessages: [{ content: 'Hello', role: 'user' }], modelRuntimeConfig: { model: 'gpt-4o', provider: 'openai' }, operationId, - toolManifestMap: {}, - tools: [], + toolSet: { manifestMap: {}, tools: [] }, userId, }); @@ -136,8 +135,7 @@ describe('AgentRuntimeService - Completion Webhook', () => { initialMessages: [{ content: 'Hello', role: 'user' }], modelRuntimeConfig: { model: 'gpt-4o', provider: 'openai' }, operationId, - toolManifestMap: {}, - tools: [], + toolSet: { manifestMap: {}, tools: [] }, userId, }); @@ -167,8 +165,7 @@ describe('AgentRuntimeService - Completion Webhook', () => { initialMessages: [{ content: 'Hello', role: 'user' }], modelRuntimeConfig: { model: 'gpt-4o', provider: 'openai' }, operationId, - toolManifestMap: {}, - tools: [], + toolSet: { manifestMap: {}, tools: [] }, userId, }); }; @@ -211,8 +208,7 @@ describe('AgentRuntimeService - Completion Webhook', () => { initialMessages: [{ content: 'Hello', role: 'user' }], modelRuntimeConfig: { model: 'gpt-4o', provider: 'openai' }, operationId, - toolManifestMap: {}, - tools: [], + toolSet: { manifestMap: {}, tools: [] }, userId, }); @@ -264,8 +260,7 @@ describe('AgentRuntimeService - Completion Webhook', () => { initialMessages: [{ content: 'Hello', role: 'user' }], modelRuntimeConfig: { model: 'gpt-4o', provider: 'openai' }, operationId, - toolManifestMap: {}, - tools: [], + toolSet: { manifestMap: {}, tools: [] }, userId, }); diff --git a/src/server/services/agentRuntime/__tests__/executeSync.test.ts b/src/server/services/agentRuntime/__tests__/executeSync.test.ts index 46bb1f1623..3366465df4 100644 --- a/src/server/services/agentRuntime/__tests__/executeSync.test.ts +++ b/src/server/services/agentRuntime/__tests__/executeSync.test.ts @@ -112,8 +112,7 @@ describe('AgentRuntimeService.executeSync', () => { initialMessages: [{ role: 'user', content: 'Hello' }], modelRuntimeConfig: { model: 'gpt-4o', provider: 'openai' }, operationId, - toolManifestMap: {}, - tools: [], + toolSet: { manifestMap: {}, tools: [] }, userId, }); @@ -153,8 +152,7 @@ describe('AgentRuntimeService.executeSync', () => { ], modelRuntimeConfig: { model: 'gpt-4o', provider: 'openai' }, operationId, - toolManifestMap: {}, - tools: [], + toolSet: { manifestMap: {}, tools: [] }, userId, }); diff --git a/src/server/services/agentRuntime/__tests__/stepLifecycleCallbacks.test.ts b/src/server/services/agentRuntime/__tests__/stepLifecycleCallbacks.test.ts index 818eb99811..e34bc8b415 100644 --- a/src/server/services/agentRuntime/__tests__/stepLifecycleCallbacks.test.ts +++ b/src/server/services/agentRuntime/__tests__/stepLifecycleCallbacks.test.ts @@ -174,8 +174,7 @@ describe('AgentRuntimeService - Step Lifecycle Callbacks', () => { modelRuntimeConfig: { model: 'gpt-4o', provider: 'openai' }, operationId, stepCallbacks: callbacks, - toolManifestMap: {}, - tools: [], + toolSet: { manifestMap: {}, tools: [] }, userId, }); @@ -205,8 +204,7 @@ describe('AgentRuntimeService - Step Lifecycle Callbacks', () => { initialMessages: [{ content: 'Hello', role: 'user' }], modelRuntimeConfig: { model: 'gpt-4o', provider: 'openai' }, operationId, - toolManifestMap: {}, - tools: [], + toolSet: { manifestMap: {}, tools: [] }, userId, }); diff --git a/src/server/services/agentRuntime/types.ts b/src/server/services/agentRuntime/types.ts index fb6edb318b..61d2d59970 100644 --- a/src/server/services/agentRuntime/types.ts +++ b/src/server/services/agentRuntime/types.ts @@ -4,6 +4,15 @@ import { type UserInterventionConfig } from '@lobechat/types'; import { type ServerUserMemoryConfig } from '@/server/modules/Mecha/ContextEngineering/types'; +// ==================== Operation Tool Set ==================== + +export interface OperationToolSet { + enabledToolIds?: string[]; + manifestMap: Record; + sourceMap?: Record; + tools?: any[]; +} + // ==================== Step Lifecycle Callbacks ==================== /** @@ -119,6 +128,7 @@ export interface AgentExecutionResult { } export interface OperationCreationParams { + activeDeviceId?: string; agentConfig?: any; appContext: { agentId?: string; @@ -136,6 +146,8 @@ export interface OperationCreationParams { body?: Record; url: string; }; + /** Device system info for placeholder variable replacement in Local System systemRole */ + deviceSystemInfo?: Record; /** Discord context for injecting channel/guild info into agent system message */ discordContext?: any; evalContext?: any; @@ -163,9 +175,7 @@ export interface OperationCreationParams { * Defaults to true. Set to false for non-streaming scenarios (e.g., bot integrations). */ stream?: boolean; - toolManifestMap: Record; - tools?: any[]; - toolSourceMap?: Record; + toolSet: OperationToolSet; userId?: string; /** * User intervention configuration @@ -175,6 +185,8 @@ export interface OperationCreationParams { userInterventionConfig?: UserInterventionConfig; /** User memory (persona) for injection into LLM context */ userMemory?: ServerUserMemoryConfig; + /** User's timezone from settings (e.g. 'Asia/Shanghai') */ + userTimezone?: string; /** * Webhook delivery method. * - 'fetch': plain HTTP POST (default) diff --git a/src/server/services/aiAgent/__tests__/execAgent.builtinRuntime.test.ts b/src/server/services/aiAgent/__tests__/execAgent.builtinRuntime.test.ts new file mode 100644 index 0000000000..586459ed6f --- /dev/null +++ b/src/server/services/aiAgent/__tests__/execAgent.builtinRuntime.test.ts @@ -0,0 +1,212 @@ +import type * as ModelBankModule from 'model-bank'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AiAgentService } from '../index'; + +const { mockCreateOperation, mockGetAgentConfig, mockMessageCreate } = vi.hoisted(() => ({ + mockCreateOperation: vi.fn(), + mockGetAgentConfig: vi.fn(), + mockMessageCreate: vi.fn(), +})); + +vi.mock('@/libs/trusted-client', () => ({ + generateTrustedClientToken: vi.fn().mockReturnValue(undefined), + getTrustedClientTokenForSession: vi.fn().mockResolvedValue(undefined), + isTrustedClientEnabled: vi.fn().mockReturnValue(false), +})); + +vi.mock('@/database/models/message', () => ({ + MessageModel: vi.fn().mockImplementation(() => ({ + create: mockMessageCreate, + query: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + })), +})); + +vi.mock('@/database/models/agent', () => ({ + AgentModel: vi.fn().mockImplementation(() => ({ + getAgentConfig: vi.fn(), + })), +})); + +vi.mock('@/server/services/agent', () => ({ + AgentService: vi.fn().mockImplementation(() => ({ + getAgentConfig: mockGetAgentConfig, + })), +})); + +vi.mock('@/database/models/plugin', () => ({ + PluginModel: vi.fn().mockImplementation(() => ({ + query: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('@/database/models/topic', () => ({ + TopicModel: vi.fn().mockImplementation(() => ({ + create: vi.fn().mockResolvedValue({ id: 'topic-1' }), + })), +})); + +vi.mock('@/database/models/thread', () => ({ + ThreadModel: vi.fn().mockImplementation(() => ({ + create: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + })), +})); + +vi.mock('@/server/services/agentRuntime', () => ({ + AgentRuntimeService: vi.fn().mockImplementation(() => ({ + createOperation: mockCreateOperation, + })), +})); + +vi.mock('@/server/services/market', () => ({ + MarketService: vi.fn().mockImplementation(() => ({ + getLobehubSkillManifests: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('@/server/services/klavis', () => ({ + KlavisService: vi.fn().mockImplementation(() => ({ + getKlavisManifests: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('@/server/services/file', () => ({ + FileService: vi.fn().mockImplementation(() => ({ + uploadFromUrl: vi.fn(), + })), +})); + +vi.mock('@/server/modules/Mecha', () => ({ + createServerAgentToolsEngine: vi.fn().mockReturnValue({ + generateToolsDetailed: vi.fn().mockReturnValue({ enabledToolIds: [], tools: [] }), + getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()), + }), + serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), +})); + +vi.mock('@/server/services/toolExecution/deviceProxy', () => ({ + deviceProxy: { + isConfigured: false, + queryDeviceList: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock('model-bank', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + LOBE_DEFAULT_MODEL_LIST: [ + { + abilities: { functionCall: true, video: false, vision: true }, + id: 'gpt-4', + providerId: 'openai', + }, + ], + }; +}); + +describe('AiAgentService.execAgent - builtin agent runtime config', () => { + let service: AiAgentService; + const mockDb = {} as any; + const userId = 'test-user-id'; + + beforeEach(() => { + vi.clearAllMocks(); + mockMessageCreate.mockResolvedValue({ id: 'msg-1' }); + mockCreateOperation.mockResolvedValue({ + autoStarted: true, + messageId: 'queue-msg-1', + operationId: 'op-123', + success: true, + }); + service = new AiAgentService(mockDb, userId); + }); + + it('should merge runtime systemRole for inbox agent when DB systemRole is empty', async () => { + // Inbox agent with no user-customized systemRole in DB + mockGetAgentConfig.mockResolvedValue({ + chatConfig: {}, + id: 'agent-inbox', + model: 'gpt-4', + plugins: [], + provider: 'openai', + slug: 'inbox', + systemRole: '', // empty in DB + }); + + await service.execAgent({ + agentId: 'agent-inbox', + prompt: 'Hello', + }); + + // Verify createOperation was called with agentConfig containing the runtime systemRole + expect(mockCreateOperation).toHaveBeenCalledTimes(1); + const callArgs = mockCreateOperation.mock.calls[0][0]; + expect(callArgs.agentConfig.systemRole).toContain('You are Lobe'); + expect(callArgs.agentConfig.systemRole).toContain('{{model}}'); + }); + + it('should NOT override user-customized systemRole for inbox agent', async () => { + const customSystemRole = 'You are a custom assistant.'; + mockGetAgentConfig.mockResolvedValue({ + chatConfig: {}, + id: 'agent-inbox', + model: 'gpt-4', + plugins: [], + provider: 'openai', + slug: 'inbox', + systemRole: customSystemRole, // user has customized + }); + + await service.execAgent({ + agentId: 'agent-inbox', + prompt: 'Hello', + }); + + const callArgs = mockCreateOperation.mock.calls[0][0]; + expect(callArgs.agentConfig.systemRole).toBe(customSystemRole); + }); + + it('should not apply runtime config for non-builtin agents', async () => { + mockGetAgentConfig.mockResolvedValue({ + chatConfig: {}, + id: 'agent-custom', + model: 'gpt-4', + plugins: [], + provider: 'openai', + slug: 'my-custom-slug', // not a builtin slug + systemRole: '', + }); + + await service.execAgent({ + agentId: 'agent-custom', + prompt: 'Hello', + }); + + const callArgs = mockCreateOperation.mock.calls[0][0]; + // Should remain empty - no runtime config applied + expect(callArgs.agentConfig.systemRole).toBe(''); + }); + + it('should not apply runtime config for agents without slug', async () => { + mockGetAgentConfig.mockResolvedValue({ + chatConfig: {}, + id: 'agent-no-slug', + model: 'gpt-4', + plugins: [], + provider: 'openai', + systemRole: '', + }); + + await service.execAgent({ + agentId: 'agent-no-slug', + prompt: 'Hello', + }); + + const callArgs = mockCreateOperation.mock.calls[0][0]; + expect(callArgs.agentConfig.systemRole).toBe(''); + }); +}); diff --git a/src/server/services/aiAgent/__tests__/execAgent.device.test.ts b/src/server/services/aiAgent/__tests__/execAgent.device.test.ts new file mode 100644 index 0000000000..866c403467 --- /dev/null +++ b/src/server/services/aiAgent/__tests__/execAgent.device.test.ts @@ -0,0 +1,340 @@ +import type * as ModelBankModule from 'model-bank'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AiAgentService } from '../index'; + +const { mockMessageCreate, mockCreateOperation } = vi.hoisted(() => ({ + mockCreateOperation: vi.fn(), + mockMessageCreate: vi.fn(), +})); + +const { mockDeviceProxy } = vi.hoisted(() => ({ + mockDeviceProxy: { + isConfigured: false, + queryDeviceList: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock('@/libs/trusted-client', () => ({ + generateTrustedClientToken: vi.fn().mockReturnValue(undefined), + getTrustedClientTokenForSession: vi.fn().mockResolvedValue(undefined), + isTrustedClientEnabled: vi.fn().mockReturnValue(false), +})); + +vi.mock('@/database/models/message', () => ({ + MessageModel: vi.fn().mockImplementation(() => ({ + create: mockMessageCreate, + query: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + })), +})); + +vi.mock('@/database/models/agent', () => ({ + AgentModel: vi.fn().mockImplementation(() => ({ + getAgentConfig: vi.fn().mockResolvedValue({ + chatConfig: {}, + files: [], + id: 'agent-1', + knowledgeBases: [], + model: 'gpt-4', + plugins: [], + provider: 'openai', + systemRole: 'You are a helpful assistant', + }), + })), +})); + +vi.mock('@/server/services/agent', () => ({ + AgentService: vi.fn().mockImplementation(() => ({ + getAgentConfig: vi.fn().mockResolvedValue({ + chatConfig: {}, + files: [], + id: 'agent-1', + knowledgeBases: [], + model: 'gpt-4', + plugins: [], + provider: 'openai', + systemRole: 'You are a helpful assistant', + }), + })), +})); + +vi.mock('@/database/models/plugin', () => ({ + PluginModel: vi.fn().mockImplementation(() => ({ + query: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('@/database/models/topic', () => ({ + TopicModel: vi.fn().mockImplementation(() => ({ + create: vi.fn().mockResolvedValue({ id: 'topic-1' }), + })), +})); + +vi.mock('@/database/models/thread', () => ({ + ThreadModel: vi.fn().mockImplementation(() => ({ + create: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + })), +})); + +vi.mock('@/server/services/agentRuntime', () => ({ + AgentRuntimeService: vi.fn().mockImplementation(() => ({ + createOperation: mockCreateOperation, + })), +})); + +vi.mock('@/server/services/market', () => ({ + MarketService: vi.fn().mockImplementation(() => ({ + getLobehubSkillManifests: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('@/server/services/klavis', () => ({ + KlavisService: vi.fn().mockImplementation(() => ({ + getKlavisManifests: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('@/server/services/file', () => ({ + FileService: vi.fn().mockImplementation(() => ({ + uploadFromUrl: vi.fn(), + })), +})); + +vi.mock('@/server/modules/Mecha', () => ({ + createServerAgentToolsEngine: vi.fn().mockReturnValue({ + generateToolsDetailed: vi.fn().mockReturnValue({ enabledToolIds: [], tools: [] }), + getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()), + }), + serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), +})); + +vi.mock('@/server/services/toolExecution/deviceProxy', () => ({ + deviceProxy: mockDeviceProxy, +})); + +vi.mock('model-bank', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + LOBE_DEFAULT_MODEL_LIST: [ + { + abilities: { functionCall: true, video: false, vision: true }, + id: 'gpt-4', + providerId: 'openai', + }, + ], + }; +}); + +describe('AiAgentService.execAgent - device auto-activation', () => { + let service: AiAgentService; + const mockDb = {} as any; + const userId = 'test-user-id'; + + beforeEach(() => { + vi.clearAllMocks(); + mockMessageCreate.mockResolvedValue({ id: 'msg-1' }); + mockCreateOperation.mockResolvedValue({ + autoStarted: true, + messageId: 'queue-msg-1', + operationId: 'op-123', + success: true, + }); + // Reset device proxy state + mockDeviceProxy.isConfigured = false; + mockDeviceProxy.queryDeviceList.mockResolvedValue([]); + + service = new AiAgentService(mockDb, userId); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const onlineDevice = { + deviceId: 'device-001', + hostname: 'my-laptop', + lastSeen: '2026-03-06T12:00:00.000Z', + online: true, + platform: 'linux' as const, + }; + + const onlineDevice2 = { + deviceId: 'device-002', + hostname: 'my-desktop', + lastSeen: '2026-03-06T12:00:00.000Z', + online: true, + platform: 'darwin' as const, + }; + + describe('IM/Bot scenario with botContext', () => { + it('should auto-activate when exactly one device is online', async () => { + mockDeviceProxy.isConfigured = true; + mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]); + + await service.execAgent({ + agentId: 'agent-1', + botContext: { platform: 'discord' } as any, + prompt: 'List my files', + }); + + expect(mockCreateOperation).toHaveBeenCalled(); + const createOpArgs = mockCreateOperation.mock.calls[0][0]; + expect(createOpArgs.activeDeviceId).toBe('device-001'); + }); + + it('should NOT auto-activate when multiple devices are online', async () => { + mockDeviceProxy.isConfigured = true; + mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice, onlineDevice2]); + + await service.execAgent({ + agentId: 'agent-1', + botContext: { platform: 'discord' } as any, + prompt: 'List my files', + }); + + expect(mockCreateOperation).toHaveBeenCalled(); + const createOpArgs = mockCreateOperation.mock.calls[0][0]; + expect(createOpArgs.activeDeviceId).toBeUndefined(); + }); + + it('should NOT auto-activate when no devices are online', async () => { + mockDeviceProxy.isConfigured = true; + mockDeviceProxy.queryDeviceList.mockResolvedValue([]); + + await service.execAgent({ + agentId: 'agent-1', + botContext: { platform: 'discord' } as any, + prompt: 'List my files', + }); + + expect(mockCreateOperation).toHaveBeenCalled(); + const createOpArgs = mockCreateOperation.mock.calls[0][0]; + expect(createOpArgs.activeDeviceId).toBeUndefined(); + }); + }); + + describe('IM/Bot scenario with discordContext', () => { + it('should auto-activate when exactly one device is online', async () => { + mockDeviceProxy.isConfigured = true; + mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]); + + await service.execAgent({ + agentId: 'agent-1', + discordContext: { channelId: 'ch-1', guildId: 'guild-1' }, + prompt: 'Check system info', + }); + + expect(mockCreateOperation).toHaveBeenCalled(); + const createOpArgs = mockCreateOperation.mock.calls[0][0]; + expect(createOpArgs.activeDeviceId).toBe('device-001'); + }); + }); + + describe('Web UI scenario (no botContext/discordContext)', () => { + it('should NOT auto-activate even with one device online', async () => { + mockDeviceProxy.isConfigured = true; + mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]); + + await service.execAgent({ + agentId: 'agent-1', + prompt: 'List my files', + }); + + expect(mockCreateOperation).toHaveBeenCalled(); + const createOpArgs = mockCreateOperation.mock.calls[0][0]; + expect(createOpArgs.activeDeviceId).toBeUndefined(); + }); + }); + + describe('boundDeviceId scenario', () => { + it('should use boundDeviceId when device is online', async () => { + mockDeviceProxy.isConfigured = true; + mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]); + + // Override the agent config mock to include boundDeviceId + const { AgentService } = await import('@/server/services/agent'); + vi.mocked(AgentService).mockImplementation( + () => + ({ + getAgentConfig: vi.fn().mockResolvedValue({ + agencyConfig: { boundDeviceId: 'device-001' }, + chatConfig: {}, + files: [], + id: 'agent-1', + knowledgeBases: [], + model: 'gpt-4', + plugins: [], + provider: 'openai', + systemRole: 'You are a helpful assistant', + }), + }) as any, + ); + + service = new AiAgentService(mockDb, userId); + + await service.execAgent({ + agentId: 'agent-1', + prompt: 'Run a command', + }); + + expect(mockCreateOperation).toHaveBeenCalled(); + const createOpArgs = mockCreateOperation.mock.calls[0][0]; + expect(createOpArgs.activeDeviceId).toBe('device-001'); + }); + + it('should NOT activate boundDeviceId when no devices are online', async () => { + mockDeviceProxy.isConfigured = true; + mockDeviceProxy.queryDeviceList.mockResolvedValue([]); + + const { AgentService } = await import('@/server/services/agent'); + vi.mocked(AgentService).mockImplementation( + () => + ({ + getAgentConfig: vi.fn().mockResolvedValue({ + agencyConfig: { boundDeviceId: 'device-001' }, + chatConfig: {}, + files: [], + id: 'agent-1', + knowledgeBases: [], + model: 'gpt-4', + plugins: [], + provider: 'openai', + systemRole: 'You are a helpful assistant', + }), + }) as any, + ); + + service = new AiAgentService(mockDb, userId); + + await service.execAgent({ + agentId: 'agent-1', + prompt: 'Run a command', + }); + + expect(mockCreateOperation).toHaveBeenCalled(); + const createOpArgs = mockCreateOperation.mock.calls[0][0]; + expect(createOpArgs.activeDeviceId).toBeUndefined(); + }); + }); + + describe('gateway not configured', () => { + it('should never set activeDeviceId when gateway is not configured', async () => { + mockDeviceProxy.isConfigured = false; + + await service.execAgent({ + agentId: 'agent-1', + botContext: { platform: 'discord' } as any, + prompt: 'List my files', + }); + + expect(mockCreateOperation).toHaveBeenCalled(); + const createOpArgs = mockCreateOperation.mock.calls[0][0]; + expect(createOpArgs.activeDeviceId).toBeUndefined(); + expect(mockDeviceProxy.queryDeviceList).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/services/aiAgent/__tests__/execAgent.deviceToolPipeline.test.ts b/src/server/services/aiAgent/__tests__/execAgent.deviceToolPipeline.test.ts new file mode 100644 index 0000000000..b9222ce008 --- /dev/null +++ b/src/server/services/aiAgent/__tests__/execAgent.deviceToolPipeline.test.ts @@ -0,0 +1,301 @@ +import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device'; +import type * as ModelBankModule from 'model-bank'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AiAgentService } from '../index'; + +const { + mockCreateOperation, + mockCreateServerAgentToolsEngine, + mockGenerateToolsDetailed, + mockGetAgentConfig, + mockGetEnabledPluginManifests, + mockMessageCreate, + mockQueryDeviceList, +} = vi.hoisted(() => ({ + mockCreateOperation: vi.fn(), + mockCreateServerAgentToolsEngine: vi.fn(), + mockGenerateToolsDetailed: vi.fn(), + mockGetAgentConfig: vi.fn(), + mockGetEnabledPluginManifests: vi.fn(), + mockMessageCreate: vi.fn(), + mockQueryDeviceList: vi.fn(), +})); + +vi.mock('@/libs/trusted-client', () => ({ + generateTrustedClientToken: vi.fn().mockReturnValue(undefined), + getTrustedClientTokenForSession: vi.fn().mockResolvedValue(undefined), + isTrustedClientEnabled: vi.fn().mockReturnValue(false), +})); + +vi.mock('@/database/models/message', () => ({ + MessageModel: vi.fn().mockImplementation(() => ({ + create: mockMessageCreate, + query: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + })), +})); + +vi.mock('@/database/models/agent', () => ({ + AgentModel: vi.fn().mockImplementation(() => ({ + getAgentConfig: vi.fn(), + })), +})); + +vi.mock('@/server/services/agent', () => ({ + AgentService: vi.fn().mockImplementation(() => ({ + getAgentConfig: mockGetAgentConfig, + })), +})); + +vi.mock('@/database/models/plugin', () => ({ + PluginModel: vi.fn().mockImplementation(() => ({ + query: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('@/database/models/topic', () => ({ + TopicModel: vi.fn().mockImplementation(() => ({ + create: vi.fn().mockResolvedValue({ id: 'topic-1' }), + })), +})); + +vi.mock('@/database/models/thread', () => ({ + ThreadModel: vi.fn().mockImplementation(() => ({ + create: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + })), +})); + +vi.mock('@/server/services/agentRuntime', () => ({ + AgentRuntimeService: vi.fn().mockImplementation(() => ({ + createOperation: mockCreateOperation, + })), +})); + +vi.mock('@/server/services/market', () => ({ + MarketService: vi.fn().mockImplementation(() => ({ + getLobehubSkillManifests: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('@/server/services/klavis', () => ({ + KlavisService: vi.fn().mockImplementation(() => ({ + getKlavisManifests: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('@/server/services/file', () => ({ + FileService: vi.fn().mockImplementation(() => ({ + uploadFromUrl: vi.fn(), + })), +})); + +vi.mock('@/server/modules/Mecha', () => { + // Return the hoisted mocks so each test can configure them + mockGenerateToolsDetailed.mockReturnValue({ enabledToolIds: [], tools: [] }); + mockGetEnabledPluginManifests.mockReturnValue(new Map()); + + mockCreateServerAgentToolsEngine.mockReturnValue({ + generateToolsDetailed: mockGenerateToolsDetailed, + getEnabledPluginManifests: mockGetEnabledPluginManifests, + }); + + return { + createServerAgentToolsEngine: mockCreateServerAgentToolsEngine, + serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), + }; +}); + +vi.mock('@/server/services/toolExecution/deviceProxy', () => ({ + deviceProxy: { + get isConfigured() { + // Will be overridden per-test via vi.spyOn or re-mock + return false; + }, + queryDeviceList: mockQueryDeviceList, + queryDeviceSystemInfo: vi.fn().mockResolvedValue(null), + }, +})); + +vi.mock('model-bank', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + LOBE_DEFAULT_MODEL_LIST: [ + { + abilities: { functionCall: true, video: false, vision: true }, + id: 'gpt-4', + providerId: 'openai', + }, + ], + }; +}); + +// Helper to create a base agent config +const createBaseAgentConfig = (overrides: Record = {}) => ({ + chatConfig: {}, + id: 'agent-1', + model: 'gpt-4', + plugins: [], + provider: 'openai', + systemRole: '', + ...overrides, +}); + +describe('AiAgentService.execAgent - device tool pipeline (LOBE-5636)', () => { + let service: AiAgentService; + const mockDb = {} as any; + const userId = 'test-user-id'; + + beforeEach(() => { + vi.clearAllMocks(); + mockMessageCreate.mockResolvedValue({ id: 'msg-1' }); + mockCreateOperation.mockResolvedValue({ + autoStarted: true, + messageId: 'queue-msg-1', + operationId: 'op-123', + success: true, + }); + mockQueryDeviceList.mockResolvedValue([]); + mockGenerateToolsDetailed.mockReturnValue({ enabledToolIds: [], tools: [] }); + mockGetEnabledPluginManifests.mockReturnValue(new Map()); + service = new AiAgentService(mockDb, userId); + }); + + describe('RemoteDevice flows through ToolsEngine pipeline', () => { + it('should pass RemoteDevice identifier in pluginIds to ToolsEngine', async () => { + mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig()); + + await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' }); + + // Verify generateToolsDetailed receives RemoteDevice in toolIds + expect(mockGenerateToolsDetailed).toHaveBeenCalledTimes(1); + const toolIds = mockGenerateToolsDetailed.mock.calls[0][0].toolIds; + expect(toolIds).toContain(RemoteDeviceManifest.identifier); + }); + + it('should pass RemoteDevice identifier in pluginIds to getEnabledPluginManifests', async () => { + mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig()); + + await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' }); + + expect(mockGetEnabledPluginManifests).toHaveBeenCalledTimes(1); + const pluginIds = mockGetEnabledPluginManifests.mock.calls[0][0]; + expect(pluginIds).toContain(RemoteDeviceManifest.identifier); + }); + }); + + describe('deviceContext forwarded to createServerAgentToolsEngine', () => { + it('should pass deviceContext when gateway is configured', async () => { + // Override deviceProxy.isConfigured + const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy'); + vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true); + mockQueryDeviceList.mockResolvedValue([ + { deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' }, + ]); + + mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig()); + + await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' }); + + expect(mockCreateServerAgentToolsEngine).toHaveBeenCalledTimes(1); + const params = mockCreateServerAgentToolsEngine.mock.calls[0][1]; + expect(params.deviceContext).toEqual({ + boundDeviceId: undefined, + deviceOnline: true, + gatewayConfigured: true, + }); + }); + + it('should not pass deviceContext when gateway is not configured', async () => { + const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy'); + vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(false); + + mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig()); + + await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' }); + + expect(mockCreateServerAgentToolsEngine).toHaveBeenCalledTimes(1); + const params = mockCreateServerAgentToolsEngine.mock.calls[0][1]; + expect(params.deviceContext).toBeUndefined(); + }); + }); + + describe('RemoteDevice systemRole override', () => { + it('should override RemoteDevice systemRole with dynamic prompt when enabled by ToolsEngine', async () => { + const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy'); + vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(true); + mockQueryDeviceList.mockResolvedValue([ + { deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' }, + ]); + + // ToolsEngine returns RemoteDevice in manifestMap (enabled by enableChecker) + const remoteDeviceManifestFromEngine = { + ...RemoteDeviceManifest, + systemRole: 'original static systemRole', + }; + mockGetEnabledPluginManifests.mockReturnValue( + new Map([[RemoteDeviceManifest.identifier, remoteDeviceManifestFromEngine]]), + ); + + mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig()); + + await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' }); + + // The toolSet.manifestMap passed to createOperation should have RemoteDevice + // with a dynamically generated systemRole (not the static one from engine) + const callArgs = mockCreateOperation.mock.calls[0][0]; + const manifestMap = callArgs.toolSet.manifestMap; + + expect(manifestMap[RemoteDeviceManifest.identifier]).toBeDefined(); + // generateSystemPrompt includes device info — it should NOT be the static original + expect(manifestMap[RemoteDeviceManifest.identifier].systemRole).not.toBe( + 'original static systemRole', + ); + // The dynamic systemRole should contain device list info + expect(typeof manifestMap[RemoteDeviceManifest.identifier].systemRole).toBe('string'); + }); + + it('should NOT have RemoteDevice in manifestMap when gateway is not configured', async () => { + const { deviceProxy } = await import('@/server/services/toolExecution/deviceProxy'); + vi.spyOn(deviceProxy, 'isConfigured', 'get').mockReturnValue(false); + + // ToolsEngine returns empty manifestMap (RemoteDevice disabled by enableChecker) + mockGetEnabledPluginManifests.mockReturnValue(new Map()); + + mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig()); + + await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' }); + + const callArgs = mockCreateOperation.mock.calls[0][0]; + const manifestMap = callArgs.toolSet.manifestMap; + + // RemoteDevice should NOT be in manifestMap — no manual injection + expect(manifestMap[RemoteDeviceManifest.identifier]).toBeUndefined(); + }); + }); + + describe('toolManifestMap fully derived from ToolsEngine', () => { + it('should derive manifestMap entirely from getEnabledPluginManifests', async () => { + const mockManifest = { + api: [{ description: 'test', name: 'action', parameters: {} }], + identifier: 'test-tool', + meta: { title: 'Test' }, + }; + mockGetEnabledPluginManifests.mockReturnValue(new Map([['test-tool', mockManifest]])); + + mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig({ plugins: ['test-tool'] })); + + await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' }); + + const callArgs = mockCreateOperation.mock.calls[0][0]; + const manifestMap = callArgs.toolSet.manifestMap; + + expect(manifestMap['test-tool']).toBe(mockManifest); + // No extra manifests added manually + expect(Object.keys(manifestMap)).toEqual(['test-tool']); + }); + }); +}); diff --git a/src/server/services/aiAgent/__tests__/execAgent.files.test.ts b/src/server/services/aiAgent/__tests__/execAgent.files.test.ts index 3c4cd3e02a..267e7199ce 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.files.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.files.test.ts @@ -105,6 +105,13 @@ vi.mock('@/server/modules/Mecha', () => ({ serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), })); +vi.mock('@/server/services/toolExecution/deviceProxy', () => ({ + deviceProxy: { + isConfigured: false, + queryDeviceList: vi.fn().mockResolvedValue([]), + }, +})); + vi.mock('model-bank', async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/server/services/aiAgent/__tests__/execAgent.threadId.test.ts b/src/server/services/aiAgent/__tests__/execAgent.threadId.test.ts index f7de6f8200..4d540cf35f 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.threadId.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.threadId.test.ts @@ -104,6 +104,13 @@ vi.mock('@/server/services/klavis', () => ({ })), })); +// Mock FileService +vi.mock('@/server/services/file', () => ({ + FileService: vi.fn().mockImplementation(() => ({ + uploadFromUrl: vi.fn(), + })), +})); + // Mock Mecha modules vi.mock('@/server/modules/Mecha', () => ({ createServerAgentToolsEngine: vi.fn().mockReturnValue({ @@ -113,6 +120,14 @@ vi.mock('@/server/modules/Mecha', () => ({ serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), })); +// Mock deviceProxy +vi.mock('@/server/services/toolExecution/deviceProxy', () => ({ + deviceProxy: { + isConfigured: false, + queryDeviceList: vi.fn().mockResolvedValue([]), + }, +})); + // Mock model-bank vi.mock('model-bank', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/server/services/aiAgent/__tests__/execAgent.topicHistory.test.ts b/src/server/services/aiAgent/__tests__/execAgent.topicHistory.test.ts index f93b60fe7c..57a062c10e 100644 --- a/src/server/services/aiAgent/__tests__/execAgent.topicHistory.test.ts +++ b/src/server/services/aiAgent/__tests__/execAgent.topicHistory.test.ts @@ -101,6 +101,19 @@ vi.mock('@/server/modules/Mecha', () => ({ serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]), })); +vi.mock('@/server/services/file', () => ({ + FileService: vi.fn().mockImplementation(() => ({ + uploadFromUrl: vi.fn(), + })), +})); + +vi.mock('@/server/services/toolExecution/deviceProxy', () => ({ + deviceProxy: { + isConfigured: false, + queryDeviceList: vi.fn().mockResolvedValue([]), + }, +})); + vi.mock('model-bank', async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/server/services/aiAgent/index.ts b/src/server/services/aiAgent/index.ts index c848052d8b..f620f0426b 100644 --- a/src/server/services/aiAgent/index.ts +++ b/src/server/services/aiAgent/index.ts @@ -1,17 +1,24 @@ -import { type AgentRuntimeContext, type AgentState } from '@lobechat/agent-runtime'; +import type { AgentRuntimeContext, AgentState } from '@lobechat/agent-runtime'; +import { BUILTIN_AGENT_SLUGS, getAgentRuntimeConfig } from '@lobechat/builtin-agents'; +import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; +import { + type DeviceAttachment, + generateSystemPrompt, + RemoteDeviceManifest, +} from '@lobechat/builtin-tool-remote-device'; import { builtinTools } from '@lobechat/builtin-tools'; import { LOADING_FLAT } from '@lobechat/const'; -import { type LobeToolManifest } from '@lobechat/context-engine'; -import { type LobeChatDatabase } from '@lobechat/database'; -import { - type ChatTopicBotContext, - type ExecAgentParams, - type ExecAgentResult, - type ExecGroupAgentParams, - type ExecGroupAgentResult, - type ExecSubAgentTaskParams, - type ExecSubAgentTaskResult, - type UserInterventionConfig, +import type { LobeToolManifest } from '@lobechat/context-engine'; +import type { LobeChatDatabase } from '@lobechat/database'; +import type { + ChatTopicBotContext, + ExecAgentParams, + ExecAgentResult, + ExecGroupAgentParams, + ExecGroupAgentResult, + ExecSubAgentTaskParams, + ExecSubAgentTaskResult, + UserInterventionConfig, } from '@lobechat/types'; import { ThreadStatus, ThreadType } from '@lobechat/types'; import { nanoid } from '@lobechat/utils'; @@ -37,6 +44,7 @@ import { type StepLifecycleCallbacks } from '@/server/services/agentRuntime/type import { FileService } from '@/server/services/file'; import { KlavisService } from '@/server/services/klavis'; import { MarketService } from '@/server/services/market'; +import { deviceProxy } from '@/server/services/toolExecution/deviceProxy'; const log = debug('lobe-server:ai-agent-service'); @@ -229,7 +237,30 @@ export class AiAgentService { agentConfig.provider, ); - // 2. Handle topic creation: if no topicId provided, create a new topic; otherwise reuse existing + // 2. Merge builtin agent runtime config (systemRole, plugins) + // The DB only stores persist config. Runtime config (e.g. inbox systemRole) is generated dynamically. + const agentSlug = agentConfig.slug; + const builtinSlugs = Object.values(BUILTIN_AGENT_SLUGS) as string[]; + if (agentSlug && builtinSlugs.includes(agentSlug)) { + const runtimeConfig = getAgentRuntimeConfig(agentSlug, { + model: agentConfig.model, + plugins: agentConfig.plugins ?? [], + }); + if (runtimeConfig) { + // Runtime systemRole takes effect only if DB has no user-customized systemRole + if (!agentConfig.systemRole && runtimeConfig.systemRole) { + agentConfig.systemRole = runtimeConfig.systemRole; + log('execAgent: merged builtin agent runtime systemRole for slug=%s', agentSlug); + } + // Runtime plugins merged (runtime plugins take priority if provided) + if (runtimeConfig.plugins && runtimeConfig.plugins.length > 0) { + agentConfig.plugins = runtimeConfig.plugins; + log('execAgent: merged builtin agent runtime plugins for slug=%s', agentSlug); + } + } + } + + // 3. Handle topic creation: if no topicId provided, create a new topic; otherwise reuse existing let topicId = appContext?.topicId; if (!topicId) { // Prepare metadata with cronJobId and botContext if provided @@ -260,18 +291,18 @@ export class AiAgentService { const model = agentConfig.model!; const provider = agentConfig.provider!; - // 3. Get installed plugins from database + // 4. Get installed plugins from database const installedPlugins = await this.pluginModel.query(); log('execAgent: got %d installed plugins', installedPlugins.length); - // 4. Get model abilities from model-bank for function calling support check + // 5. Get model abilities from model-bank for function calling support check const { LOBE_DEFAULT_MODEL_LIST } = await import('model-bank'); const isModelSupportToolUse = (m: string, p: string) => { const info = LOBE_DEFAULT_MODEL_LIST.find((item) => item.id === m && item.providerId === p); return info?.abilities?.functionCall ?? true; }; - // 5. Fetch LobeHub Skills manifests (temporary solution until LOBE-3517 is implemented) + // 6. Fetch LobeHub Skills manifests (temporary solution until LOBE-3517 is implemented) let lobehubSkillManifests: LobeToolManifest[] = []; try { lobehubSkillManifests = await this.marketService.getLobehubSkillManifests(); @@ -280,7 +311,7 @@ export class AiAgentService { } log('execAgent: got %d lobehub skill manifests', lobehubSkillManifests.length); - // 6. Fetch Klavis tool manifests from database + // 7. Fetch Klavis tool manifests from database let klavisManifests: LobeToolManifest[] = []; try { klavisManifests = await this.klavisService.getKlavisManifests(); @@ -289,11 +320,44 @@ export class AiAgentService { } log('execAgent: got %d klavis manifests', klavisManifests.length); - // 7. Create tools using Server AgentToolsEngine + // 8. Fetch user settings (memory config + timezone) + let globalMemoryEnabled = false; + let userTimezone: string | undefined; + try { + const userModel = new UserModel(this.db, this.userId); + const settings = await userModel.getUserSettings(); + const memorySettings = settings?.memory as { enabled?: boolean } | undefined; + globalMemoryEnabled = memorySettings?.enabled !== false; + const generalSettings = settings?.general as { timezone?: string } | undefined; + userTimezone = generalSettings?.timezone; + } catch (error) { + log('execAgent: failed to fetch user settings: %O', error); + } + log( + 'execAgent: globalMemoryEnabled=%s, timezone=%s', + globalMemoryEnabled, + userTimezone ?? 'default', + ); + + // 9. Create tools using Server AgentToolsEngine const hasEnabledKnowledgeBases = agentConfig.knowledgeBases?.some((kb: { enabled?: boolean | null }) => kb.enabled === true) ?? false; + // Build device context for ToolsEngine enableChecker + const gatewayConfigured = deviceProxy.isConfigured; + const boundDeviceId = agentConfig.agencyConfig?.boundDeviceId; + let onlineDevices: DeviceAttachment[] = []; + if (gatewayConfigured) { + try { + onlineDevices = await deviceProxy.queryDeviceList(this.userId); + log('execAgent: found %d online device(s)', onlineDevices.length); + } catch (error) { + log('execAgent: failed to query device list: %O', error); + } + } + const deviceOnline = onlineDevices.length > 0; + const toolsContext: ServerAgentToolsContext = { installedPlugins, isModelSupportToolUse, @@ -305,13 +369,22 @@ export class AiAgentService { chatConfig: agentConfig.chatConfig ?? undefined, plugins: agentConfig?.plugins ?? undefined, }, + deviceContext: gatewayConfigured + ? { boundDeviceId, deviceOnline, gatewayConfigured: true } + : undefined, + globalMemoryEnabled, hasEnabledKnowledgeBases, model, provider, }); // Generate tools and manifest map - const pluginIds = agentConfig.plugins || []; + // Include device tool IDs so ToolsEngine can process them via enableChecker + const pluginIds = [ + ...(agentConfig.plugins || []), + LocalSystemManifest.identifier, + RemoteDeviceManifest.identifier, + ]; log('execAgent: agent configured plugins: %O', pluginIds); const toolsResult = toolsEngine.generateToolsDetailed({ @@ -351,7 +424,54 @@ export class AiAgentService { klavisManifests.length, ); - // 7.5. Build Agent Management context if agent-management tool is enabled + // Override RemoteDevice manifest's systemRole with dynamic device list prompt + // The manifest is already included/excluded by ToolsEngine enableChecker + if (toolManifestMap[RemoteDeviceManifest.identifier]) { + toolManifestMap[RemoteDeviceManifest.identifier] = { + ...toolManifestMap[RemoteDeviceManifest.identifier], + systemRole: generateSystemPrompt(onlineDevices), + }; + } + + // Derive activeDeviceId from device context: + // 1. If agent has a bound device and it's online, use it + // 2. In IM/Bot scenarios, auto-activate when exactly one device is online + const activeDeviceId = boundDeviceId + ? deviceOnline + ? boundDeviceId + : undefined + : (discordContext || botContext) && onlineDevices.length === 1 + ? onlineDevices[0].deviceId + : undefined; + + // 9.4. Fetch device system info for placeholder variable replacement + let deviceSystemInfo: Record = {}; + if (activeDeviceId) { + try { + const systemInfo = await deviceProxy.queryDeviceSystemInfo(this.userId, activeDeviceId); + if (systemInfo) { + const activeDevice = onlineDevices.find((d) => d.deviceId === activeDeviceId); + deviceSystemInfo = { + arch: systemInfo.arch, + desktopPath: systemInfo.desktopPath, + documentsPath: systemInfo.documentsPath, + downloadsPath: systemInfo.downloadsPath, + homePath: systemInfo.homePath, + musicPath: systemInfo.musicPath, + picturesPath: systemInfo.picturesPath, + platform: activeDevice?.platform ?? 'unknown', + userDataPath: systemInfo.userDataPath, + videosPath: systemInfo.videosPath, + workingDirectory: systemInfo.workingDirectory, + }; + log('execAgent: fetched device system info for %s', activeDeviceId); + } + } catch (error) { + log('execAgent: failed to fetch device system info: %O', error); + } + } + + // 9.5. Build Agent Management context if agent-management tool is enabled const isAgentManagementEnabled = toolsResult.enabledToolIds?.includes('lobe-agent-management'); let agentManagementContext; if (isAgentManagementEnabled) { @@ -443,22 +563,9 @@ export class AiAgentService { ); } - // 8. Fetch user persona for memory injection - // Persona is user-level global memory, only depends on user's global memory setting + // 10. Fetch user persona for memory injection (reuses globalMemoryEnabled from step 8) let userMemory: ServerUserMemoryConfig | undefined; - let globalMemoryEnabled = true; // default: enabled (matches DEFAULT_MEMORY_SETTINGS) - try { - const userModel = new UserModel(this.db, this.userId); - const settings = await userModel.getUserSettings(); - const memorySettings = settings?.memory as { enabled?: boolean } | undefined; - globalMemoryEnabled = memorySettings?.enabled !== false; - } catch (error) { - log('execAgent: failed to fetch user memory settings: %O', error); - } - - log('execAgent: memory check — globalMemoryEnabled=%s', globalMemoryEnabled); - if (globalMemoryEnabled) { try { const personaModel = new UserPersonaModel(this.db, this.userId); @@ -484,7 +591,7 @@ export class AiAgentService { } } - // 9. Get existing messages if provided + // 11. Get existing messages if provided let historyMessages: any[] = []; if (existingMessageIds.length > 0) { historyMessages = await this.messageModel.query({ @@ -501,7 +608,7 @@ export class AiAgentService { }); } - // 9. Upload external files to S3 and collect file IDs + // 12. Upload external files to S3 and collect file IDs let fileIds: string[] | undefined; let imageList: Array<{ alt: string; id: string; url: string }> | undefined; @@ -534,7 +641,7 @@ export class AiAgentService { if (imageList.length === 0) imageList = undefined; } - // 10. Create user message in database + // 13. Create user message in database // Include threadId if provided (for SubAgent task execution in isolated Thread) const userMessageRecord = await this.messageModel.create({ agentId: resolvedAgentId, @@ -546,7 +653,7 @@ export class AiAgentService { }); log('execAgent: created user message %s', userMessageRecord.id); - // 11. Create assistant message placeholder in database + // 14. Create assistant message placeholder in database // Include threadId if provided (for SubAgent task execution in isolated Thread) const assistantMessageRecord = await this.messageModel.create({ agentId: resolvedAgentId, @@ -568,11 +675,11 @@ export class AiAgentService { log('execAgent: prepared evalContext for executor'); - // 12. Generate operation ID: agt_{timestamp}_{agentId}_{topicId}_{random} + // 15. Generate operation ID: agt_{timestamp}_{agentId}_{topicId}_{random} const timestamp = Date.now(); const operationId = `op_${timestamp}_${resolvedAgentId}_${topicId}_${nanoid(8)}`; - // 13. Create initial context + // 16. Create initial context const initialContext: AgentRuntimeContext = { payload: { // Pass assistant message ID so agent runtime knows which message to update @@ -593,7 +700,7 @@ export class AiAgentService { }, }; - // 14. Log final operation parameters summary + // 17. Log final operation parameters summary log( 'execAgent: creating operation %s with params: model=%s, provider=%s, tools=%d, messages=%d, manifests=%d', operationId, @@ -604,12 +711,15 @@ export class AiAgentService { Object.keys(toolManifestMap).length, ); - // 15. Create operation using AgentRuntimeService + // 18. Create operation using AgentRuntimeService // Wrap in try-catch to handle operation startup failures (e.g., QStash unavailable) // If createOperation fails, we still have valid messages that need error info try { const result = await this.agentRuntimeService.createOperation({ + activeDeviceId, agentConfig, + deviceSystemInfo: Object.keys(deviceSystemInfo).length > 0 ? deviceSystemInfo : undefined, + userTimezone, appContext: { agentId: resolvedAgentId, groupId: appContext?.groupId, @@ -623,14 +733,17 @@ export class AiAgentService { initialContext, initialMessages: allMessages, maxSteps, - stepWebhook, modelRuntimeConfig: { model, provider }, operationId, stepCallbacks, + stepWebhook, stream, - toolManifestMap, - toolSourceMap, - tools, + toolSet: { + enabledToolIds: toolsResult.enabledToolIds, + manifestMap: toolManifestMap, + sourceMap: toolSourceMap, + tools, + }, userId: this.userId, userInterventionConfig, userMemory, diff --git a/src/server/services/bot/AgentBridgeService.ts b/src/server/services/bot/AgentBridgeService.ts index aa50acbee3..7a11acc837 100644 --- a/src/server/services/bot/AgentBridgeService.ts +++ b/src/server/services/bot/AgentBridgeService.ts @@ -67,19 +67,16 @@ async function safeReaction(fn: () => Promise, label: string): Promise= 4 && parts[0] === 'discord' && parts[3]) { - return `discord:${parts[1]}:${parts[3]}`; + if (parts.length >= 4 && parts[0] === 'discord') { + return `discord:${parts[1]}:${parts[2]}`; } return threadId; } @@ -137,13 +134,11 @@ export class AgentBridgeService { ); // Immediate feedback: mark as received + show typing + // The mention message lives in the parent channel (not the thread), so we strip + // the thread segment from the ID to target the parent channel for reactions. await safeReaction( () => - thread.adapter.addReaction( - rewriteThreadIdForReaction(thread.id), - message.id, - RECEIVED_EMOJI, - ), + thread.adapter.addReaction(parentChannelThreadId(thread.id), message.id, RECEIVED_EMOJI), 'add eyes', ); await thread.subscribe(); @@ -166,6 +161,7 @@ export class AgentBridgeService { agentId, botContext, channelContext, + reactionThreadId: parentChannelThreadId(thread.id), trigger: 'bot', }); @@ -182,7 +178,8 @@ export class AgentBridgeService { clearInterval(typingInterval); // In queue mode, reaction is removed by the bot-callback webhook on completion if (!queueMode) { - await this.removeReceivedReaction(thread, message); + // Mention message is in parent channel + await this.removeReceivedReaction(thread, message, parentChannelThreadId(thread.id)); } } } @@ -212,13 +209,9 @@ export class AgentBridgeService { const queueMode = isQueueAgentRuntimeEnabled(); // Immediate feedback: mark as received + show typing + // Subscribed messages are inside the thread, so pass thread.id directly await safeReaction( - () => - thread.adapter.addReaction( - rewriteThreadIdForReaction(thread.id), - message.id, - RECEIVED_EMOJI, - ), + () => thread.adapter.addReaction(thread.id, message.id, RECEIVED_EMOJI), 'add eyes', ); await thread.startTyping(); @@ -271,6 +264,8 @@ export class AgentBridgeService { agentId: string; botContext?: ChatTopicBotContext; channelContext?: DiscordChannelContext; + /** Thread ID to use for removing the user message reaction in queue mode */ + reactionThreadId?: string; topicId?: string; trigger?: string; }, @@ -293,11 +288,12 @@ export class AgentBridgeService { agentId: string; botContext?: ChatTopicBotContext; channelContext?: DiscordChannelContext; + reactionThreadId?: string; topicId?: string; trigger?: string; }, ): Promise<{ reply: string; topicId: string }> { - const { agentId, botContext, channelContext, topicId, trigger } = opts; + const { agentId, botContext, channelContext, reactionThreadId, topicId, trigger } = opts; const aiAgentService = new AiAgentService(this.db, this.userId); const timezone = await this.loadTimezone(); @@ -328,10 +324,14 @@ export class AgentBridgeService { const callbackUrl = urlJoin(baseURL, '/api/agent/webhooks/bot-callback'); // Shared webhook body with bot context + // reactionChannelId: the Discord channel where the user message lives (for reaction removal). + // For mention messages this is the parent channel; for thread messages it's the thread itself. + const reactionChannelId = reactionThreadId ? reactionThreadId.split(':')[2] : undefined; const webhookBody = { applicationId: botContext?.applicationId, platformThreadId: botContext?.platformThreadId, progressMessageId, + reactionChannelId, userMessageId: userMessage.id, }; @@ -720,18 +720,18 @@ export class AgentBridgeService { /** * Remove the received reaction from a user message (fire-and-forget). + * @param reactionThreadId - The thread ID to use for the reaction API call. + * For messages in parent channels (handleMention), use parentChannelThreadId(thread.id). + * For messages inside threads (handleSubscribedMessage), use thread.id directly. */ private async removeReceivedReaction( thread: Thread, message: Message, + reactionThreadId?: string, ): Promise { await safeReaction( () => - thread.adapter.removeReaction( - rewriteThreadIdForReaction(thread.id), - message.id, - RECEIVED_EMOJI, - ), + thread.adapter.removeReaction(reactionThreadId ?? thread.id, message.id, RECEIVED_EMOJI), 'remove eyes', ); } diff --git a/src/server/services/bot/BotCallbackService.ts b/src/server/services/bot/BotCallbackService.ts new file mode 100644 index 0000000000..2015afcf62 --- /dev/null +++ b/src/server/services/bot/BotCallbackService.ts @@ -0,0 +1,353 @@ +import debug from 'debug'; + +import { AgentBotProviderModel } from '@/database/models/agentBotProvider'; +import { TopicModel } from '@/database/models/topic'; +import { type LobeChatDatabase } from '@/database/type'; +import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt'; +import { SystemAgentService } from '@/server/services/systemAgent'; + +import { DiscordRestApi } from './discordRestApi'; +import { LarkRestApi } from './larkRestApi'; +import { renderError, renderFinalReply, renderStepProgress, splitMessage } from './replyTemplate'; +import { TelegramRestApi } from './telegramRestApi'; + +const log = debug('lobe-server:bot:callback'); + +// --------------- Platform helpers --------------- + +function extractDiscordChannelId(platformThreadId: string): string { + const parts = platformThreadId.split(':'); + return parts[3] || parts[2]; +} + +function extractTelegramChatId(platformThreadId: string): string { + return platformThreadId.split(':')[1]; +} + +function extractLarkChatId(platformThreadId: string): string { + return platformThreadId.split(':')[1]; +} + +function parseTelegramMessageId(compositeId: string): number { + const colonIdx = compositeId.lastIndexOf(':'); + return colonIdx !== -1 ? Number(compositeId.slice(colonIdx + 1)) : Number(compositeId); +} + +const TELEGRAM_CHAR_LIMIT = 4000; +const LARK_CHAR_LIMIT = 4000; + +// --------------- Platform-agnostic messenger --------------- + +interface PlatformMessenger { + createMessage: (content: string) => Promise; + editMessage: (messageId: string, content: string) => Promise; + removeReaction: (messageId: string, emoji: string) => Promise; + triggerTyping: () => Promise; + updateThreadName?: (name: string) => Promise; +} + +function createDiscordMessenger( + discord: DiscordRestApi, + channelId: string, + platformThreadId: string, +): PlatformMessenger { + return { + createMessage: (content) => discord.createMessage(channelId, content).then(() => {}), + editMessage: (messageId, content) => discord.editMessage(channelId, messageId, content), + removeReaction: (messageId, emoji) => discord.removeOwnReaction(channelId, messageId, emoji), + triggerTyping: () => discord.triggerTyping(channelId), + updateThreadName: (name) => { + const threadId = platformThreadId.split(':')[3]; + return threadId ? discord.updateChannelName(threadId, name) : Promise.resolve(); + }, + }; +} + +function createTelegramMessenger(telegram: TelegramRestApi, chatId: string): PlatformMessenger { + return { + createMessage: (content) => telegram.sendMessage(chatId, content).then(() => {}), + editMessage: (messageId, content) => + telegram.editMessageText(chatId, parseTelegramMessageId(messageId), content), + removeReaction: (messageId) => + telegram.removeMessageReaction(chatId, parseTelegramMessageId(messageId)), + triggerTyping: () => telegram.sendChatAction(chatId, 'typing'), + }; +} + +function createLarkMessenger(lark: LarkRestApi, chatId: string): PlatformMessenger { + return { + createMessage: (content) => lark.sendMessage(chatId, content).then(() => {}), + editMessage: (messageId, content) => lark.editMessage(messageId, content), + // Lark has no reaction/typing API for bots + removeReaction: () => Promise.resolve(), + triggerTyping: () => Promise.resolve(), + }; +} + +// --------------- Callback body types --------------- + +export interface BotCallbackBody { + applicationId: string; + content?: string; + cost?: number; + duration?: number; + elapsedMs?: number; + errorMessage?: string; + executionTimeMs?: number; + lastAssistantContent?: string; + lastLLMContent?: string; + lastToolsCalling?: any; + llmCalls?: number; + platformThreadId: string; + progressMessageId: string; + reactionChannelId?: string; + reason?: string; + reasoning?: string; + shouldContinue?: boolean; + stepType?: 'call_llm' | 'call_tool'; + thinking?: boolean; + toolCalls?: number; + toolsCalling?: any; + toolsResult?: any; + topicId?: string; + totalCost?: number; + totalInputTokens?: number; + totalOutputTokens?: number; + totalSteps?: number; + totalTokens?: number; + totalToolCalls?: any; + type: 'completion' | 'step'; + userId?: string; + userMessageId?: string; + userPrompt?: string; +} + +// --------------- Service --------------- + +export class BotCallbackService { + private readonly db: LobeChatDatabase; + + constructor(db: LobeChatDatabase) { + this.db = db; + } + + async handleCallback(body: BotCallbackBody): Promise { + const { type, applicationId, platformThreadId, progressMessageId } = body; + const platform = platformThreadId.split(':')[0]; + + const { botToken, messenger, charLimit } = await this.createMessenger( + platform, + applicationId, + platformThreadId, + ); + + if (type === 'step') { + await this.handleStep(body, messenger, progressMessageId, platform); + } else if (type === 'completion') { + await this.handleCompletion(body, messenger, progressMessageId, platform, charLimit); + await this.removeEyesReaction(body, messenger, botToken, platform, platformThreadId); + this.summarizeTopicTitle(body, messenger); + } + } + + private async createMessenger( + platform: string, + applicationId: string, + platformThreadId: string, + ): Promise<{ botToken: string; charLimit?: number; messenger: PlatformMessenger }> { + const row = await AgentBotProviderModel.findByPlatformAndAppId( + this.db, + platform, + applicationId, + ); + + if (!row?.credentials) { + throw new Error(`Bot provider not found for ${platform} appId=${applicationId}`); + } + + const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey(); + let credentials: Record; + try { + credentials = JSON.parse((await gateKeeper.decrypt(row.credentials)).plaintext); + } catch { + credentials = JSON.parse(row.credentials); + } + + const isLark = platform === 'lark' || platform === 'feishu'; + + if (isLark ? !credentials.appId || !credentials.appSecret : !credentials.botToken) { + throw new Error(`Bot credentials incomplete for ${platform} appId=${applicationId}`); + } + + switch (platform) { + case 'telegram': { + const telegram = new TelegramRestApi(credentials.botToken); + const chatId = extractTelegramChatId(platformThreadId); + return { + botToken: credentials.botToken, + charLimit: TELEGRAM_CHAR_LIMIT, + messenger: createTelegramMessenger(telegram, chatId), + }; + } + case 'lark': + case 'feishu': { + const lark = new LarkRestApi(credentials.appId, credentials.appSecret, platform); + const chatId = extractLarkChatId(platformThreadId); + return { + botToken: credentials.appId, + charLimit: LARK_CHAR_LIMIT, + messenger: createLarkMessenger(lark, chatId), + }; + } + case 'discord': + default: { + const discord = new DiscordRestApi(credentials.botToken); + const channelId = extractDiscordChannelId(platformThreadId); + return { + botToken: credentials.botToken, + messenger: createDiscordMessenger(discord, channelId, platformThreadId), + }; + } + } + } + + private async handleStep( + body: BotCallbackBody, + messenger: PlatformMessenger, + progressMessageId: string, + platform: string, + ): Promise { + if (!body.shouldContinue) return; + + const progressText = renderStepProgress({ + content: body.content, + elapsedMs: body.elapsedMs, + executionTimeMs: body.executionTimeMs ?? 0, + lastContent: body.lastLLMContent, + lastToolsCalling: body.lastToolsCalling, + platform, + reasoning: body.reasoning, + stepType: body.stepType ?? ('call_llm' as const), + thinking: body.thinking ?? false, + toolsCalling: body.toolsCalling, + toolsResult: body.toolsResult, + totalCost: body.totalCost ?? 0, + totalInputTokens: body.totalInputTokens ?? 0, + totalOutputTokens: body.totalOutputTokens ?? 0, + totalSteps: body.totalSteps ?? 0, + totalTokens: body.totalTokens ?? 0, + totalToolCalls: body.totalToolCalls, + }); + + const isLlmFinalResponse = + body.stepType === 'call_llm' && !body.toolsCalling?.length && body.content; + + try { + await messenger.editMessage(progressMessageId, progressText); + if (!isLlmFinalResponse) { + await messenger.triggerTyping(); + } + } catch (error) { + log('handleStep: failed to edit progress message: %O', error); + } + } + + private async handleCompletion( + body: BotCallbackBody, + messenger: PlatformMessenger, + progressMessageId: string, + platform: string, + charLimit?: number, + ): Promise { + const { reason, lastAssistantContent, errorMessage } = body; + + if (reason === 'error') { + const errorText = renderError(errorMessage || 'Agent execution failed'); + try { + await messenger.editMessage(progressMessageId, errorText); + } catch (error) { + log('handleCompletion: failed to edit error message: %O', error); + } + return; + } + + if (!lastAssistantContent) { + log('handleCompletion: no lastAssistantContent, skipping'); + return; + } + + const finalText = renderFinalReply(lastAssistantContent, { + elapsedMs: body.duration, + llmCalls: body.llmCalls ?? 0, + platform, + toolCalls: body.toolCalls ?? 0, + totalCost: body.cost ?? 0, + totalTokens: body.totalTokens ?? 0, + }); + + const chunks = splitMessage(finalText, charLimit); + + try { + await messenger.editMessage(progressMessageId, chunks[0]); + for (let i = 1; i < chunks.length; i++) { + await messenger.createMessage(chunks[i]); + } + } catch (error) { + log('handleCompletion: failed to edit/post final message: %O', error); + } + } + + private async removeEyesReaction( + body: BotCallbackBody, + messenger: PlatformMessenger, + botToken: string, + platform: string, + platformThreadId: string, + ): Promise { + const { userMessageId, reactionChannelId } = body; + if (!userMessageId) return; + + try { + if (platform === 'discord') { + // Use reactionChannelId (parent channel for mentions, thread for follow-ups) + const discord = new DiscordRestApi(botToken); + const targetChannelId = reactionChannelId || extractDiscordChannelId(platformThreadId); + await discord.removeOwnReaction(targetChannelId, userMessageId, '👀'); + } else { + await messenger.removeReaction(userMessageId, '👀'); + } + } catch (error) { + log('removeEyesReaction: failed: %O', error); + } + } + + private summarizeTopicTitle(body: BotCallbackBody, messenger: PlatformMessenger): void { + const { reason, topicId, userId, userPrompt, lastAssistantContent } = body; + if (reason === 'error' || !topicId || !userId || !userPrompt || !lastAssistantContent) return; + + const topicModel = new TopicModel(this.db, userId); + topicModel + .findById(topicId) + .then(async (topic) => { + if (topic?.title) return; + + const systemAgent = new SystemAgentService(this.db, userId); + const title = await systemAgent.generateTopicTitle({ + lastAssistantContent, + userPrompt, + }); + if (!title) return; + + await topicModel.update(topicId, { title }); + + if (messenger.updateThreadName) { + messenger.updateThreadName(title).catch((error) => { + log('summarizeTopicTitle: failed to update thread name: %O', error); + }); + } + }) + .catch((error) => { + log('summarizeTopicTitle: failed: %O', error); + }); + } +} diff --git a/src/server/services/bot/__tests__/BotCallbackService.test.ts b/src/server/services/bot/__tests__/BotCallbackService.test.ts new file mode 100644 index 0000000000..17dadac4f7 --- /dev/null +++ b/src/server/services/bot/__tests__/BotCallbackService.test.ts @@ -0,0 +1,794 @@ +import { describe, expect, it, vi } from 'vitest'; + +// ==================== Import after mocks ==================== +import type { BotCallbackBody } from '../BotCallbackService'; +import { BotCallbackService } from '../BotCallbackService'; + +// ==================== Hoisted mocks ==================== + +const mockFindByPlatformAndAppId = vi.hoisted(() => vi.fn()); +const mockInitWithEnvKey = vi.hoisted(() => vi.fn()); +const mockDecrypt = vi.hoisted(() => vi.fn()); +const mockFindById = vi.hoisted(() => vi.fn()); +const mockTopicUpdate = vi.hoisted(() => vi.fn()); +const mockGenerateTopicTitle = vi.hoisted(() => vi.fn()); + +// Discord REST mock methods +const mockDiscordEditMessage = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockDiscordTriggerTyping = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockDiscordRemoveOwnReaction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockDiscordCreateMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'new-msg' })); +const mockDiscordUpdateChannelName = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +// Telegram REST mock methods +const mockTelegramSendMessage = vi.hoisted(() => vi.fn().mockResolvedValue({ message_id: 12345 })); +const mockTelegramEditMessageText = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockTelegramRemoveMessageReaction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockTelegramSendChatAction = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +// ==================== vi.mock ==================== + +vi.mock('@/database/models/agentBotProvider', () => ({ + AgentBotProviderModel: { + findByPlatformAndAppId: mockFindByPlatformAndAppId, + }, +})); + +vi.mock('@/database/models/topic', () => ({ + TopicModel: vi.fn().mockImplementation(() => ({ + findById: mockFindById, + update: mockTopicUpdate, + })), +})); + +vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({ + KeyVaultsGateKeeper: { + initWithEnvKey: mockInitWithEnvKey, + }, +})); + +vi.mock('@/server/services/systemAgent', () => ({ + SystemAgentService: vi.fn().mockImplementation(() => ({ + generateTopicTitle: mockGenerateTopicTitle, + })), +})); + +vi.mock('../discordRestApi', () => ({ + DiscordRestApi: vi.fn().mockImplementation(() => ({ + createMessage: mockDiscordCreateMessage, + editMessage: mockDiscordEditMessage, + removeOwnReaction: mockDiscordRemoveOwnReaction, + triggerTyping: mockDiscordTriggerTyping, + updateChannelName: mockDiscordUpdateChannelName, + })), +})); + +vi.mock('../telegramRestApi', () => ({ + TelegramRestApi: vi.fn().mockImplementation(() => ({ + editMessageText: mockTelegramEditMessageText, + removeMessageReaction: mockTelegramRemoveMessageReaction, + sendChatAction: mockTelegramSendChatAction, + sendMessage: mockTelegramSendMessage, + })), +})); + +// ==================== Helpers ==================== + +const FAKE_DB = {} as any; +const FAKE_BOT_TOKEN = 'fake-bot-token-123'; +const FAKE_CREDENTIALS = JSON.stringify({ botToken: FAKE_BOT_TOKEN }); + +function setupCredentials(credentials = FAKE_CREDENTIALS) { + mockFindByPlatformAndAppId.mockResolvedValue({ credentials }); + mockInitWithEnvKey.mockResolvedValue({ decrypt: mockDecrypt }); + mockDecrypt.mockResolvedValue({ plaintext: credentials }); +} + +function makeBody(overrides: Partial = {}): BotCallbackBody { + return { + applicationId: 'app-123', + platformThreadId: 'discord:guild:channel-id', + progressMessageId: 'progress-msg-1', + type: 'step', + ...overrides, + }; +} + +function makeTelegramBody(overrides: Partial = {}): BotCallbackBody { + return makeBody({ + platformThreadId: 'telegram:chat-456', + ...overrides, + }); +} + +// ==================== Tests ==================== + +describe('BotCallbackService', () => { + let service: BotCallbackService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new BotCallbackService(FAKE_DB); + setupCredentials(); + }); + + // ==================== Platform detection ==================== + + describe('platform detection from platformThreadId', () => { + it('should detect discord platform from platformThreadId prefix', async () => { + const body = makeBody({ + shouldContinue: true, + stepType: 'call_llm', + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockFindByPlatformAndAppId).toHaveBeenCalledWith(FAKE_DB, 'discord', 'app-123'); + }); + + it('should detect telegram platform from platformThreadId prefix', async () => { + const body = makeTelegramBody({ + shouldContinue: true, + stepType: 'call_llm', + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockFindByPlatformAndAppId).toHaveBeenCalledWith(FAKE_DB, 'telegram', 'app-123'); + }); + }); + + // ==================== Messenger creation errors ==================== + + describe('messenger creation failures', () => { + it('should throw when bot provider not found', async () => { + mockFindByPlatformAndAppId.mockResolvedValue(null); + + const body = makeBody({ type: 'step' }); + + await expect(service.handleCallback(body)).rejects.toThrow( + 'Bot provider not found for discord appId=app-123', + ); + }); + + it('should throw when credentials have no botToken', async () => { + const noTokenCreds = JSON.stringify({ someOtherKey: 'value' }); + setupCredentials(noTokenCreds); + + const body = makeBody({ type: 'step' }); + + await expect(service.handleCallback(body)).rejects.toThrow( + 'Bot credentials incomplete for discord appId=app-123', + ); + }); + + it('should fall back to raw credentials when decryption fails', async () => { + mockFindByPlatformAndAppId.mockResolvedValue({ credentials: FAKE_CREDENTIALS }); + mockInitWithEnvKey.mockResolvedValue({ + decrypt: vi.fn().mockRejectedValue(new Error('decrypt failed')), + }); + + const body = makeBody({ + shouldContinue: true, + stepType: 'call_llm', + type: 'step', + }); + + // Should not throw because it falls back to raw JSON parse + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalled(); + }); + }); + + // ==================== handleCallback routing ==================== + + describe('handleCallback routing', () => { + it('should route step type to handleStep', async () => { + const body = makeBody({ + content: 'Thinking...', + shouldContinue: true, + stepType: 'call_llm', + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledWith( + 'channel-id', + 'progress-msg-1', + expect.any(String), + ); + }); + + it('should route completion type to handleCompletion', async () => { + const body = makeBody({ + lastAssistantContent: 'Here is the answer.', + reason: 'completed', + type: 'completion', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledWith( + 'channel-id', + 'progress-msg-1', + expect.stringContaining('Here is the answer.'), + ); + }); + }); + + // ==================== Step handling ==================== + + describe('step handling', () => { + it('should skip step processing when shouldContinue is false', async () => { + const body = makeBody({ + shouldContinue: false, + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).not.toHaveBeenCalled(); + }); + + it('should edit progress message and trigger typing for non-final LLM step', async () => { + const body = makeBody({ + content: 'Processing...', + shouldContinue: true, + stepType: 'call_llm', + toolsCalling: [{ apiName: 'search', arguments: '{}', identifier: 'web' }], + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1); + expect(mockDiscordTriggerTyping).toHaveBeenCalledTimes(1); + }); + + it('should NOT trigger typing for final LLM response (no tool calls + has content)', async () => { + const body = makeBody({ + content: 'Final answer here', + shouldContinue: true, + stepType: 'call_llm', + toolsCalling: [], + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1); + expect(mockDiscordTriggerTyping).not.toHaveBeenCalled(); + }); + + it('should handle tool step type', async () => { + const body = makeBody({ + lastToolsCalling: [{ apiName: 'search', identifier: 'web' }], + shouldContinue: true, + stepType: 'call_tool', + toolsResult: [{ apiName: 'search', identifier: 'web', output: 'result data' }], + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1); + expect(mockDiscordTriggerTyping).toHaveBeenCalledTimes(1); + }); + + it('should not throw when edit message fails during step', async () => { + mockDiscordEditMessage.mockRejectedValueOnce(new Error('Discord API error')); + + const body = makeBody({ + content: 'Processing...', + shouldContinue: true, + stepType: 'call_llm', + type: 'step', + }); + + // Should not throw - error is logged but swallowed + await expect(service.handleCallback(body)).resolves.toBeUndefined(); + }); + }); + + // ==================== Completion handling ==================== + + describe('completion handling', () => { + it('should render error message when reason is error', async () => { + const body = makeBody({ + errorMessage: 'Model quota exceeded', + reason: 'error', + type: 'completion', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledWith( + 'channel-id', + 'progress-msg-1', + expect.stringContaining('Model quota exceeded'), + ); + }); + + it('should use default error message when errorMessage is not provided', async () => { + const body = makeBody({ + reason: 'error', + type: 'completion', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledWith( + 'channel-id', + 'progress-msg-1', + expect.stringContaining('Agent execution failed'), + ); + }); + + it('should skip when no lastAssistantContent on successful completion', async () => { + const body = makeBody({ + reason: 'completed', + type: 'completion', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).not.toHaveBeenCalled(); + }); + + it('should edit progress message with final reply content', async () => { + const body = makeBody({ + cost: 0.005, + duration: 3000, + lastAssistantContent: 'The answer is 42.', + llmCalls: 2, + reason: 'completed', + toolCalls: 1, + totalTokens: 1500, + type: 'completion', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledWith( + 'channel-id', + 'progress-msg-1', + expect.stringContaining('The answer is 42.'), + ); + }); + + it('should not throw when editing completion message fails', async () => { + mockDiscordEditMessage.mockRejectedValueOnce(new Error('Edit failed')); + + const body = makeBody({ + lastAssistantContent: 'Some response', + reason: 'completed', + type: 'completion', + }); + + await expect(service.handleCallback(body)).resolves.toBeUndefined(); + }); + }); + + // ==================== Message splitting ==================== + + describe('message splitting', () => { + it('should split long Discord messages into multiple chunks', async () => { + // Default Discord limit is 1800 chars (from splitMessage default) + const longContent = 'A'.repeat(3000); + + const body = makeBody({ + lastAssistantContent: longContent, + reason: 'completed', + type: 'completion', + }); + + await service.handleCallback(body); + + // First chunk via editMessage, additional chunks via createMessage + expect(mockDiscordEditMessage).toHaveBeenCalledTimes(1); + expect(mockDiscordCreateMessage).toHaveBeenCalled(); + }); + + it('should use Telegram char limit (4000) for Telegram platform', async () => { + // Content just over default 1800 but under 4000 should NOT split for Telegram + const mediumContent = 'B'.repeat(2500); + + const body = makeTelegramBody({ + lastAssistantContent: mediumContent, + reason: 'completed', + type: 'completion', + }); + + await service.handleCallback(body); + + // Should be single message (4000 limit), so only editMessage + expect(mockTelegramEditMessageText).toHaveBeenCalledTimes(1); + expect(mockTelegramSendMessage).not.toHaveBeenCalled(); + }); + + it('should split Telegram messages that exceed 4000 chars', async () => { + const longContent = 'C'.repeat(6000); + + const body = makeTelegramBody({ + lastAssistantContent: longContent, + reason: 'completed', + type: 'completion', + }); + + await service.handleCallback(body); + + expect(mockTelegramEditMessageText).toHaveBeenCalledTimes(1); + expect(mockTelegramSendMessage).toHaveBeenCalled(); + }); + }); + + // ==================== Eyes reaction removal ==================== + + describe('removeEyesReaction', () => { + it('should remove eyes reaction on completion for Discord', async () => { + const body = makeBody({ + lastAssistantContent: 'Done.', + reason: 'completed', + type: 'completion', + userMessageId: 'user-msg-1', + }); + + await service.handleCallback(body); + + // Discord uses a separate DiscordRestApi instance for reaction removal + expect(mockDiscordRemoveOwnReaction).toHaveBeenCalled(); + }); + + it('should use reactionChannelId when provided for Discord', async () => { + const body = makeBody({ + lastAssistantContent: 'Done.', + reactionChannelId: 'parent-channel-id', + reason: 'completed', + type: 'completion', + userMessageId: 'user-msg-1', + }); + + await service.handleCallback(body); + + expect(mockDiscordRemoveOwnReaction).toHaveBeenCalledWith( + 'parent-channel-id', + 'user-msg-1', + '👀', + ); + }); + + it('should skip reaction removal when no userMessageId', async () => { + const body = makeBody({ + lastAssistantContent: 'Done.', + reason: 'completed', + type: 'completion', + }); + + await service.handleCallback(body); + + // removeReaction should not be called + expect(mockDiscordRemoveOwnReaction).not.toHaveBeenCalled(); + }); + + it('should remove reaction for Telegram using messenger', async () => { + const body = makeTelegramBody({ + lastAssistantContent: 'Done.', + reason: 'completed', + type: 'completion', + userMessageId: 'telegram:chat-456:789', + }); + + await service.handleCallback(body); + + // Telegram uses messenger.removeReaction which calls removeMessageReaction + expect(mockTelegramRemoveMessageReaction).toHaveBeenCalledWith('chat-456', 789); + }); + + it('should not throw when reaction removal fails', async () => { + mockDiscordRemoveOwnReaction.mockRejectedValueOnce(new Error('Reaction not found')); + + const body = makeBody({ + lastAssistantContent: 'Done.', + reason: 'completed', + type: 'completion', + userMessageId: 'user-msg-1', + }); + + await expect(service.handleCallback(body)).resolves.toBeUndefined(); + }); + }); + + // ==================== Topic title summarization ==================== + + describe('topic title summarization', () => { + it('should summarize topic title on successful completion', async () => { + mockFindById.mockResolvedValue({ title: null }); + mockGenerateTopicTitle.mockResolvedValue('Generated Topic Title'); + mockTopicUpdate.mockResolvedValue(undefined); + + const body = makeBody({ + lastAssistantContent: 'Here is the answer.', + reason: 'completed', + topicId: 'topic-1', + type: 'completion', + userId: 'user-1', + userPrompt: 'What is the meaning of life?', + }); + + await service.handleCallback(body); + + // summarizeTopicTitle is fire-and-forget; wait for promises to settle + await vi.waitFor(() => { + expect(mockFindById).toHaveBeenCalledWith('topic-1'); + }); + + await vi.waitFor(() => { + expect(mockGenerateTopicTitle).toHaveBeenCalledWith({ + lastAssistantContent: 'Here is the answer.', + userPrompt: 'What is the meaning of life?', + }); + }); + + await vi.waitFor(() => { + expect(mockTopicUpdate).toHaveBeenCalledWith('topic-1', { + title: 'Generated Topic Title', + }); + }); + }); + + it('should not summarize when topic already has a title', async () => { + mockFindById.mockResolvedValue({ title: 'Existing Title' }); + + const body = makeBody({ + lastAssistantContent: 'Here is the answer.', + reason: 'completed', + topicId: 'topic-1', + type: 'completion', + userId: 'user-1', + userPrompt: 'What is the meaning of life?', + }); + + await service.handleCallback(body); + + await vi.waitFor(() => { + expect(mockFindById).toHaveBeenCalledWith('topic-1'); + }); + + expect(mockGenerateTopicTitle).not.toHaveBeenCalled(); + }); + + it('should skip summarization when reason is error', async () => { + const body = makeBody({ + errorMessage: 'Failed', + lastAssistantContent: 'partial', + reason: 'error', + topicId: 'topic-1', + type: 'completion', + userId: 'user-1', + userPrompt: 'test', + }); + + await service.handleCallback(body); + + // Wait a tick to ensure no async work was started + await new Promise((r) => setTimeout(r, 50)); + expect(mockFindById).not.toHaveBeenCalled(); + }); + + it('should skip summarization when topicId is missing', async () => { + const body = makeBody({ + lastAssistantContent: 'Done.', + reason: 'completed', + type: 'completion', + userId: 'user-1', + userPrompt: 'test', + }); + + await service.handleCallback(body); + + await new Promise((r) => setTimeout(r, 50)); + expect(mockFindById).not.toHaveBeenCalled(); + }); + + it('should skip summarization when userId is missing', async () => { + const body = makeBody({ + lastAssistantContent: 'Done.', + reason: 'completed', + topicId: 'topic-1', + type: 'completion', + userPrompt: 'test', + }); + + await service.handleCallback(body); + + await new Promise((r) => setTimeout(r, 50)); + expect(mockFindById).not.toHaveBeenCalled(); + }); + + it('should update thread name on Discord after generating title', async () => { + mockFindById.mockResolvedValue({ title: null }); + mockGenerateTopicTitle.mockResolvedValue('New Title'); + mockTopicUpdate.mockResolvedValue(undefined); + + const body = makeBody({ + lastAssistantContent: 'Answer.', + platformThreadId: 'discord:guild:channel-id:thread-id', + reason: 'completed', + topicId: 'topic-1', + type: 'completion', + userId: 'user-1', + userPrompt: 'Question?', + }); + + await service.handleCallback(body); + + await vi.waitFor(() => { + expect(mockDiscordUpdateChannelName).toHaveBeenCalledWith('thread-id', 'New Title'); + }); + }); + + it('should not update thread name when generated title is empty', async () => { + mockFindById.mockResolvedValue({ title: null }); + mockGenerateTopicTitle.mockResolvedValue(''); + mockTopicUpdate.mockResolvedValue(undefined); + + const body = makeBody({ + lastAssistantContent: 'Answer.', + platformThreadId: 'discord:guild:channel-id:thread-id', + reason: 'completed', + topicId: 'topic-1', + type: 'completion', + userId: 'user-1', + userPrompt: 'Question?', + }); + + await service.handleCallback(body); + + // Wait for async chain + await new Promise((r) => setTimeout(r, 50)); + expect(mockTopicUpdate).not.toHaveBeenCalled(); + expect(mockDiscordUpdateChannelName).not.toHaveBeenCalled(); + }); + }); + + // ==================== Discord channel ID extraction ==================== + + describe('Discord channel ID extraction', () => { + it('should extract channel ID from 3-part platformThreadId (no thread)', async () => { + const body = makeBody({ + platformThreadId: 'discord:guild:channel-123', + shouldContinue: true, + stepType: 'call_llm', + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledWith( + 'channel-123', + expect.any(String), + expect.any(String), + ); + }); + + it('should extract thread ID (4th part) as channel when thread exists', async () => { + const body = makeBody({ + platformThreadId: 'discord:guild:parent-channel:thread-456', + shouldContinue: true, + stepType: 'call_llm', + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockDiscordEditMessage).toHaveBeenCalledWith( + 'thread-456', + expect.any(String), + expect.any(String), + ); + }); + }); + + // ==================== Telegram chat ID and message ID ==================== + + describe('Telegram message handling', () => { + it('should extract chat ID from platformThreadId', async () => { + const body = makeTelegramBody({ + shouldContinue: true, + stepType: 'call_llm', + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockTelegramEditMessageText).toHaveBeenCalledWith( + 'chat-456', + expect.any(Number), + expect.any(String), + ); + }); + + it('should parse composite message ID for Telegram', async () => { + const body = makeTelegramBody({ + lastAssistantContent: 'Done.', + progressMessageId: 'telegram:chat-456:99', + reason: 'completed', + type: 'completion', + userMessageId: 'telegram:chat-456:100', + }); + + await service.handleCallback(body); + + // editMessageText should receive parsed numeric message ID + expect(mockTelegramEditMessageText).toHaveBeenCalledWith('chat-456', 99, expect.any(String)); + }); + + it('should trigger typing for Telegram steps', async () => { + const body = makeTelegramBody({ + shouldContinue: true, + stepType: 'call_tool', + type: 'step', + }); + + await service.handleCallback(body); + + expect(mockTelegramSendChatAction).toHaveBeenCalledWith('chat-456', 'typing'); + }); + }); + + // ==================== Completion + reaction + summarization flow ==================== + + describe('full completion flow', () => { + it('should execute completion, reaction removal, and topic summarization', async () => { + mockFindById.mockResolvedValue({ title: null }); + mockGenerateTopicTitle.mockResolvedValue('Summary Title'); + mockTopicUpdate.mockResolvedValue(undefined); + + const body = makeBody({ + cost: 0.01, + lastAssistantContent: 'Complete answer.', + reason: 'completed', + topicId: 'topic-1', + type: 'completion', + userId: 'user-1', + userMessageId: 'user-msg-1', + userPrompt: 'Tell me something.', + }); + + await service.handleCallback(body); + + // Completion: edit message + expect(mockDiscordEditMessage).toHaveBeenCalled(); + + // Reaction removal + expect(mockDiscordRemoveOwnReaction).toHaveBeenCalled(); + + // Topic summarization (async) + await vi.waitFor(() => { + expect(mockTopicUpdate).toHaveBeenCalledWith('topic-1', { title: 'Summary Title' }); + }); + }); + + it('should not run reaction removal or summarization for step type', async () => { + const body = makeBody({ + shouldContinue: true, + stepType: 'call_llm', + topicId: 'topic-1', + type: 'step', + userId: 'user-1', + userMessageId: 'user-msg-1', + userPrompt: 'test', + }); + + await service.handleCallback(body); + + expect(mockDiscordRemoveOwnReaction).not.toHaveBeenCalled(); + await new Promise((r) => setTimeout(r, 50)); + expect(mockFindById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/services/toolExecution/__tests__/deviceProxy.test.ts b/src/server/services/toolExecution/__tests__/deviceProxy.test.ts new file mode 100644 index 0000000000..6bea6927c9 --- /dev/null +++ b/src/server/services/toolExecution/__tests__/deviceProxy.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Import after mocks are set up +import { DeviceProxy } from '../deviceProxy'; + +const mockEnv = vi.hoisted(() => ({ + DEVICE_GATEWAY_SERVICE_TOKEN: undefined as string | undefined, + DEVICE_GATEWAY_URL: undefined as string | undefined, +})); + +const mockClient = vi.hoisted(() => ({ + executeToolCall: vi.fn(), + getDeviceSystemInfo: vi.fn(), + queryDeviceList: vi.fn(), + queryDeviceStatus: vi.fn(), +})); + +const MockGatewayHttpClient = vi.hoisted(() => vi.fn(() => mockClient)); + +vi.mock('@/envs/gateway', () => ({ + gatewayEnv: mockEnv, +})); + +vi.mock('@lobechat/device-gateway-client', () => ({ + GatewayHttpClient: MockGatewayHttpClient, +})); + +describe('DeviceProxy', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEnv.DEVICE_GATEWAY_URL = undefined; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = undefined; + }); + + describe('isConfigured', () => { + it('should return false when DEVICE_GATEWAY_URL is not set', () => { + const proxy = new DeviceProxy(); + expect(proxy.isConfigured).toBe(false); + }); + + it('should return true when DEVICE_GATEWAY_URL is set', () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + const proxy = new DeviceProxy(); + expect(proxy.isConfigured).toBe(true); + }); + }); + + describe('queryDeviceStatus', () => { + it('should return offline status when not configured', async () => { + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceStatus('user-1'); + expect(result).toEqual({ deviceCount: 0, online: false }); + }); + + it('should return status from client on success', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + const expected = { deviceCount: 2, online: true }; + mockClient.queryDeviceStatus.mockResolvedValue(expected); + + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceStatus('user-1'); + + expect(result).toEqual(expected); + expect(mockClient.queryDeviceStatus).toHaveBeenCalledWith('user-1'); + }); + + it('should return offline status on error', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + mockClient.queryDeviceStatus.mockRejectedValue(new Error('network error')); + + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceStatus('user-1'); + + expect(result).toEqual({ deviceCount: 0, online: false }); + }); + }); + + describe('queryDeviceList', () => { + it('should return empty array when not configured', async () => { + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceList('user-1'); + expect(result).toEqual([]); + }); + + it('should transform connectedAt to lastSeen and set online: true', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + const connectedAt = '2025-01-15T10:30:00Z'; + mockClient.queryDeviceList.mockResolvedValue([ + { + connectedAt, + deviceId: 'dev-1', + hostname: 'my-laptop', + platform: 'darwin', + }, + { + connectedAt, + deviceId: 'dev-2', + hostname: 'my-desktop', + platform: 'win32', + }, + ]); + + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceList('user-1'); + + expect(result).toEqual([ + { + deviceId: 'dev-1', + hostname: 'my-laptop', + lastSeen: new Date(connectedAt).toISOString(), + online: true, + platform: 'darwin', + }, + { + deviceId: 'dev-2', + hostname: 'my-desktop', + lastSeen: new Date(connectedAt).toISOString(), + online: true, + platform: 'win32', + }, + ]); + expect(mockClient.queryDeviceList).toHaveBeenCalledWith('user-1'); + }); + + it('should return empty array on error', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + mockClient.queryDeviceList.mockRejectedValue(new Error('fail')); + + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceList('user-1'); + + expect(result).toEqual([]); + }); + }); + + describe('queryDeviceSystemInfo', () => { + it('should return undefined when not configured', async () => { + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceSystemInfo('user-1', 'dev-1'); + expect(result).toBeUndefined(); + }); + + it('should return systemInfo on success', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + const systemInfo = { cpuModel: 'Apple M1', os: 'macOS', totalMemory: 16384 }; + mockClient.getDeviceSystemInfo.mockResolvedValue({ success: true, systemInfo }); + + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceSystemInfo('user-1', 'dev-1'); + + expect(result).toEqual(systemInfo); + expect(mockClient.getDeviceSystemInfo).toHaveBeenCalledWith('user-1', 'dev-1'); + }); + + it('should return undefined when result is not successful', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + mockClient.getDeviceSystemInfo.mockResolvedValue({ success: false }); + + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceSystemInfo('user-1', 'dev-1'); + + expect(result).toBeUndefined(); + }); + + it('should return undefined on error', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + mockClient.getDeviceSystemInfo.mockRejectedValue(new Error('timeout')); + + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceSystemInfo('user-1', 'dev-1'); + + expect(result).toBeUndefined(); + }); + }); + + describe('executeToolCall', () => { + const params = { deviceId: 'dev-1', userId: 'user-1' }; + const toolCall = { apiName: 'listFiles', arguments: '{}', identifier: 'file-manager' }; + + it('should return error when not configured', async () => { + const proxy = new DeviceProxy(); + const result = await proxy.executeToolCall(params, toolCall); + + expect(result).toEqual({ + content: 'Device Gateway is not configured', + error: 'GATEWAY_NOT_CONFIGURED', + success: false, + }); + }); + + it('should execute tool call with default timeout', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + const expected = { content: 'file list', success: true }; + mockClient.executeToolCall.mockResolvedValue(expected); + + const proxy = new DeviceProxy(); + const result = await proxy.executeToolCall(params, toolCall); + + expect(result).toEqual(expected); + expect(mockClient.executeToolCall).toHaveBeenCalledWith( + { deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' }, + toolCall, + ); + }); + + it('should use custom timeout', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + mockClient.executeToolCall.mockResolvedValue({ content: 'ok', success: true }); + + const proxy = new DeviceProxy(); + await proxy.executeToolCall(params, toolCall, 60_000); + + expect(mockClient.executeToolCall).toHaveBeenCalledWith( + { deviceId: 'dev-1', timeout: 60_000, userId: 'user-1' }, + toolCall, + ); + }); + + it('should return error result on Error exception', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + mockClient.executeToolCall.mockRejectedValue(new Error('connection refused')); + + const proxy = new DeviceProxy(); + const result = await proxy.executeToolCall(params, toolCall); + + expect(result).toEqual({ + content: 'Device tool call error: connection refused', + error: 'connection refused', + success: false, + }); + }); + + it('should handle non-Error exceptions', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + mockClient.executeToolCall.mockRejectedValue('string error'); + + const proxy = new DeviceProxy(); + const result = await proxy.executeToolCall(params, toolCall); + + expect(result).toEqual({ + content: 'Device tool call error: string error', + error: 'string error', + success: false, + }); + }); + }); + + describe('getClient (lazy initialization)', () => { + it('should return null when URL is missing', async () => { + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceStatus('user-1'); + + expect(result).toEqual({ deviceCount: 0, online: false }); + expect(MockGatewayHttpClient).not.toHaveBeenCalled(); + }); + + it('should return null when token is missing', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + const proxy = new DeviceProxy(); + const result = await proxy.queryDeviceStatus('user-1'); + + expect(result).toEqual({ deviceCount: 0, online: false }); + expect(MockGatewayHttpClient).not.toHaveBeenCalled(); + }); + + it('should create client only once across multiple calls', async () => { + mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com'; + mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token'; + mockClient.queryDeviceStatus.mockResolvedValue({ deviceCount: 1, online: true }); + + const proxy = new DeviceProxy(); + await proxy.queryDeviceStatus('user-1'); + await proxy.queryDeviceStatus('user-2'); + + expect(MockGatewayHttpClient).toHaveBeenCalledTimes(1); + expect(MockGatewayHttpClient).toHaveBeenCalledWith({ + gatewayUrl: 'https://gateway.example.com', + serviceToken: 'token', + }); + }); + }); +}); diff --git a/src/server/services/toolExecution/deviceProxy.ts b/src/server/services/toolExecution/deviceProxy.ts new file mode 100644 index 0000000000..e0fc9f034a --- /dev/null +++ b/src/server/services/toolExecution/deviceProxy.ts @@ -0,0 +1,115 @@ +import { type DeviceAttachment } from '@lobechat/builtin-tool-remote-device'; +import { + type DeviceStatusResult, + type DeviceSystemInfo, + GatewayHttpClient, +} from '@lobechat/device-gateway-client'; +import debug from 'debug'; + +import { gatewayEnv } from '@/envs/gateway'; + +const log = debug('lobe-server:device-proxy'); + +export type { DeviceAttachment, DeviceStatusResult, DeviceSystemInfo }; + +export class DeviceProxy { + private client: GatewayHttpClient | null = null; + + get isConfigured(): boolean { + return !!gatewayEnv.DEVICE_GATEWAY_URL; + } + + async queryDeviceStatus(userId: string): Promise { + const client = this.getClient(); + if (!client) return { deviceCount: 0, online: false }; + + try { + return await client.queryDeviceStatus(userId); + } catch { + return { deviceCount: 0, online: false }; + } + } + + async queryDeviceList(userId: string): Promise { + const client = this.getClient(); + if (!client) return []; + + try { + const devices = await client.queryDeviceList(userId); + // Transform gateway format to runtime-expected format + // All devices from gateway have active WebSocket connections, so they're online + return devices.map((d) => ({ + deviceId: d.deviceId, + hostname: d.hostname, + lastSeen: new Date(d.connectedAt).toISOString(), + online: true, + platform: d.platform, + })); + } catch { + return []; + } + } + + async queryDeviceSystemInfo( + userId: string, + deviceId: string, + ): Promise { + const client = this.getClient(); + if (!client) return undefined; + + try { + const result = await client.getDeviceSystemInfo(userId, deviceId); + return result.success ? result.systemInfo : undefined; + } catch { + log('queryDeviceSystemInfo: failed for userId=%s, deviceId=%s', userId, deviceId); + return undefined; + } + } + + async executeToolCall( + params: { deviceId: string; userId: string }, + toolCall: { apiName: string; arguments: string; identifier: string }, + timeout = 30_000, + ): Promise<{ content: string; error?: string; success: boolean }> { + const client = this.getClient(); + if (!client) { + return { + content: 'Device Gateway is not configured', + error: 'GATEWAY_NOT_CONFIGURED', + success: false, + }; + } + + log( + 'executeToolCall: userId=%s, deviceId=%s, tool=%s/%s', + params.userId, + params.deviceId, + toolCall.identifier, + toolCall.apiName, + ); + + try { + return await client.executeToolCall( + { deviceId: params.deviceId, timeout, userId: params.userId }, + toolCall, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log('executeToolCall: error — %s', message); + return { content: `Device tool call error: ${message}`, error: message, success: false }; + } + } + + private getClient(): GatewayHttpClient | null { + const url = gatewayEnv.DEVICE_GATEWAY_URL; + const token = gatewayEnv.DEVICE_GATEWAY_SERVICE_TOKEN; + if (!url || !token) return null; + + if (!this.client) { + this.client = new GatewayHttpClient({ gatewayUrl: url, serviceToken: token }); + } + return this.client; + } +} + +export const deviceProxy = new DeviceProxy(); diff --git a/src/server/services/toolExecution/serverRuntimes/__tests__/localSystem.test.ts b/src/server/services/toolExecution/serverRuntimes/__tests__/localSystem.test.ts new file mode 100644 index 0000000000..3a28616112 --- /dev/null +++ b/src/server/services/toolExecution/serverRuntimes/__tests__/localSystem.test.ts @@ -0,0 +1,110 @@ +import { LocalSystemIdentifier, LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; +import { describe, expect, it, vi } from 'vitest'; + +import { type ToolExecutionContext } from '../../types'; + +// Mock deviceProxy +const mockExecuteToolCall = vi.fn(); +vi.mock('../../deviceProxy', () => ({ + deviceProxy: { + executeToolCall: (...args: any[]) => mockExecuteToolCall(...args), + }, +})); + +// Import after mock setup +const { localSystemRuntime } = await import('../localSystem'); + +describe('localSystemRuntime', () => { + it('should have the correct identifier', () => { + expect(localSystemRuntime.identifier).toBe(LocalSystemIdentifier); + }); + + describe('factory', () => { + it('should throw when userId is missing', () => { + const context: ToolExecutionContext = { + activeDeviceId: 'device-1', + toolManifestMap: {}, + }; + + expect(() => localSystemRuntime.factory(context)).toThrow( + 'userId is required for Local System device proxy execution', + ); + }); + + it('should throw when activeDeviceId is missing', () => { + const context: ToolExecutionContext = { + toolManifestMap: {}, + userId: 'user-1', + }; + + expect(() => localSystemRuntime.factory(context)).toThrow( + 'activeDeviceId is required for Local System device proxy execution', + ); + }); + + it('should create a proxy with a function for each API in LocalSystemManifest', () => { + const context: ToolExecutionContext = { + activeDeviceId: 'device-1', + toolManifestMap: {}, + userId: 'user-1', + }; + + const proxy = localSystemRuntime.factory(context); + + for (const api of LocalSystemManifest.api) { + expect(proxy[api.name]).toBeDefined(); + expect(typeof proxy[api.name]).toBe('function'); + } + }); + + it('should call deviceProxy.executeToolCall with correct arguments when a proxy function is invoked', async () => { + const context: ToolExecutionContext = { + activeDeviceId: 'device-1', + toolManifestMap: {}, + userId: 'user-1', + }; + + const expectedResult = { content: 'ok', success: true }; + mockExecuteToolCall.mockResolvedValue(expectedResult); + + const proxy = localSystemRuntime.factory(context); + const apiName = LocalSystemManifest.api[0].name; + const args = { path: '/tmp/test' }; + + const result = await proxy[apiName](args); + + expect(mockExecuteToolCall).toHaveBeenCalledWith( + { deviceId: 'device-1', userId: 'user-1' }, + { + apiName, + arguments: JSON.stringify(args), + identifier: LocalSystemIdentifier, + }, + ); + expect(result).toEqual(expectedResult); + }); + + it('should JSON.stringify the arguments passed to the proxy function', async () => { + const context: ToolExecutionContext = { + activeDeviceId: 'device-2', + toolManifestMap: {}, + userId: 'user-2', + }; + + mockExecuteToolCall.mockResolvedValue({ content: '', success: true }); + + const proxy = localSystemRuntime.factory(context); + const apiName = LocalSystemManifest.api[0].name; + const complexArgs = { keywords: 'test', fileTypes: ['txt', 'md'], limit: 10 }; + + await proxy[apiName](complexArgs); + + expect(mockExecuteToolCall).toHaveBeenCalledWith( + { deviceId: 'device-2', userId: 'user-2' }, + expect.objectContaining({ + arguments: JSON.stringify(complexArgs), + }), + ); + }); + }); +}); diff --git a/src/server/services/toolExecution/serverRuntimes/__tests__/remoteDevice.test.ts b/src/server/services/toolExecution/serverRuntimes/__tests__/remoteDevice.test.ts new file mode 100644 index 0000000000..9bafd126ae --- /dev/null +++ b/src/server/services/toolExecution/serverRuntimes/__tests__/remoteDevice.test.ts @@ -0,0 +1,73 @@ +import { + RemoteDeviceExecutionRuntime, + RemoteDeviceIdentifier, +} from '@lobechat/builtin-tool-remote-device'; +import { describe, expect, it, vi } from 'vitest'; + +import { type ToolExecutionContext } from '../../types'; + +// Mock deviceProxy +const mockQueryDeviceList = vi.fn(); +vi.mock('../../deviceProxy', () => ({ + deviceProxy: { + queryDeviceList: (...args: any[]) => mockQueryDeviceList(...args), + }, +})); + +// Import after mock setup +const { remoteDeviceRuntime } = await import('../remoteDevice'); + +describe('remoteDeviceRuntime', () => { + it('should have the correct identifier', () => { + expect(remoteDeviceRuntime.identifier).toBe(RemoteDeviceIdentifier); + }); + + describe('factory', () => { + it('should throw when userId is missing', () => { + const context: ToolExecutionContext = { + toolManifestMap: {}, + }; + + expect(() => remoteDeviceRuntime.factory(context)).toThrow( + 'userId is required for Remote Device execution', + ); + }); + + it('should return a RemoteDeviceExecutionRuntime instance', () => { + const context: ToolExecutionContext = { + toolManifestMap: {}, + userId: 'user-1', + }; + + const runtime = remoteDeviceRuntime.factory(context); + + expect(runtime).toBeInstanceOf(RemoteDeviceExecutionRuntime); + }); + + it('should pass queryDeviceList that calls deviceProxy with the userId', async () => { + const context: ToolExecutionContext = { + toolManifestMap: {}, + userId: 'user-1', + }; + + const mockDevices = [ + { + deviceId: 'd1', + hostname: 'host1', + lastSeen: '2024-01-01', + online: true, + platform: 'darwin', + }, + ]; + mockQueryDeviceList.mockResolvedValue(mockDevices); + + const runtime = remoteDeviceRuntime.factory(context) as RemoteDeviceExecutionRuntime; + + // Call listOnlineDevices which internally calls queryDeviceList + const result = await runtime.listOnlineDevices(); + + expect(mockQueryDeviceList).toHaveBeenCalledWith('user-1'); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/server/services/toolExecution/serverRuntimes/index.ts b/src/server/services/toolExecution/serverRuntimes/index.ts index 545a091863..1fe210aada 100644 --- a/src/server/services/toolExecution/serverRuntimes/index.ts +++ b/src/server/services/toolExecution/serverRuntimes/index.ts @@ -9,8 +9,10 @@ import { type ToolExecutionContext } from '../types'; import { calculatorRuntime } from './calculator'; import { cloudSandboxRuntime } from './cloudSandbox'; +import { localSystemRuntime } from './localSystem'; import { memoryRuntime } from './memory'; import { notebookRuntime } from './notebook'; +import { remoteDeviceRuntime } from './remoteDevice'; import { skillsRuntime } from './skills'; import { skillStoreRuntime } from './skillStore'; import { toolsActivatorRuntime } from './tools'; @@ -41,6 +43,8 @@ registerRuntimes([ skillsRuntime, memoryRuntime, toolsActivatorRuntime, + localSystemRuntime, + remoteDeviceRuntime, ]); // ==================== Registry API ==================== diff --git a/src/server/services/toolExecution/serverRuntimes/localSystem.ts b/src/server/services/toolExecution/serverRuntimes/localSystem.ts new file mode 100644 index 0000000000..d688b9d985 --- /dev/null +++ b/src/server/services/toolExecution/serverRuntimes/localSystem.ts @@ -0,0 +1,33 @@ +import { LocalSystemIdentifier, LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; + +import { deviceProxy } from '../deviceProxy'; +import { type ServerRuntimeRegistration } from './types'; + +export const localSystemRuntime: ServerRuntimeRegistration = { + factory: (context) => { + if (!context.userId) { + throw new Error('userId is required for Local System device proxy execution'); + } + if (!context.activeDeviceId) { + throw new Error('activeDeviceId is required for Local System device proxy execution'); + } + + const proxy: Record Promise> = {}; + + for (const api of LocalSystemManifest.api) { + proxy[api.name] = async (args: any) => { + return deviceProxy.executeToolCall( + { deviceId: context.activeDeviceId!, userId: context.userId! }, + { + apiName: api.name, + arguments: JSON.stringify(args), + identifier: LocalSystemIdentifier, + }, + ); + }; + } + + return proxy; + }, + identifier: LocalSystemIdentifier, +}; diff --git a/src/server/services/toolExecution/serverRuntimes/remoteDevice.ts b/src/server/services/toolExecution/serverRuntimes/remoteDevice.ts new file mode 100644 index 0000000000..961d71d284 --- /dev/null +++ b/src/server/services/toolExecution/serverRuntimes/remoteDevice.ts @@ -0,0 +1,22 @@ +import { + RemoteDeviceExecutionRuntime, + RemoteDeviceIdentifier, +} from '@lobechat/builtin-tool-remote-device'; + +import { deviceProxy } from '../deviceProxy'; +import { type ServerRuntimeRegistration } from './types'; + +export const remoteDeviceRuntime: ServerRuntimeRegistration = { + factory: (context) => { + if (!context.userId) { + throw new Error('userId is required for Remote Device execution'); + } + + const userId = context.userId; + + return new RemoteDeviceExecutionRuntime({ + queryDeviceList: () => deviceProxy.queryDeviceList(userId), + }); + }, + identifier: RemoteDeviceIdentifier, +}; diff --git a/src/server/services/toolExecution/types.ts b/src/server/services/toolExecution/types.ts index b8f2f3f821..98378bb0a5 100644 --- a/src/server/services/toolExecution/types.ts +++ b/src/server/services/toolExecution/types.ts @@ -3,6 +3,8 @@ import { type LobeChatDatabase } from '@lobechat/database'; import { type ChatToolPayload } from '@lobechat/types'; export interface ToolExecutionContext { + /** Target device ID for device proxy tool calls */ + activeDeviceId?: string; /** Memory tool permission from agent chat config */ memoryToolPermission?: 'read-only' | 'read-write'; /** Server database for LobeHub Skills execution */ diff --git a/src/services/chat/chat.test.ts b/src/services/chat/chat.test.ts index c623230aff..2b305b9259 100644 --- a/src/services/chat/chat.test.ts +++ b/src/services/chat/chat.test.ts @@ -15,11 +15,12 @@ import { type ResolvedAgentConfig } from './mecha'; // Helper to compute expected date content from SystemDateProvider const getCurrentDateContent = () => { + const tz = 'UTC'; const today = new Date(); - const year = today.getFullYear(); - const month = String(today.getMonth() + 1).padStart(2, '0'); - const day = String(today.getDate()).padStart(2, '0'); - return `Current date: ${year}-${month}-${day}`; + const year = today.toLocaleString('en-US', { timeZone: tz, year: 'numeric' }); + const month = today.toLocaleString('en-US', { month: '2-digit', timeZone: tz }); + const day = today.toLocaleString('en-US', { day: '2-digit', timeZone: tz }); + return `Current date: ${year}-${month}-${day} (${tz})`; }; /** diff --git a/src/services/chat/mecha/contextEngineering.test.ts b/src/services/chat/mecha/contextEngineering.test.ts index 6393ef5317..8f8213d2fe 100644 --- a/src/services/chat/mecha/contextEngineering.test.ts +++ b/src/services/chat/mecha/contextEngineering.test.ts @@ -39,11 +39,12 @@ afterEach(() => { // Helper to compute expected date content from SystemDateProvider const getCurrentDateContent = () => { + const tz = 'UTC'; const today = new Date(); - const year = today.getFullYear(); - const month = String(today.getMonth() + 1).padStart(2, '0'); - const day = String(today.getDate()).padStart(2, '0'); - return `Current date: ${year}-${month}-${day}`; + const year = today.toLocaleString('en-US', { timeZone: tz, year: 'numeric' }); + const month = today.toLocaleString('en-US', { month: '2-digit', timeZone: tz }); + const day = today.toLocaleString('en-US', { day: '2-digit', timeZone: tz }); + return `Current date: ${year}-${month}-${day} (${tz})`; }; describe('contextEngineering', () => { diff --git a/src/store/chat/slices/aiChat/actions/streamingExecutor.ts b/src/store/chat/slices/aiChat/actions/streamingExecutor.ts index 4b6987aff6..060ef9ddd2 100644 --- a/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +++ b/src/store/chat/slices/aiChat/actions/streamingExecutor.ts @@ -127,7 +127,9 @@ export class StreamingExecutorActionImpl { const { agentConfig: agentConfigData, plugins: pluginIds } = agentConfig; if (!agentConfigData || !agentConfigData.model) { - throw new Error(`[internal_createAgentState] Agent config not found or incomplete for agentId: ${effectiveAgentId}, scope: ${scope}`); + throw new Error( + `[internal_createAgentState] Agent config not found or incomplete for agentId: ${effectiveAgentId}, scope: ${scope}`, + ); } log( @@ -206,6 +208,12 @@ export class StreamingExecutorActionImpl { }, modelRuntimeConfig, operationId: operationId ?? agentId, + operationToolSet: { + enabledToolIds, + manifestMap: toolManifestMap, + sourceMap: {}, + tools: toolsDetailed.tools ?? [], + }, toolManifestMap, userInterventionConfig, });