Compare commits

...

8 Commits

Author SHA1 Message Date
Innei fd9fa0252d feat(chat): add session_bypassed status indicator and executor passthrough
StatusIndicator shows green ShieldCheck for session-bypassed tools.
Executor preserves auditType and session_bypassed status on tool messages.
2026-03-10 19:01:04 +08:00
Innei 5461c6260a feat(ui): add batch intervention card and i18n for grouped tool approvals
Batch pending tools by auditType into a single card with per-item
and aggregate actions (Approve All, Reject All, Allow for Session).
2026-03-10 19:00:52 +08:00
Innei 12979ae0cf feat(store): add session bypass state, pass to AgentState, add batch approve method 2026-03-10 18:59:08 +08:00
Innei a51f2332c5 feat(agent-runtime): annotate auditType and support session bypass in checkInterventionNeeded 2026-03-10 18:58:58 +08:00
Innei 9b5fd01190 feat(chat): propagate auditType through request_human_approve executor
Pass intervention.auditType from tool payload to pluginIntervention when
creating pending tool messages, enabling session bypass matching downstream.
2026-03-10 18:09:39 +08:00
Innei 32aa89214c feat(agent-runtime): add human_approved_tools batch approve phase to runtime
Add batch tool approval support: when multiple tools are approved at once,
route to call_tool (single) or call_tools_batch (multiple) with
skipCreateToolMessage propagated through the batch executor.
2026-03-10 18:09:29 +08:00
Innei 006f0cd277 feat(agent-runtime): add sessionBypassedAudits to AgentState 2026-03-10 17:37:26 +08:00
Innei dad876882e feat(types): add auditType and session_bypassed status to ToolIntervention 2026-03-10 17:37:18 +08:00
22 changed files with 951 additions and 27 deletions
+6
View File
@@ -391,9 +391,13 @@
"tokenTag.overload": "Exceeded Limit",
"tokenTag.remained": "Remaining",
"tokenTag.used": "Used",
"tool.intervention.allowSession": "Allow for This Session",
"tool.intervention.approve": "Approve",
"tool.intervention.approveAll": "Approve All",
"tool.intervention.approveAndRemember": "Approve and Remember",
"tool.intervention.approveOnce": "Approve This Time Only",
"tool.intervention.batchTitle_one": "{{count}} operation requires approval",
"tool.intervention.batchTitle_other": "{{count}} operations require approval",
"tool.intervention.mode.allowList": "Allow List",
"tool.intervention.mode.allowListDesc": "Only automatically execute approved tools",
"tool.intervention.mode.autoRun": "Auto Approve",
@@ -401,11 +405,13 @@
"tool.intervention.mode.manual": "Manual",
"tool.intervention.mode.manualDesc": "Manual approval required for each invocation",
"tool.intervention.reject": "Reject",
"tool.intervention.rejectAll": "Reject All",
"tool.intervention.rejectAndContinue": "Reject and Retry",
"tool.intervention.rejectOnly": "Reject",
"tool.intervention.rejectReasonPlaceholder": "A reason helps the Agent understand your boundaries and improve future actions",
"tool.intervention.rejectTitle": "Reject this Skill call",
"tool.intervention.rejectedWithReason": "This Skill call was rejected: {{reason}}",
"tool.intervention.sessionBypassed": "Auto-approved (session authorized)",
"tool.intervention.toolAbort": "You canceled this Skill call",
"tool.intervention.toolRejected": "This Skill call was rejected",
"toolAuth.authorize": "Authorize",
+6
View File
@@ -391,9 +391,13 @@
"tokenTag.overload": "超出限制",
"tokenTag.remained": "剩余",
"tokenTag.used": "已使用",
"tool.intervention.allowSession": "允许当前会话",
"tool.intervention.approve": "批准",
"tool.intervention.approveAll": "全部批准",
"tool.intervention.approveAndRemember": "批准并记住",
"tool.intervention.approveOnce": "仅本次批准",
"tool.intervention.batchTitle_one": "{{count}} 个操作需要确认",
"tool.intervention.batchTitle_other": "{{count}} 个操作需要确认",
"tool.intervention.mode.allowList": "白名单",
"tool.intervention.mode.allowListDesc": "仅自动执行已批准的技能",
"tool.intervention.mode.autoRun": "自动批准",
@@ -401,11 +405,13 @@
"tool.intervention.mode.manual": "手动批准",
"tool.intervention.mode.manualDesc": "每次调用都需要你确认",
"tool.intervention.reject": "拒绝",
"tool.intervention.rejectAll": "全部拒绝",
"tool.intervention.rejectAndContinue": "拒绝后继续",
"tool.intervention.rejectOnly": "仅拒绝",
"tool.intervention.rejectReasonPlaceholder": "填写原因可帮助助理理解你的边界,并优化后续行动",
"tool.intervention.rejectTitle": "拒绝本次技能调用",
"tool.intervention.rejectedWithReason": "本次技能调用已被拒绝:{{reason}}",
"tool.intervention.sessionBypassed": "已自动批准(会话授权)",
"tool.intervention.toolAbort": "你已取消本次技能调用",
"tool.intervention.toolRejected": "本次技能调用已被拒绝",
"toolAuth.authorize": "授权",
@@ -128,18 +128,12 @@ export class GeneralChatAgent implements Agent {
const toolsNeedingIntervention: ChatToolPayload[] = [];
const toolsToExecute: ChatToolPayload[] = [];
// Get security blacklist for resolver metadata
const securityBlacklist = state.securityBlacklist ?? DEFAULT_SECURITY_BLACKLIST;
// Build resolver metadata: merge state.metadata with security blacklist
const resolverMetadata = { ...state.metadata, securityBlacklist };
// Get user config (default to 'manual' mode)
const userConfig = state.userInterventionConfig || { approvalMode: 'manual' };
const { approvalMode, allowList = [] } = userConfig;
// Global audits: default to security blacklist audit if not provided
const globalResolvers = this.config.globalInterventionAudits ?? createDefaultGlobalAudits();
const sessionBypassed = state.sessionBypassedAudits ?? [];
for (const toolCalling of toolsCalling) {
const { identifier, apiName } = toolCalling;
@@ -161,6 +155,10 @@ export class GeneralChatAgent implements Agent {
if (globalResolver.resolver(toolArgs, resolverMetadata)) {
globalBlocked = true;
globalPolicy = globalResolver.policy ?? 'always';
toolCalling.intervention = {
...toolCalling.intervention,
auditType: globalResolver.type,
};
break;
}
}
@@ -194,14 +192,29 @@ export class GeneralChatAgent implements Agent {
if (dynamicPolicy === 'never') {
toolsToExecute.push(toolCalling);
} else {
toolsNeedingIntervention.push(toolCalling);
const dynamicConfig = config as { dynamic: { type: string } };
const auditType = dynamicConfig.dynamic.type;
toolCalling.intervention = { ...toolCalling.intervention, auditType };
if (sessionBypassed.includes(auditType)) {
toolCalling.intervention = { ...toolCalling.intervention, status: 'session_bypassed' };
toolsToExecute.push(toolCalling);
} else {
toolsNeedingIntervention.push(toolCalling);
}
}
continue;
}
// Phase 3.5: Handle overridable global block (policy !== 'always')
if (globalBlocked && globalPolicy !== 'always') {
toolsNeedingIntervention.push(toolCalling);
const auditType = toolCalling.intervention?.auditType;
if (auditType && sessionBypassed.includes(auditType)) {
toolCalling.intervention = { ...toolCalling.intervention, status: 'session_bypassed' };
toolsToExecute.push(toolCalling);
} else {
toolsNeedingIntervention.push(toolCalling);
}
continue;
}
@@ -2507,4 +2507,346 @@ describe('GeneralChatAgent', () => {
]);
});
});
describe('audit type annotation and session bypass', () => {
it('should annotate auditType from global resolver on tools needing intervention', async () => {
const customResolver: GlobalInterventionAuditConfig = {
type: 'customBlocker',
policy: 'always',
resolver: (toolArgs) => toolArgs.dangerous === true,
};
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
globalInterventionAudits: [customResolver],
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCall: ChatToolPayload = {
id: 'call-1',
identifier: 'my-tool',
apiName: 'doSomething',
arguments: '{"dangerous":true}',
type: 'default',
};
const state = createMockState({
toolManifestMap: {
'my-tool': { identifier: 'my-tool', humanIntervention: 'never' },
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [toolCall],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
expect(result).toEqual([
{
type: 'request_human_approve',
pendingToolsCalling: [
expect.objectContaining({
id: 'call-1',
intervention: { auditType: 'customBlocker' },
}),
],
reason: 'human_intervention_required',
},
]);
});
it('should annotate auditType from dynamic resolver on tools needing intervention', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
dynamicInterventionAudits: {
pathScopeAudit: (toolArgs, metadata) => {
const workingDirectory = metadata?.workingDirectory as string | undefined;
if (!workingDirectory) return false;
const path = toolArgs.path as string;
return !path.startsWith(workingDirectory);
},
},
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCall: ChatToolPayload = {
id: 'call-1',
identifier: 'local-system',
apiName: 'readLocalFile',
arguments: '{"path":"/etc/passwd"}',
type: 'builtin',
};
const state = createMockState({
metadata: { workingDirectory: '/workspace' },
toolManifestMap: {
'local-system': {
identifier: 'local-system',
api: [
{
name: 'readLocalFile',
humanIntervention: {
dynamic: {
default: 'never',
policy: 'required',
type: 'pathScopeAudit',
},
},
},
],
},
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [toolCall],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
expect(result).toEqual([
{
type: 'request_human_approve',
pendingToolsCalling: [
expect.objectContaining({
id: 'call-1',
intervention: { auditType: 'pathScopeAudit' },
}),
],
reason: 'human_intervention_required',
},
]);
});
it('should session-bypass dynamic audit type when in sessionBypassedAudits', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
dynamicInterventionAudits: {
pathScopeAudit: () => true,
},
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCall: ChatToolPayload = {
id: 'call-1',
identifier: 'local-system',
apiName: 'readLocalFile',
arguments: '{"path":"/etc/passwd"}',
type: 'builtin',
};
const state = createMockState({
sessionBypassedAudits: ['pathScopeAudit'],
toolManifestMap: {
'local-system': {
identifier: 'local-system',
api: [
{
name: 'readLocalFile',
humanIntervention: {
dynamic: {
default: 'never',
policy: 'required',
type: 'pathScopeAudit',
},
},
},
],
},
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [toolCall],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
expect(result).toEqual([
{
type: 'call_tool',
payload: {
parentMessageId: 'msg-1',
toolCalling: expect.objectContaining({
id: 'call-1',
intervention: { auditType: 'pathScopeAudit', status: 'session_bypassed' },
}),
},
},
]);
});
it('should NOT session-bypass dynamic audit type when NOT in sessionBypassedAudits', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
dynamicInterventionAudits: {
pathScopeAudit: () => true,
},
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCall: ChatToolPayload = {
id: 'call-1',
identifier: 'local-system',
apiName: 'readLocalFile',
arguments: '{"path":"/etc/passwd"}',
type: 'builtin',
};
const state = createMockState({
sessionBypassedAudits: ['someOtherAudit'],
toolManifestMap: {
'local-system': {
identifier: 'local-system',
api: [
{
name: 'readLocalFile',
humanIntervention: {
dynamic: {
default: 'never',
policy: 'required',
type: 'pathScopeAudit',
},
},
},
],
},
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [toolCall],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
expect(result).toEqual([
{
type: 'request_human_approve',
pendingToolsCalling: [
expect.objectContaining({
id: 'call-1',
intervention: { auditType: 'pathScopeAudit' },
}),
],
reason: 'human_intervention_required',
},
]);
});
it('should session-bypass overridable global block when auditType is in sessionBypassedAudits', async () => {
const softBlocker: GlobalInterventionAuditConfig = {
type: 'softBlocker',
policy: 'required',
resolver: () => true,
};
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
globalInterventionAudits: [softBlocker],
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCall: ChatToolPayload = {
id: 'call-1',
identifier: 'my-tool',
apiName: 'doSomething',
arguments: '{}',
type: 'default',
};
const state = createMockState({
sessionBypassedAudits: ['softBlocker'],
toolManifestMap: {
'my-tool': { identifier: 'my-tool', humanIntervention: 'never' },
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [toolCall],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
expect(result).toEqual([
{
type: 'call_tool',
payload: {
parentMessageId: 'msg-1',
toolCalling: expect.objectContaining({
id: 'call-1',
intervention: { auditType: 'softBlocker', status: 'session_bypassed' },
}),
},
},
]);
});
it('should NOT session-bypass global audit with policy always (e.g., securityBlacklist)', async () => {
const alwaysBlocker: GlobalInterventionAuditConfig = {
type: 'securityBlacklist',
policy: 'always',
resolver: (toolArgs) => toolArgs.dangerous === true,
};
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
globalInterventionAudits: [alwaysBlocker],
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCall: ChatToolPayload = {
id: 'call-1',
identifier: 'my-tool',
apiName: 'doSomething',
arguments: '{"dangerous":true}',
type: 'default',
};
const state = createMockState({
sessionBypassedAudits: ['securityBlacklist'],
toolManifestMap: {
'my-tool': { identifier: 'my-tool', humanIntervention: 'never' },
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [toolCall],
parentMessageId: 'msg-1',
});
const result = await agent.runner(context, state);
expect(result).toEqual([
{
type: 'request_human_approve',
pendingToolsCalling: [
expect.objectContaining({
id: 'call-1',
intervention: { auditType: 'securityBlacklist' },
}),
],
reason: 'human_intervention_required',
},
]);
});
});
});
+31 -1
View File
@@ -121,6 +121,32 @@ export class AgentRuntime {
},
type: 'call_tool',
};
} else if (runtimeContext.phase === 'human_approved_tools') {
// Batch approve: execute all approved tools
const approvedPayload = runtimeContext.payload as {
approvedToolCalls: ChatToolPayload[];
parentMessageId: string;
};
if (approvedPayload.approvedToolCalls.length === 1) {
rawInstructions = {
payload: {
parentMessageId: approvedPayload.parentMessageId,
skipCreateToolMessage: true,
toolCalling: approvedPayload.approvedToolCalls[0],
},
type: 'call_tool',
};
} else {
rawInstructions = {
payload: {
parentMessageId: approvedPayload.parentMessageId,
skipCreateToolMessage: true,
toolsCalling: approvedPayload.approvedToolCalls,
},
type: 'call_tools_batch',
};
}
} else {
// Standard flow: Plan -> Execute
rawInstructions = await this.agent.runner(runtimeContext, newState);
@@ -685,7 +711,11 @@ export class AgentRuntime {
const results = await pMap(instruction.payload.toolsCalling, (toolCalling: ChatToolPayload) =>
this.executors.call_tool(
{
payload: { parentMessageId: payload.parentMessageId, toolCalling },
payload: {
parentMessageId: payload.parentMessageId,
skipCreateToolMessage: payload.skipCreateToolMessage,
toolCalling,
},
type: 'call_tool',
} as AgentInstructionCallTool,
structuredClone(baseState), // Each tool starts from the same base state
@@ -39,6 +39,7 @@ export interface AgentRuntimeContext {
| 'tasks_batch_result'
| 'human_response'
| 'human_approved_tool'
| 'human_approved_tools'
| 'human_abort'
| 'compression_result'
| 'error';
@@ -104,6 +104,7 @@ export interface AgentState {
* If not provided, DEFAULT_SECURITY_BLACKLIST will be used.
*/
securityBlacklist?: SecurityBlacklistConfig;
sessionBypassedAudits?: string[];
// --- State Machine ---
status: 'idle' | 'running' | 'waiting_for_human' | 'done' | 'error' | 'interrupted';
+6 -2
View File
@@ -6,13 +6,17 @@ import type { LobeToolRenderType } from '../../tool';
// ToolIntervention must be defined first to avoid circular dependency
export interface ToolIntervention {
auditType?: string;
rejectedReason?: string;
status?: 'pending' | 'approved' | 'rejected' | 'aborted' | 'none';
status?: 'pending' | 'approved' | 'rejected' | 'aborted' | 'none' | 'session_bypassed';
}
export const ToolInterventionSchema = z.object({
auditType: z.string().optional(),
rejectedReason: z.string().optional(),
status: z.enum(['pending', 'approved', 'rejected', 'aborted', 'none']).optional(),
status: z
.enum(['pending', 'approved', 'rejected', 'aborted', 'none', 'session_bypassed'])
.optional(),
});
export interface ChatPluginPayload {
@@ -0,0 +1,92 @@
import { Button, Flexbox } from '@lobehub/ui';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { useConversationStore } from '../../../store';
import { useMessageAggregationContext } from '../../Contexts/MessageAggregationContext';
interface BatchApprovalActionsProps {
auditType: string;
toolMessageIds: string[];
}
const BatchApprovalActions = memo<BatchApprovalActionsProps>(({ auditType, toolMessageIds }) => {
const { t } = useTranslation('chat');
const { assistantGroupId } = useMessageAggregationContext();
const [loading, setLoading] = useState<string | null>(null);
const approveAllToolCallings = useChatStore((s) => s.approveAllToolCallings);
const bypassAuditForSession = useChatStore((s) => s.bypassAuditForSession);
const rejectToolCall = useConversationStore((s) => s.rejectToolCall);
const context = useConversationStore((s) => s.context);
const handleApproveAll = async () => {
setLoading('approveAll');
try {
await approveAllToolCallings(toolMessageIds, assistantGroupId, context);
} finally {
setLoading(null);
}
};
const handleRejectAll = async () => {
setLoading('rejectAll');
try {
for (const id of toolMessageIds) {
await rejectToolCall(id);
}
} finally {
setLoading(null);
}
};
const handleAllowSession = async () => {
setLoading('allowSession');
try {
bypassAuditForSession(auditType);
await approveAllToolCallings(toolMessageIds, assistantGroupId, context);
} finally {
setLoading(null);
}
};
const isAnyLoading = loading !== null;
return (
<Flexbox horizontal gap={8} justify="flex-end">
<Button
color="default"
disabled={isAnyLoading}
loading={loading === 'rejectAll'}
size="small"
variant="filled"
onClick={handleRejectAll}
>
{t('tool.intervention.rejectAll')}
</Button>
<Button
disabled={isAnyLoading}
loading={loading === 'approveAll'}
size="small"
type="primary"
variant="outlined"
onClick={handleApproveAll}
>
{t('tool.intervention.approveAll')}
</Button>
<Button
disabled={isAnyLoading}
loading={loading === 'allowSession'}
size="small"
type="primary"
onClick={handleAllowSession}
>
{t('tool.intervention.allowSession')}
</Button>
</Flexbox>
);
});
export default BatchApprovalActions;
@@ -0,0 +1,97 @@
import { getBuiltinIntervention } from '@lobechat/builtin-tools/interventions';
import { type ChatToolPayloadWithResult } from '@lobechat/types';
import { safeParseJSON } from '@lobechat/utils';
import { AccordionItem, Button, Flexbox } from '@lobehub/ui';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useConversationStore } from '../../../store';
import { useMessageAggregationContext } from '../../Contexts/MessageAggregationContext';
interface BatchToolItemProps {
tool: ChatToolPayloadWithResult;
}
const BatchToolItem = memo<BatchToolItemProps>(({ tool }) => {
const { t } = useTranslation('chat');
const { assistantGroupId } = useMessageAggregationContext();
const [approveToolCall, rejectToolCall] = useConversationStore((s) => [
s.approveToolCall,
s.rejectToolCall,
]);
const [loading, setLoading] = useState(false);
const toolName = `${tool.identifier}/${tool.apiName}`;
const parsedArgs = useMemo(() => safeParseJSON(tool.arguments || '') ?? {}, [tool.arguments]);
const BuiltinIntervention = getBuiltinIntervention(tool.identifier, tool.apiName);
const isMessageCreating = !tool.result_msg_id || tool.result_msg_id.startsWith('tmp_');
const handleApprove = async () => {
if (isMessageCreating) return;
setLoading(true);
try {
await approveToolCall(tool.result_msg_id!, assistantGroupId);
} finally {
setLoading(false);
}
};
const handleReject = async () => {
if (isMessageCreating) return;
setLoading(true);
try {
await rejectToolCall(tool.result_msg_id!);
} finally {
setLoading(false);
}
};
return (
<AccordionItem
expand
itemKey={tool.id}
paddingBlock={4}
paddingInline={8}
title={<span style={{ fontSize: 13 }}>{toolName}</span>}
action={
<Flexbox horizontal gap={4}>
<Button
color="default"
disabled={loading || isMessageCreating}
size="small"
variant="text"
onClick={handleReject}
>
{t('tool.intervention.reject')}
</Button>
<Button
disabled={isMessageCreating}
loading={loading}
size="small"
type="primary"
variant="text"
onClick={handleApprove}
>
{t('tool.intervention.approve')}
</Button>
</Flexbox>
}
>
{BuiltinIntervention ? (
<BuiltinIntervention
apiName={tool.apiName}
args={parsedArgs}
identifier={tool.identifier}
messageId={tool.result_msg_id || ''}
/>
) : (
<pre style={{ fontSize: 12, margin: 0, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(parsedArgs, null, 2)}
</pre>
)}
</AccordionItem>
);
});
export default BatchToolItem;
@@ -0,0 +1,15 @@
import { Icon, Tag } from '@lobehub/ui';
import { ShieldCheck } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const SessionBypassBadge = memo(() => {
const { t } = useTranslation('chat');
return (
<Tag icon={<Icon icon={ShieldCheck} />} size="small">
{t('tool.intervention.sessionBypassed')}
</Tag>
);
});
export default SessionBypassBadge;
@@ -0,0 +1,46 @@
import { type ChatToolPayloadWithResult } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { Divider } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import BatchApprovalActions from './BatchApprovalActions';
import BatchToolItem from './BatchToolItem';
interface BatchInterventionProps {
assistantMessageId: string;
auditType: string;
tools: ChatToolPayloadWithResult[];
}
const BatchIntervention = memo<BatchInterventionProps>(({ auditType, tools }) => {
const { t } = useTranslation('chat');
const toolMessageIds = tools
.map((tool) => tool.result_msg_id)
.filter((id): id is string => !!id && !id.startsWith('tmp_'));
return (
<Flexbox
gap={8}
style={{
border: '1px solid var(--lobe-color-border)',
borderRadius: 'var(--lobe-border-radius)',
padding: 12,
}}
>
<div style={{ fontSize: 13, fontWeight: 500 }}>
{t('tool.intervention.batchTitle', { count: tools.length })}
</div>
<Flexbox gap={4}>
{tools.map((tool) => (
<BatchToolItem key={tool.id} tool={tool} />
))}
</Flexbox>
<Divider dashed style={{ margin: '4px 0' }} />
<BatchApprovalActions auditType={auditType} toolMessageIds={toolMessageIds} />
</Flexbox>
);
});
export default BatchIntervention;
@@ -1,7 +1,7 @@
import { type ToolIntervention } from '@lobechat/types';
import { Block, Icon, Tooltip } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Ban, Check, HandIcon, PauseIcon, X } from 'lucide-react';
import { Ban, Check, HandIcon, PauseIcon, ShieldCheck, X } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -22,6 +22,7 @@ const StatusIndicator = memo<StatusIndicatorProps>(({ intervention, result }) =>
const isPending = intervention?.status === 'pending';
const isReject = intervention?.status === 'rejected';
const isAbort = intervention?.status === 'aborted';
const isSessionBypassed = intervention?.status === 'session_bypassed';
let icon;
@@ -39,6 +40,12 @@ const StatusIndicator = memo<StatusIndicatorProps>(({ intervention, result }) =>
);
} else if (hasError) {
icon = <Icon color={cssVar.colorError} icon={X} />;
} else if (isSessionBypassed) {
icon = (
<Tooltip title={t('tool.intervention.sessionBypassed')}>
<Icon color={cssVar.colorSuccess} icon={ShieldCheck} />
</Tooltip>
);
} else if (isPending) {
icon = <Icon color={cssVar.colorInfo} icon={HandIcon} />;
} else if (hasResult) {
@@ -1,7 +1,8 @@
import { type ChatToolPayloadWithResult } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import BatchIntervention from './BatchIntervention';
import Tool from './Tool';
interface ToolsRendererProps {
@@ -11,11 +12,51 @@ interface ToolsRendererProps {
}
export const Tools = memo<ToolsRendererProps>(({ disableEditing, messageId, tools }) => {
const { batchGroups, nonBatchTools } = useMemo(() => {
if (!tools || tools.length === 0) return { batchGroups: [], nonBatchTools: [] };
const pendingByAuditType = new Map<string, ChatToolPayloadWithResult[]>();
const others: ChatToolPayloadWithResult[] = [];
for (const tool of tools) {
const isPending = tool.intervention?.status === 'pending';
const auditType = tool.intervention?.auditType;
if (isPending && auditType) {
const group = pendingByAuditType.get(auditType) ?? [];
group.push(tool);
pendingByAuditType.set(auditType, group);
} else {
others.push(tool);
}
}
// Only batch groups with 2+ tools; singles go to nonBatchTools
const batches: Array<{ auditType: string; tools: ChatToolPayloadWithResult[] }> = [];
for (const [auditType, groupedTools] of pendingByAuditType) {
if (groupedTools.length > 1) {
batches.push({ auditType, tools: groupedTools });
} else {
others.push(groupedTools[0]);
}
}
return { batchGroups: batches, nonBatchTools: others };
}, [tools]);
if (!tools || tools.length === 0) return null;
return (
<Flexbox gap={8}>
{tools.map((tool) => (
{batchGroups.map(({ auditType, tools: groupedTools }) => (
<BatchIntervention
assistantMessageId={messageId}
auditType={auditType}
key={`batch-${auditType}`}
tools={groupedTools}
/>
))}
{nonBatchTools.map((tool) => (
<Tool
apiName={tool.apiName}
arguments={tool.arguments}
@@ -11,19 +11,11 @@ import { dataSelectors } from '../data/selectors';
* Handles tool call approval and rejection
*/
export interface ToolAction {
/**
* Approve a tool call
*/
approveAllByAuditType: (auditType: string, assistantGroupId: string) => Promise<void>;
approveToolCall: (toolMessageId: string, assistantGroupId: string) => Promise<void>;
/**
* Reject a tool call and continue the conversation
*/
bypassAuditForSession: (auditType: string, assistantGroupId: string) => Promise<void>;
rejectAllByAuditType: (auditType: string, reason?: string) => Promise<void>;
rejectAndContinueToolCall: (toolMessageId: string, reason?: string) => Promise<void>;
/**
* Reject a tool call
*/
rejectToolCall: (toolMessageId: string, reason?: string) => Promise<void>;
}
@@ -33,6 +25,44 @@ export const toolSlice: StateCreator<
[],
ToolAction
> = (set, get) => ({
approveAllByAuditType: async (auditType: string, assistantGroupId: string) => {
const state = get();
const { context, hooks, waitForPendingArgsUpdate } = state;
const allMessages = dataSelectors.dbMessages(state);
const pendingIds = allMessages
.filter(
(m) =>
m.role === 'tool' &&
!m.id.startsWith('tmp_') &&
m.pluginIntervention?.status === 'pending' &&
m.pluginIntervention?.auditType === auditType,
)
.map((m) => m.id);
if (pendingIds.length === 0) return;
// Wait for all pending args updates before batch approval
await Promise.all(pendingIds.map((id) => waitForPendingArgsUpdate(id)));
// Fire onToolApproved hook for each tool
if (hooks.onToolApproved) {
for (const id of pendingIds) {
const shouldProceed = await hooks.onToolApproved(id);
if (shouldProceed === false) return;
}
}
const chatStore = useChatStore.getState();
await chatStore.approveAllToolCallings(pendingIds, assistantGroupId, context);
// Fire onToolCallComplete hook for each tool
if (hooks.onToolCallComplete) {
for (const id of pendingIds) {
hooks.onToolCallComplete(id, undefined);
}
}
},
approveToolCall: async (toolMessageId: string, assistantGroupId: string) => {
const state = get();
const { hooks, context, waitForPendingArgsUpdate } = state;
@@ -56,6 +86,30 @@ export const toolSlice: StateCreator<
}
},
bypassAuditForSession: async (auditType: string, assistantGroupId: string) => {
const chatStore = useChatStore.getState();
chatStore.bypassAuditForSession(auditType);
await get().approveAllByAuditType(auditType, assistantGroupId);
},
rejectAllByAuditType: async (auditType: string, reason?: string) => {
const state = get();
const allMessages = dataSelectors.dbMessages(state);
const pendingIds = allMessages
.filter(
(m) =>
m.role === 'tool' &&
!m.id.startsWith('tmp_') &&
m.pluginIntervention?.status === 'pending' &&
m.pluginIntervention?.auditType === auditType,
)
.map((m) => m.id);
for (const id of pendingIds) {
await get().rejectToolCall(id, reason);
}
},
rejectAndContinueToolCall: async (toolMessageId: string, reason?: string) => {
const { context, waitForPendingArgsUpdate } = get();
+6
View File
@@ -443,6 +443,12 @@ export default {
'tool.intervention.rejectedWithReason': 'This Skill call was rejected: {{reason}}',
'tool.intervention.toolAbort': 'You canceled this Skill call',
'tool.intervention.toolRejected': 'This Skill call was rejected',
'tool.intervention.approveAll': 'Approve All',
'tool.intervention.rejectAll': 'Reject All',
'tool.intervention.allowSession': 'Allow for This Session',
'tool.intervention.sessionBypassed': 'Auto-approved (session authorized)',
'tool.intervention.batchTitle_one': '{{count}} operation requires approval',
'tool.intervention.batchTitle_other': '{{count}} operations require approval',
'toolAuth.authorize': 'Authorize',
'toolAuth.authorizing': 'Authorizing...',
'toolAuth.hint':
@@ -694,6 +694,10 @@ export const createAgentExecutors = (context: {
groupId: assistantMessage?.groupId,
parentId: payload.parentMessageId,
plugin: chatToolPayload,
pluginIntervention:
chatToolPayload.intervention?.status === 'session_bypassed'
? { status: 'session_bypassed', auditType: chatToolPayload.intervention.auditType }
: undefined,
role: 'tool',
agentId: effectiveAgentId!,
threadId: opContext.threadId,
@@ -1025,7 +1029,10 @@ export const createAgentExecutors = (context: {
plugin: {
...toolPayload,
},
pluginIntervention: { status: 'pending' },
pluginIntervention: {
auditType: toolPayload.intervention?.auditType,
status: 'pending',
},
role: 'tool',
agentId: effectiveAgentId!,
threadId: opContext.threadId,
@@ -190,6 +190,103 @@ export class ConversationControlActionImpl {
}
};
approveAllToolCallings = async (
toolMessageIds: string[],
_assistantGroupId: string,
context?: ConversationContext,
): Promise<void> => {
const { internal_execAgentRuntime, startOperation, completeOperation } = this.#get();
const effectiveContext: ConversationContext = context ?? {
agentId: this.#get().activeAgentId,
topicId: this.#get().activeTopicId,
threadId: this.#get().activeThreadId,
};
const { agentId, topicId, threadId, scope } = effectiveContext;
// Use the last tool message as parent for the resumed execution
const parentMessageId = toolMessageIds.at(-1);
if (!parentMessageId) return;
const { operationId } = startOperation({
type: 'approveToolCalling',
context: {
agentId,
topicId: topicId ?? undefined,
threadId: threadId ?? undefined,
scope,
messageId: parentMessageId,
},
});
const optimisticContext = { operationId };
// Update all intervention statuses to approved
const approvedToolCalls: unknown[] = [];
for (const toolMsgId of toolMessageIds) {
const toolMessage = dbMessageSelectors.getDbMessageById(toolMsgId)(this.#get());
if (!toolMessage) continue;
await this.#get().optimisticUpdatePlugin(
toolMsgId,
{ intervention: { status: 'approved' } },
optimisticContext,
);
approvedToolCalls.push(toolMessage.plugin);
}
// Bail out if no valid tool messages were found
if (approvedToolCalls.length === 0) {
completeOperation(operationId);
return;
}
// Get current messages for state construction
const chatKey = messageMapKey({ agentId, topicId, threadId, scope });
const currentMessages = displayMessageSelectors.getDisplayMessagesByKey(chatKey)(this.#get());
const { state, context: initialContext } = this.#get().internal_createAgentState({
messages: currentMessages,
parentMessageId,
agentId,
topicId,
threadId: threadId ?? undefined,
operationId,
});
const agentRuntimeContext: AgentRuntimeContext = {
...initialContext,
phase: 'human_approved_tools',
payload: {
approvedToolCalls,
parentMessageId,
skipCreateToolMessage: true,
},
};
try {
await internal_execAgentRuntime({
context: effectiveContext,
messages: currentMessages,
parentMessageId,
parentMessageType: 'tool',
initialState: state,
initialContext: agentRuntimeContext,
parentOperationId: operationId,
});
completeOperation(operationId);
} catch (error) {
const err = error as Error;
console.error('[approveAllToolCallings] Error executing agent runtime:', err);
this.#get().failOperation(operationId, {
type: 'approveToolCalling',
message: err.message || 'Unknown error',
});
}
};
rejectToolCalling = async (
messageId: string,
reason?: string,
@@ -9,6 +9,8 @@ import { type ConversationLifecycleAction } from './conversationLifecycle';
import { ConversationLifecycleActionImpl } from './conversationLifecycle';
import { type ChatMemoryAction } from './memory';
import { ChatMemoryActionImpl } from './memory';
import { type SessionBypassAction } from './sessionBypass';
import { SessionBypassActionImpl } from './sessionBypass';
import { type StreamingExecutorAction } from './streamingExecutor';
import { StreamingExecutorActionImpl } from './streamingExecutor';
import { type StreamingStatesAction } from './streamingStates';
@@ -17,6 +19,7 @@ import { StreamingStatesActionImpl } from './streamingStates';
export type ChatAIChatAction = ChatMemoryAction &
ConversationLifecycleAction &
ConversationControlAction &
SessionBypassAction &
StreamingExecutorAction &
StreamingStatesAction;
@@ -34,6 +37,7 @@ export const chatAiChat: StateCreator<
new ChatMemoryActionImpl(...params),
new ConversationLifecycleActionImpl(...params),
new ConversationControlActionImpl(...params),
new SessionBypassActionImpl(...params),
new StreamingExecutorActionImpl(...params),
new StreamingStatesActionImpl(...params),
]);
@@ -0,0 +1,44 @@
import { type ChatStore } from '@/store/chat/store';
import { type StoreSetter } from '@/store/types';
type Setter = StoreSetter<ChatStore>;
export class SessionBypassActionImpl {
readonly #get: () => ChatStore;
readonly #set: Setter;
constructor(set: Setter, get: () => ChatStore, _api?: unknown) {
void _api;
this.#set = set;
this.#get = get;
}
bypassAuditForSession = (auditType: string): void => {
const key = this.#getCurrentKey();
const current = this.#get().sessionBypassedAudits[key] ?? [];
if (current.includes(auditType)) return;
this.#set(
{
sessionBypassedAudits: {
...this.#get().sessionBypassedAudits,
[key]: [...current, auditType],
},
},
false,
'bypassAuditForSession',
);
};
getSessionBypassedAudits = (): string[] => {
const key = this.#getCurrentKey();
return this.#get().sessionBypassedAudits[key] ?? [];
};
#getCurrentKey(): string {
const { activeAgentId, activeTopicId } = this.#get();
return `${activeAgentId}-${activeTopicId ?? 'default'}`;
}
}
export type SessionBypassAction = Pick<SessionBypassActionImpl, keyof SessionBypassActionImpl>;
@@ -194,6 +194,10 @@ export class StreamingExecutorActionImpl {
const agentWorkingDirectory = agentSelectors.currentAgentWorkingDirectory(getAgentStoreState());
const workingDirectory = topicWorkingDirectory ?? agentWorkingDirectory;
// Read session-bypassed audits for this conversation
const conversationKey = `${agentId}-${topicId ?? 'default'}`;
const sessionBypassedAudits = this.#get().sessionBypassedAudits[conversationKey] ?? [];
// Create initial state or use provided state
const state =
initialState ||
@@ -214,6 +218,7 @@ export class StreamingExecutorActionImpl {
sourceMap: {},
tools: toolsDetailed.tools ?? [],
},
sessionBypassedAudits: sessionBypassedAudits.length > 0 ? sessionBypassedAudits : undefined,
toolManifestMap,
userInterventionConfig,
});
@@ -5,6 +5,11 @@ export interface ChatAIChatState {
inputMessage: string;
mainInputEditor: ChatInputEditor | null;
searchWorkflowLoadingIds: string[];
/**
* Session-bypassed audit types per conversation key.
* Key format: `${agentId}-${topicId ?? 'default'}`
*/
sessionBypassedAudits: Record<string, string[]>;
threadInputEditor: ChatInputEditor | null;
/**
* the tool calling stream ids
@@ -17,6 +22,7 @@ export const initialAiChatState: ChatAIChatState = {
inputMessage: '',
mainInputEditor: null,
searchWorkflowLoadingIds: [],
sessionBypassedAudits: {},
threadInputEditor: null,
toolCallingStreamIds: {},
};