Compare commits

...

1 Commits

Author SHA1 Message Date
Arvin Xu 6deb8650cc feat(lobe-agent): add vent tool for reporting platform friction
Add a `vent` API to the lobe-agent builtin tool that lets a running agent
privately report friction in its own working conditions (missing tool,
schema mismatch, doc conflict, platform bug, environment limit) back to the
platform builders. The system prompt asks the agent to stay restrained and
vent at most once per task, only when genuinely blocked.

Implemented on both lobe-agent execution paths (client executor + server
runtime), with a pure recording service that validates input, assigns a
stable id, and enforces per-scope rate limits. The durable record is the
persisted vent tool-call message itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:39:18 +08:00
11 changed files with 596 additions and 4 deletions
@@ -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[];
+8
View File
@@ -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' });
});
});
+114
View File
@@ -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 };
},
};
};