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, });