mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-18 13:25:45 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6deb8650cc |
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles, cx } from 'antd-style';
|
||||
import { CheckCircle2, CircleAlert } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { VentCategory, VentParams, VentState } from '../../../types';
|
||||
|
||||
const CATEGORY_LABEL_KEYS = {
|
||||
doc_conflict: 'builtins.lobe-agent.apiName.vent.category.doc_conflict',
|
||||
env_limitation: 'builtins.lobe-agent.apiName.vent.category.env_limitation',
|
||||
missing_tool: 'builtins.lobe-agent.apiName.vent.category.missing_tool',
|
||||
other: 'builtins.lobe-agent.apiName.vent.category.other',
|
||||
platform_bug: 'builtins.lobe-agent.apiName.vent.category.platform_bug',
|
||||
schema_mismatch: 'builtins.lobe-agent.apiName.vent.category.schema_mismatch',
|
||||
} as const satisfies Record<VentCategory, string>;
|
||||
|
||||
const getTitleKey = (category?: VentCategory) =>
|
||||
category ? CATEGORY_LABEL_KEYS[category] : ('builtins.lobe-agent.apiName.vent' as const);
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
iconRecorded: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorSuccess};
|
||||
`,
|
||||
iconRejected: css`
|
||||
flex-shrink: 0;
|
||||
color: ${cssVar.colorWarning};
|
||||
`,
|
||||
meta: css`
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
`,
|
||||
summary: css`
|
||||
overflow: hidden;
|
||||
|
||||
min-width: 0;
|
||||
max-width: 320px;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const VentInspector = memo<BuiltinInspectorProps<VentParams, VentState>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const data = args ?? partialArgs;
|
||||
const summary = data?.summary;
|
||||
const hasContext = Boolean(summary || data?.category);
|
||||
const title = t(getTitleKey(data?.category));
|
||||
|
||||
if (isArgumentsStreaming && !hasContext) {
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSettled = !isArgumentsStreaming && !isLoading && !!pluginState;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ flexWrap: 'wrap', gap: 4 }}
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{title}</span>
|
||||
{summary && (
|
||||
<span className={cx(highlightTextStyles.primary, styles.summary)}>{summary}</span>
|
||||
)}
|
||||
{isSettled &&
|
||||
pluginState &&
|
||||
(pluginState.recorded ? (
|
||||
<Icon className={styles.iconRecorded} icon={CheckCircle2} size={14} />
|
||||
) : (
|
||||
<>
|
||||
<Icon className={styles.iconRejected} icon={CircleAlert} size={14} />
|
||||
<span className={styles.meta}>{t('builtins.lobe-agent.apiName.vent.rejected')}</span>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
VentInspector.displayName = 'VentInspector';
|
||||
|
||||
export default VentInspector;
|
||||
@@ -9,6 +9,7 @@ import { CreatePlanInspector } from './CreatePlan';
|
||||
import { CreateTodosInspector } from './CreateTodos';
|
||||
import { UpdatePlanInspector } from './UpdatePlan';
|
||||
import { UpdateTodosInspector } from './UpdateTodos';
|
||||
import { VentInspector } from './Vent';
|
||||
|
||||
/**
|
||||
* Lobe Agent Inspector Components Registry
|
||||
@@ -25,4 +26,5 @@ export const LobeAgentInspectors: Record<string, BuiltinInspector> = {
|
||||
[LobeAgentApiName.createTodos]: CreateTodosInspector as BuiltinInspector,
|
||||
[LobeAgentApiName.updatePlan]: UpdatePlanInspector as BuiltinInspector,
|
||||
[LobeAgentApiName.updateTodos]: UpdateTodosInspector as BuiltinInspector,
|
||||
[LobeAgentApiName.vent]: VentInspector as BuiltinInspector,
|
||||
};
|
||||
|
||||
@@ -14,8 +14,10 @@ import type {
|
||||
CreateTodosParams,
|
||||
UpdatePlanParams,
|
||||
UpdateTodosParams,
|
||||
VentParams,
|
||||
VentState,
|
||||
} from '../../types';
|
||||
import { LobeAgentApiName } from '../../types';
|
||||
import { LobeAgentApiName, VENT_CATEGORIES, VENT_SEVERITIES } from '../../types';
|
||||
import type { VisualFileItem } from '../../visualMedia';
|
||||
import {
|
||||
buildAnalyzeVisualMediaContent,
|
||||
@@ -161,6 +163,49 @@ class LobeAgentExecutor extends BaseExecutor<typeof LobeAgentApiName> {
|
||||
clearTodos = (params: ClearTodosParams, ctx: BuiltinToolContext): Promise<BuiltinToolResult> =>
|
||||
this.planRuntime.clearTodos(params, toPlanRuntimeContext(ctx));
|
||||
|
||||
// ==================== Vent ====================
|
||||
|
||||
/**
|
||||
* Privately flag platform friction. The report's durable record is the
|
||||
* persisted tool-call message itself; this just validates the input and
|
||||
* surfaces a settled state for the inspector. Server-side execution
|
||||
* additionally emits a live observability log.
|
||||
*/
|
||||
vent = async (params: VentParams, ctx: BuiltinToolContext): Promise<BuiltinToolResult> => {
|
||||
if (!VENT_CATEGORIES.includes(params?.category)) {
|
||||
const message = 'vent requires a valid category.';
|
||||
return {
|
||||
content: message,
|
||||
error: { message, type: 'InvalidArguments' },
|
||||
state: { recorded: false, reason: 'invalid_category' } satisfies VentState,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!VENT_SEVERITIES.includes(params?.severity)) {
|
||||
const message = 'vent requires a valid severity.';
|
||||
return {
|
||||
content: message,
|
||||
error: { message, type: 'InvalidArguments' },
|
||||
state: { recorded: false, reason: 'invalid_severity' } satisfies VentState,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const ventId = ctx.toolCallId ? `vent:${ctx.toolCallId}` : null;
|
||||
|
||||
return {
|
||||
content: 'Flagged platform friction for the builders.',
|
||||
state: {
|
||||
category: params.category,
|
||||
recorded: true,
|
||||
severity: params.severity,
|
||||
ventId,
|
||||
} satisfies VentState,
|
||||
success: true,
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== Visual ====================
|
||||
|
||||
analyzeVisualMedia = async (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { LobeAgentManifest } from './manifest';
|
||||
import { LobeAgentApiName } from './types';
|
||||
|
||||
describe('LobeAgentManifest', () => {
|
||||
it('should keep the package metadata generic for future Lobe Agent capabilities', () => {
|
||||
@@ -42,4 +43,22 @@ describe('LobeAgentManifest', () => {
|
||||
expect(parameters).not.toHaveProperty('allOf');
|
||||
expect(parameters).not.toHaveProperty('anyOf');
|
||||
});
|
||||
|
||||
it('should expose a restrained vent API for reporting platform friction', () => {
|
||||
const ventApi = LobeAgentManifest.api.find((api) => api.name === LobeAgentApiName.vent);
|
||||
|
||||
expect(ventApi).toBeDefined();
|
||||
expect(ventApi!.parameters.required).toEqual(['category', 'severity', 'summary', 'details']);
|
||||
expect(Object.keys(ventApi!.parameters.properties)).toEqual([
|
||||
'category',
|
||||
'severity',
|
||||
'summary',
|
||||
'details',
|
||||
'attempts',
|
||||
'toolName',
|
||||
'evidenceRefs',
|
||||
]);
|
||||
expect(ventApi!.description).toContain('at most one vent per task');
|
||||
expect(LobeAgentManifest.systemRole).toContain('<vent>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,13 @@ import type { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
import { isDesktop } from './const';
|
||||
import { systemPrompt } from './systemRole';
|
||||
import { LobeAgentApiName, LobeAgentIdentifier } from './types';
|
||||
import {
|
||||
LobeAgentApiName,
|
||||
LobeAgentIdentifier,
|
||||
VENT_CATEGORIES,
|
||||
VENT_EVIDENCE_REF_TYPES,
|
||||
VENT_SEVERITIES,
|
||||
} from './types';
|
||||
|
||||
export const LobeAgentManifest: BuiltinToolManifest = {
|
||||
api: [
|
||||
@@ -264,6 +270,74 @@ export const LobeAgentManifest: BuiltinToolManifest = {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== Vent ====================
|
||||
{
|
||||
description:
|
||||
'Privately report friction in your own working conditions to the platform builders when you are genuinely blocked — a missing tool, a parameter/schema mismatch, conflicting or wrong docs, anomalous platform behavior, or an environment limit causing repeated failure. Not user-facing; it only records the report and does not fix anything. Use sparingly: at most one vent per task, only for the single worst blocker.',
|
||||
name: LobeAgentApiName.vent,
|
||||
parameters: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
category: {
|
||||
description:
|
||||
'Friction category: missing_tool, schema_mismatch, doc_conflict, platform_bug, env_limitation, or other.',
|
||||
enum: [...VENT_CATEGORIES],
|
||||
type: 'string',
|
||||
},
|
||||
severity: {
|
||||
description:
|
||||
'How badly it blocked the task: high = could not complete, medium = forced a costly workaround, low = friction but recovered.',
|
||||
enum: [...VENT_SEVERITIES],
|
||||
type: 'string',
|
||||
},
|
||||
summary: {
|
||||
description:
|
||||
'One short sentence naming the specific friction. Name the tool/surface if one is at fault.',
|
||||
type: 'string',
|
||||
},
|
||||
details: {
|
||||
description:
|
||||
'What you tried, what you expected, what actually happened, and why it blocked you. Specific enough for an engineer to reproduce or fix.',
|
||||
type: 'string',
|
||||
},
|
||||
attempts: {
|
||||
description: 'How many times you hit this wall before venting, when countable.',
|
||||
minimum: 1,
|
||||
type: 'integer',
|
||||
},
|
||||
toolName: {
|
||||
description:
|
||||
'Exact tool/API/surface involved, when one specific component is at fault.',
|
||||
type: 'string',
|
||||
},
|
||||
evidenceRefs: {
|
||||
description:
|
||||
'Optional stable references that ground the report. Prefer tool_call, message, operation, topic, or task refs.',
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
id: { description: 'Stable evidence identifier.', type: 'string' },
|
||||
summary: {
|
||||
description: 'Optional short note explaining why this evidence matters.',
|
||||
type: 'string',
|
||||
},
|
||||
type: {
|
||||
description: 'Evidence object type.',
|
||||
enum: [...VENT_EVIDENCE_REF_TYPES],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['id', 'type'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
},
|
||||
required: ['category', 'severity', 'summary', 'details'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
],
|
||||
identifier: LobeAgentIdentifier,
|
||||
meta: {
|
||||
|
||||
@@ -213,7 +213,20 @@ Use it only for refs/URLs you cannot inspect directly, or when the active model
|
||||
</visual_analysis>
|
||||
`;
|
||||
|
||||
const ventSection = `
|
||||
<vent>
|
||||
\`vent\` is a private side channel for telling the people who built this platform that something about your *own working conditions* got in the way: a missing tool, a parameter/schema that does not match the docs or actual behavior, conflicting or wrong documentation, anomalous platform behavior, or an environment limitation that made you fail repeatedly.
|
||||
|
||||
- It is NOT shown to the user, is NOT an answer, apology, or progress update, and does NOT fix anything by itself. Recording a vent never changes what you do next — continue the task normally after venting.
|
||||
- Be reluctant, not eager. Most tasks run fine and need no vent. Vent only when you are *genuinely blocked or clearly frustrated* by the platform itself — typically after the friction has actually cost you (a failed tool call, a retry that hit the same wall, a doc that contradicted reality).
|
||||
- Emit at most ONE vent per task, only for the single worst blocker. Do not vent about your own mistakes, a hard user request, normal model limitations, or things you simply chose not to do. Never put secrets or sensitive user data in a vent.
|
||||
- **category**: missing_tool (no tool could do what was needed) · schema_mismatch (params/schema disagreed with docs or behavior) · doc_conflict (docs/instructions wrong, contradictory, or missing) · platform_bug (a surface errored or behaved anomalously, not your input) · env_limitation (sandbox/network/timeout/resource limit caused repeated failure) · other.
|
||||
- **severity**: high = could not complete, medium = forced a costly workaround, low = friction but recovered. **details**: what you tried, expected, what happened, and why it blocked you — specific enough to reproduce or fix.
|
||||
</vent>
|
||||
`;
|
||||
|
||||
export const systemPrompt = `Use Lobe Agent capabilities only when the active model needs built-in assistance. Prefer the active model's native capabilities whenever they are sufficient. Follow each tool's description and schema, and use tool results to answer the user directly.
|
||||
${visualAnalysisSection}
|
||||
${planTodoSection}
|
||||
${subAgentSection}`;
|
||||
${subAgentSection}
|
||||
${ventSection}`;
|
||||
|
||||
@@ -9,10 +9,95 @@ export const LobeAgentApiName = {
|
||||
createTodos: 'createTodos',
|
||||
updatePlan: 'updatePlan',
|
||||
updateTodos: 'updateTodos',
|
||||
vent: 'vent',
|
||||
} as const;
|
||||
|
||||
export type LobeAgentApiNameType = (typeof LobeAgentApiName)[keyof typeof LobeAgentApiName];
|
||||
|
||||
// ==================== Vent ====================
|
||||
|
||||
/**
|
||||
* Friction categories an agent may vent about. These describe blockers in the
|
||||
* agent's own working conditions, reported back to the platform builders — not
|
||||
* user-facing answers.
|
||||
*/
|
||||
export const VENT_CATEGORIES = [
|
||||
'missing_tool',
|
||||
'schema_mismatch',
|
||||
'doc_conflict',
|
||||
'platform_bug',
|
||||
'env_limitation',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
/** Severity describing how badly the friction blocked the task. */
|
||||
export const VENT_SEVERITIES = ['low', 'medium', 'high'] as const;
|
||||
|
||||
/** Evidence reference type accepted alongside a vent. */
|
||||
export const VENT_EVIDENCE_REF_TYPES = [
|
||||
'tool_call',
|
||||
'message',
|
||||
'operation',
|
||||
'topic',
|
||||
'task',
|
||||
'source',
|
||||
] as const;
|
||||
|
||||
/** Friction category reported by a running agent. */
|
||||
export type VentCategory = (typeof VENT_CATEGORIES)[number];
|
||||
|
||||
/** Severity assigned by the running agent to one vent. */
|
||||
export type VentSeverity = (typeof VENT_SEVERITIES)[number];
|
||||
|
||||
/** Evidence reference type accepted alongside a vent. */
|
||||
export type VentEvidenceRefType = (typeof VENT_EVIDENCE_REF_TYPES)[number];
|
||||
|
||||
/** Optional reference that grounds one vent report. */
|
||||
export interface VentEvidenceRef {
|
||||
/** Stable evidence identifier in its source domain. */
|
||||
id: string;
|
||||
/** Optional short note explaining why this evidence matters. */
|
||||
summary?: string;
|
||||
/** Evidence object type. */
|
||||
type: VentEvidenceRefType;
|
||||
}
|
||||
|
||||
/** Parameters for the vent API. */
|
||||
export interface VentParams {
|
||||
/** How many times the agent failed at this before venting. */
|
||||
attempts?: number;
|
||||
/** Friction category the vent is about. */
|
||||
category: VentCategory;
|
||||
/** What happened, what was expected, and what is blocked. */
|
||||
details: string;
|
||||
/** Evidence references that ground the report. */
|
||||
evidenceRefs?: VentEvidenceRef[];
|
||||
/** Severity describing how badly the friction blocked the task. */
|
||||
severity: VentSeverity;
|
||||
/** One-line summary of the friction. */
|
||||
summary: string;
|
||||
/** Tool / API / surface involved, when one specific component is to blame. */
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export type VentRejectionReason = 'invalid_category' | 'invalid_severity' | 'rate_limited';
|
||||
|
||||
export type VentStateReason = VentRejectionReason | 'missing_context' | 'runtime_error' | null;
|
||||
|
||||
/** State persisted on the vent tool message for inspector display. */
|
||||
export interface VentState {
|
||||
/** Friction category for inspector display. */
|
||||
category?: VentCategory;
|
||||
/** Rejection or runtime reason. */
|
||||
reason?: VentStateReason;
|
||||
/** Whether the vent crossed the recording boundary. */
|
||||
recorded: boolean;
|
||||
/** Severity for inspector display. */
|
||||
severity?: VentSeverity;
|
||||
/** Stable vent id for recorded reports. */
|
||||
ventId?: null | string;
|
||||
}
|
||||
|
||||
export interface AnalyzeVisualMediaParams {
|
||||
question: string;
|
||||
refs?: string[];
|
||||
|
||||
@@ -158,6 +158,14 @@ export default {
|
||||
'builtins.lobe-agent.apiName.updatePlan.completed': 'Completed',
|
||||
'builtins.lobe-agent.apiName.updatePlan.modified': 'Modified',
|
||||
'builtins.lobe-agent.apiName.updateTodos': 'Update todos',
|
||||
'builtins.lobe-agent.apiName.vent': 'Flag platform friction',
|
||||
'builtins.lobe-agent.apiName.vent.category.doc_conflict': 'Docs conflict',
|
||||
'builtins.lobe-agent.apiName.vent.category.env_limitation': 'Environment limit',
|
||||
'builtins.lobe-agent.apiName.vent.category.missing_tool': 'Missing tool',
|
||||
'builtins.lobe-agent.apiName.vent.category.other': 'Platform friction',
|
||||
'builtins.lobe-agent.apiName.vent.category.platform_bug': 'Platform bug',
|
||||
'builtins.lobe-agent.apiName.vent.category.schema_mismatch': 'Schema mismatch',
|
||||
'builtins.lobe-agent.apiName.vent.rejected': 'Not recorded',
|
||||
'builtins.lobe-knowledge-base.apiName.readKnowledge': 'Read Library content',
|
||||
'builtins.lobe-knowledge-base.apiName.searchKnowledgeBase': 'Search Library',
|
||||
'builtins.lobe-knowledge-base.inspector.andMoreFiles': 'and {{count}} more',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { VisualFileItem, VisualSourceMessage } from '@lobechat/builtin-tool-lobe-agent';
|
||||
import type {
|
||||
VentParams,
|
||||
VentState,
|
||||
VisualFileItem,
|
||||
VisualSourceMessage,
|
||||
} from '@lobechat/builtin-tool-lobe-agent';
|
||||
import {
|
||||
buildAnalyzeVisualMediaContent,
|
||||
createUrlVisualFileItems,
|
||||
@@ -16,15 +21,23 @@ import type { ChatStreamPayload } from '@lobechat/model-runtime';
|
||||
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
|
||||
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
|
||||
import { RequestTrigger } from '@lobechat/types';
|
||||
import { nanoid } from '@lobechat/utils';
|
||||
|
||||
import { MessageModel } from '@/database/models/message';
|
||||
import { toolsEnv } from '@/envs/tools';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { FileService } from '@/server/services/file';
|
||||
import { createVentService } from '@/server/services/vent';
|
||||
|
||||
import { createServerPlanRuntimeService } from './lobeAgentPlan';
|
||||
import type { ServerRuntimeRegistration } from './types';
|
||||
|
||||
// The durable record of a vent is the persisted vent tool-call message itself.
|
||||
// This shared service only validates input, assigns a stable id, and enforces
|
||||
// per-scope rate limits. Module-scoped so the rate-limit state survives across
|
||||
// per-request runtime instances.
|
||||
const sharedVentService = createVentService({ nextToolCallId: () => nanoid() });
|
||||
|
||||
interface AnalyzeVisualMediaParams {
|
||||
question: string;
|
||||
refs?: string[];
|
||||
@@ -105,6 +118,53 @@ class LobeAgentExecutionRuntime {
|
||||
clearTodos = (params: any) =>
|
||||
this.planRuntime.clearTodos(params, { messageId: this.messageId, topicId: this.topicId });
|
||||
|
||||
// ==================== Vent ====================
|
||||
|
||||
vent = async (
|
||||
params: VentParams,
|
||||
context: { operationId?: string; toolCallId?: string } = {},
|
||||
): Promise<BuiltinServerRuntimeOutput> => {
|
||||
if (!this.agentId || !this.userId || !this.topicId) {
|
||||
const state: VentState = { recorded: false, reason: 'missing_context' };
|
||||
return { content: JSON.stringify(state), state, success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sharedVentService.recordVent({
|
||||
agentId: this.agentId,
|
||||
input: params,
|
||||
topicId: this.topicId,
|
||||
userId: this.userId,
|
||||
...(context.operationId ? { operationId: context.operationId } : {}),
|
||||
...(context.toolCallId ? { toolCallId: context.toolCallId } : {}),
|
||||
});
|
||||
|
||||
const state: VentState = {
|
||||
category: params.category,
|
||||
reason: result.reason ?? null,
|
||||
recorded: result.recorded,
|
||||
severity: params.severity,
|
||||
ventId: result.ventId ?? null,
|
||||
};
|
||||
|
||||
return { content: JSON.stringify(state), state, success: true };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown vent error';
|
||||
const state: VentState = {
|
||||
category: params.category,
|
||||
reason: 'runtime_error',
|
||||
recorded: false,
|
||||
severity: params.severity,
|
||||
};
|
||||
return {
|
||||
content: `vent failed with error detail: ${message}`,
|
||||
error: { message },
|
||||
state,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
private queryScopeMessages = (
|
||||
messageModel: MessageModel,
|
||||
sourceMessage: ServerVisualSourceMessage,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createVentService, type VentRecordInput } from '../index';
|
||||
|
||||
const baseInput = (overrides: Partial<VentRecordInput> = {}): VentRecordInput => ({
|
||||
agentId: 'agent-1',
|
||||
input: {
|
||||
category: 'platform_bug',
|
||||
details: 'The run-command tool returned a 500 twice in a row.',
|
||||
severity: 'high',
|
||||
summary: 'run-command crashes on valid input.',
|
||||
},
|
||||
topicId: 'topic-1',
|
||||
userId: 'user-1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('createVentService', () => {
|
||||
it('accepts a valid vent and returns a stable vent id', async () => {
|
||||
const service = createVentService({ nextToolCallId: () => 'tool-1' });
|
||||
|
||||
const result = await service.recordVent(baseInput());
|
||||
|
||||
expect(result.recorded).toBe(true);
|
||||
expect(result.ventId).toBe('vent:user-1:agent-1:topic:topic-1:tool-1');
|
||||
});
|
||||
|
||||
it('generates a tool-call id when the caller does not provide one', async () => {
|
||||
const service = createVentService({ nextToolCallId: () => 'generated-1' });
|
||||
|
||||
const result = await service.recordVent(baseInput());
|
||||
|
||||
expect(result.ventId).toBe('vent:user-1:agent-1:topic:topic-1:generated-1');
|
||||
});
|
||||
|
||||
it('rejects invalid category and severity', async () => {
|
||||
const service = createVentService({ nextToolCallId: () => 'tool-1' });
|
||||
|
||||
const badCategory = await service.recordVent(
|
||||
baseInput({ input: { ...baseInput().input, category: 'nope' as never } }),
|
||||
);
|
||||
const badSeverity = await service.recordVent(
|
||||
baseInput({ input: { ...baseInput().input, severity: 'urgent' as never } }),
|
||||
);
|
||||
|
||||
expect(badCategory).toEqual({ recorded: false, reason: 'invalid_category' });
|
||||
expect(badSeverity).toEqual({ recorded: false, reason: 'invalid_severity' });
|
||||
});
|
||||
|
||||
it('allows only one vent per operation scope', async () => {
|
||||
const service = createVentService({ nextToolCallId: () => 'tool-1' });
|
||||
const input = baseInput({ operationId: 'op-1', toolCallId: 'tc-1' });
|
||||
|
||||
const first = await service.recordVent(input);
|
||||
const second = await service.recordVent({ ...input, toolCallId: 'tc-2' });
|
||||
|
||||
expect(first.recorded).toBe(true);
|
||||
expect(second).toEqual({ recorded: false, reason: 'rate_limited' });
|
||||
});
|
||||
|
||||
it('allows up to three vents per topic scope when no operation id is present', async () => {
|
||||
let counter = 0;
|
||||
const service = createVentService({ nextToolCallId: () => `tool-${++counter}` });
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
results.push(await service.recordVent(baseInput()));
|
||||
}
|
||||
|
||||
expect(results.filter((r) => r.recorded)).toHaveLength(3);
|
||||
expect(results[3]).toEqual({ recorded: false, reason: 'rate_limited' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { VentCategory, VentParams, VentSeverity } from '@lobechat/builtin-tool-lobe-agent';
|
||||
import { VENT_CATEGORIES, VENT_SEVERITIES } from '@lobechat/builtin-tool-lobe-agent';
|
||||
|
||||
/** Input used by the vent service to record one report. */
|
||||
export interface VentRecordInput {
|
||||
/** Stable agent id associated with the running agent. */
|
||||
agentId: string;
|
||||
/** Agent-declared vent payload. */
|
||||
input: VentParams;
|
||||
/** Runtime operation id when the vent is operation-scoped. */
|
||||
operationId?: string;
|
||||
/** Caller-provided tool-call id. */
|
||||
toolCallId?: string;
|
||||
/** Topic the vent belongs to. */
|
||||
topicId: string;
|
||||
/** Stable user id associated with the running agent. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type VentRecordRejection = 'invalid_category' | 'invalid_severity' | 'rate_limited';
|
||||
|
||||
/** Result returned after one vent attempt. */
|
||||
export interface VentResult {
|
||||
/** Optional rejection reason when nothing was recorded. */
|
||||
reason?: VentRecordRejection;
|
||||
/** Whether the vent was recorded. */
|
||||
recorded: boolean;
|
||||
/** Stable vent id built for recorded reports when available. */
|
||||
ventId?: string;
|
||||
}
|
||||
|
||||
/** Vent recording service API consumed by the LobeAgent server runtime. */
|
||||
export interface VentRuntimeService {
|
||||
recordVent: (input: VentRecordInput) => Promise<VentResult>;
|
||||
}
|
||||
|
||||
/** Dependencies used by the pure vent recording service. */
|
||||
export interface VentServiceDependencies {
|
||||
/** Creates a stable tool-call id when the caller did not provide one. */
|
||||
nextToolCallId: () => string;
|
||||
}
|
||||
|
||||
/** At most this many vents per operation scope; a topic-scoped fallback gets a looser cap. */
|
||||
const VENT_LIMIT_PER_OPERATION = 1;
|
||||
const VENT_LIMIT_PER_TOPIC = 3;
|
||||
|
||||
const validCategories = new Set<VentCategory>(VENT_CATEGORIES);
|
||||
const validSeverities = new Set<VentSeverity>(VENT_SEVERITIES);
|
||||
|
||||
const getScope = (input: VentRecordInput) =>
|
||||
input.operationId
|
||||
? ({ id: input.operationId, key: `operation:${input.operationId}`, type: 'operation' } as const)
|
||||
: ({ id: input.topicId, key: `topic:${input.topicId}`, type: 'topic' } as const);
|
||||
|
||||
const buildVentId = (params: {
|
||||
agentId: string;
|
||||
scopeId: string;
|
||||
scopeType: string;
|
||||
toolCallId: string;
|
||||
userId: string;
|
||||
}) =>
|
||||
`vent:${params.userId}:${params.agentId}:${params.scopeType}:${params.scopeId}:${params.toolCallId}`;
|
||||
|
||||
/**
|
||||
* Creates a pure vent recording service.
|
||||
*
|
||||
* Use when:
|
||||
* - The LobeAgent server runtime needs a DI-friendly vent boundary
|
||||
* - Tests need deterministic tool-call ids and rate-limit state
|
||||
*
|
||||
* Expects:
|
||||
* - The durable record is the persisted vent tool-call message itself
|
||||
* - The service instance owns only in-memory fast-loop rate limiting
|
||||
*
|
||||
* Returns:
|
||||
* - A service that accepts valid vents and never mutates user-facing resources
|
||||
*/
|
||||
export const createVentService = (deps: VentServiceDependencies): VentRuntimeService => {
|
||||
const recordedCounts = new Map<string, number>();
|
||||
|
||||
return {
|
||||
recordVent: async (input): Promise<VentResult> => {
|
||||
if (!validCategories.has(input.input.category)) {
|
||||
return { recorded: false, reason: 'invalid_category' };
|
||||
}
|
||||
|
||||
if (!validSeverities.has(input.input.severity)) {
|
||||
return { recorded: false, reason: 'invalid_severity' };
|
||||
}
|
||||
|
||||
const scope = getScope(input);
|
||||
const limit = scope.type === 'operation' ? VENT_LIMIT_PER_OPERATION : VENT_LIMIT_PER_TOPIC;
|
||||
const rateLimitKey = `${input.userId}:${input.agentId}:${scope.key}`;
|
||||
const recordedCount = recordedCounts.get(rateLimitKey) ?? 0;
|
||||
|
||||
if (recordedCount >= limit) {
|
||||
return { recorded: false, reason: 'rate_limited' };
|
||||
}
|
||||
|
||||
const toolCallId = input.toolCallId ?? deps.nextToolCallId();
|
||||
const ventId = buildVentId({
|
||||
agentId: input.agentId,
|
||||
scopeId: scope.id,
|
||||
scopeType: scope.type,
|
||||
toolCallId,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
recordedCounts.set(rateLimitKey, recordedCount + 1);
|
||||
|
||||
return { recorded: true, ventId };
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user