feat(agent-runner): enforce 4.x host-owned execution

This commit is contained in:
huanghuoguoguo
2026-07-12 20:36:32 +08:00
parent 9aa71d54e3
commit d88d05e27c
170 changed files with 6952 additions and 5381 deletions
@@ -17,6 +17,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
const [agent, setAgent] = useState<Agent | null>(null);
const [loading, setLoading] = useState(!isCreateMode);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
useEffect(() => {
if (isCreateMode) {
@@ -73,7 +74,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
<Button type="submit" form="agent-form" disabled={!formDirty}>
<Button
type="submit"
form="agent-form"
disabled={!formDirty || formSaving}
>
{t('common.save')}
</Button>
</div>
@@ -83,13 +88,13 @@ export default function AgentDetailContent({ id }: { id: string }) {
agentId={id}
onFinish={() => {
refreshPipelines();
setFormDirty(false);
}}
onDeleted={() => {
refreshPipelines();
navigate('/home/agents');
}}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
/>
</div>
</div>
@@ -62,6 +62,7 @@ interface AgentFormComponentProps {
onFinish: () => void;
onDeleted: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
}
interface SectionItem {
@@ -75,6 +76,7 @@ export default function AgentFormComponent({
onFinish,
onDeleted,
onDirtyChange,
onSavingChange,
}: AgentFormComponentProps) {
const { t } = useTranslation();
const [activeSection, setActiveSection] =
@@ -86,6 +88,8 @@ export default function AgentFormComponent({
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const isSavingRef = useRef(false);
const formSchema = z.object({
basic: z.object({
@@ -118,10 +122,10 @@ export default function AgentFormComponent({
const savedSnapshotRef = useRef('');
const initializedStagesRef = useRef<Set<string>>(new Set());
const watchedValues = form.watch();
const hasUnsavedChanges = useMemo(() => {
const hasUnsavedChanges = (() => {
if (!savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [watchedValues]);
})();
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
@@ -388,6 +392,8 @@ export default function AgentFormComponent({
}
function handleSubmit(values: FormValues) {
if (isSavingRef.current) return;
const submittedSnapshot = JSON.stringify(values);
const runner = values.runner || {};
const agent: Partial<Agent> = {
name: values.basic.name,
@@ -404,16 +410,23 @@ export default function AgentFormComponent({
},
};
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.updateAgent(agentId, agent)
.then(() => {
const snapshotValues = form.getValues();
savedSnapshotRef.current = JSON.stringify(snapshotValues);
savedSnapshotRef.current = submittedSnapshot;
onFinish();
toast.success(t('agents.saveSuccess'));
})
.catch((err) => {
toast.error(t('agents.saveError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
}
@@ -574,6 +587,7 @@ export default function AgentFormComponent({
type="button"
variant="destructive"
size="sm"
disabled={isSaving}
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
@@ -1561,7 +1561,11 @@ export default function EventBindingsEditor({
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
if (s.has(id)) {
s.delete(id);
} else {
s.add(id);
}
return s;
});
}
@@ -106,6 +106,25 @@ function getBoundPluginId(plugin: BoundPlugin) {
return `${plugin.author || ''}/${plugin.name}`;
}
function getGlobalExtensions(
plugins: AvailablePlugin[],
mcpServers: MCPServer[],
): PipelineExtensions {
return {
enable_all_plugins: true,
enable_all_mcp_servers: true,
enable_all_skills: true,
mcp_resource_agent_read_enabled: true,
bound_plugins: [],
available_plugins: plugins,
bound_mcp_servers: [],
available_mcp_servers: mcpServers,
bound_mcp_resources: [],
bound_skills: [],
available_skills: [],
};
}
function InfoTooltip({ label }: { label: string }) {
return (
<Tooltip>
@@ -348,19 +367,60 @@ export default function ToolResourceSelectors({
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
setTools([]);
setKnowledgeBases([]);
setExtensions(null);
if (mode !== 'resources') {
backendClient.getTools(pipelineId).then((resp) => setTools(resp.tools));
backendClient
.getTools(pipelineId)
.then((resp) => {
if (!cancelled) setTools(resp.tools);
})
.catch(() => {
if (!cancelled) setTools([]);
});
}
if (mode !== 'tools') {
backendClient
.getKnowledgeBases()
.then((resp) => setKnowledgeBases(resp.bases));
.then((resp) => {
if (!cancelled) setKnowledgeBases(resp.bases);
})
.catch(() => {
if (!cancelled) setKnowledgeBases([]);
});
}
if (pipelineId) {
backendClient
.getPipelineExtensions(pipelineId)
.then((resp) => setExtensions(resp));
.then((resp) => {
if (!cancelled) setExtensions(resp);
})
.catch(() => {
if (!cancelled) setExtensions(null);
});
} else {
Promise.all([
backendClient
.getPlugins()
.catch(() => ({ plugins: [] as AvailablePlugin[] })),
backendClient
.getMCPServers()
.catch(() => ({ servers: [] as MCPServer[] })),
]).then(([pluginResp, mcpResp]) => {
if (!cancelled) {
setExtensions(
getGlobalExtensions(pluginResp.plugins, mcpResp.servers),
);
}
});
}
return () => {
cancelled = true;
};
}, [mode, pipelineId]);
const enableAllTools = value['enable-all-tools'] !== false;
@@ -480,11 +540,6 @@ export default function ToolResourceSelectors({
],
);
const availableToolNames = useMemo(
() => new Set(availableTools.map((tool) => tool.name)),
[availableTools],
);
const resourceServers = useMemo(() => {
return scopedMCPServers.filter(
(server) =>
@@ -513,6 +568,9 @@ export default function ToolResourceSelectors({
? availableMCPResourceKeys.has(getMCPResourceKey(server, resource.uri))
: false;
});
const unavailableSelectedMCPResources = selectedMCPResources.filter(
(resource) => !scopedSelectedMCPResources.includes(resource),
);
const selectedTools = selectedToolNames
.map((name: string) => availableTools.find((tool) => tool.name === name))
@@ -524,10 +582,10 @@ export default function ToolResourceSelectors({
const sourceLabels = useMemo<Record<string, string>>(
() => ({
builtin: t('pipelines.localAgent.builtinTools'),
plugin: t('pipelines.localAgent.pluginTools'),
mcp: t('pipelines.localAgent.mcpTools'),
skill: t('pipelines.localAgent.skillTools'),
builtin: t('pipelines.agentRunner.builtinTools'),
plugin: t('pipelines.agentRunner.pluginTools'),
mcp: t('pipelines.agentRunner.mcpTools'),
skill: t('pipelines.agentRunner.skillTools'),
}),
[t],
);
@@ -546,11 +604,7 @@ export default function ToolResourceSelectors({
};
const handleConfirmTools = () => {
onChange({
tools: tempSelectedToolNames.filter((name) =>
availableToolNames.has(name),
),
});
onChange({ tools: tempSelectedToolNames });
setToolsDialogOpen(false);
};
@@ -580,7 +634,9 @@ export default function ToolResourceSelectors({
: scopedSelectedMCPResources.filter(
(item) => !isSameMCPResource(item, server, resource.uri),
);
onChange({ 'mcp-resources': next });
onChange({
'mcp-resources': [...unavailableSelectedMCPResources, ...next],
});
};
const isMCPResourceSelected = (server: MCPServer, uri: string) =>
@@ -597,25 +653,27 @@ export default function ToolResourceSelectors({
<div>
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-semibold">
{t('pipelines.localAgent.toolsTitle')}
{t('pipelines.agentRunner.toolsTitle')}
</h3>
<InfoTooltip
label={t('pipelines.localAgent.toolsScopeTooltip')}
/>
{pipelineId && (
<InfoTooltip
label={t('pipelines.agentRunner.toolsScopeTooltip')}
/>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{t('pipelines.localAgent.toolsDescription')}
{t('pipelines.agentRunner.toolsDescription')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Label
htmlFor="local-agent-enable-all-tools"
htmlFor="agent-runner-enable-all-tools"
className="cursor-pointer text-sm font-normal"
>
{t('pipelines.localAgent.enableAllTools')}
{t('pipelines.agentRunner.enableAllTools')}
</Label>
<Switch
id="local-agent-enable-all-tools"
id="agent-runner-enable-all-tools"
checked={enableAllTools}
onCheckedChange={handleToggleToolMode}
/>
@@ -625,13 +683,13 @@ export default function ToolResourceSelectors({
{enableAllTools ? (
<div className="flex h-24 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
<p className="text-sm text-muted-foreground">
{t('pipelines.localAgent.allToolsEnabled')}
{t('pipelines.agentRunner.allToolsEnabled')}
</p>
</div>
) : selectedTools.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.localAgent.noToolsSelected')}
{t('pipelines.agentRunner.noToolsSelected')}
</p>
</div>
) : (
@@ -701,16 +759,12 @@ export default function ToolResourceSelectors({
className="w-full"
disabled={enableAllTools}
onClick={() => {
setTempSelectedToolNames(
selectedToolNames.filter((name: string) =>
availableToolNames.has(name),
),
);
setTempSelectedToolNames(selectedToolNames);
setToolsDialogOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.localAgent.editTools')}
{t('pipelines.agentRunner.editTools')}
</Button>
</div>
)}
@@ -719,10 +773,10 @@ export default function ToolResourceSelectors({
<div className="space-y-4 rounded-lg border p-4">
<div>
<h3 className="text-sm font-semibold">
{t('pipelines.localAgent.resourcesTitle')}
{t('pipelines.agentRunner.resourcesTitle')}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t('pipelines.localAgent.resourcesDescription')}
{t('pipelines.agentRunner.resourcesDescription')}
</p>
</div>
@@ -731,7 +785,7 @@ export default function ToolResourceSelectors({
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t('pipelines.localAgent.knowledgeBases')}
{t('pipelines.agentRunner.knowledgeBases')}
</span>
</div>
<Button
@@ -801,24 +855,26 @@ export default function ToolResourceSelectors({
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t('pipelines.localAgent.mcpResources')}
{t('pipelines.agentRunner.mcpResources')}
</span>
<InfoTooltip
label={t('pipelines.localAgent.mcpResourcesScopeTooltip')}
/>
{pipelineId && (
<InfoTooltip
label={t('pipelines.agentRunner.mcpResourcesScopeTooltip')}
/>
)}
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="local-agent-mcp-resource-read"
htmlFor="agent-runner-mcp-resource-read"
className="cursor-pointer text-sm font-normal"
>
{t('pipelines.localAgent.enableMCPResourceRead')}
{t('pipelines.agentRunner.enableMCPResourceRead')}
</Label>
<InfoTooltip
label={t('pipelines.localAgent.mcpResourceReadTooltip')}
label={t('pipelines.agentRunner.mcpResourceReadTooltip')}
/>
<Switch
id="local-agent-mcp-resource-read"
id="agent-runner-mcp-resource-read"
checked={mcpResourceReadEnabled}
onCheckedChange={(checked) =>
onChange({ 'mcp-resource-agent-read-enabled': checked })
@@ -830,7 +886,7 @@ export default function ToolResourceSelectors({
{resourceServers.length === 0 ? (
<div className="flex h-20 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.localAgent.noMCPResourcesAvailable')}
{t('pipelines.agentRunner.noMCPResourcesAvailable')}
</p>
</div>
) : (
@@ -900,7 +956,9 @@ export default function ToolResourceSelectors({
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{t('pipelines.localAgent.selectTools')}</DialogTitle>
<DialogTitle>
{t('pipelines.agentRunner.selectTools')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 space-y-5 overflow-y-auto pr-2">
{availableToolGroups.map((sourceGroup) => {
@@ -910,15 +968,17 @@ export default function ToolResourceSelectors({
<span className="text-sm font-semibold">
{sourceGroup.label}
</span>
{sourceGroup.key === 'mcp' && (
{pipelineId && sourceGroup.key === 'mcp' && (
<InfoTooltip
label={t('pipelines.localAgent.mcpToolsScopeTooltip')}
label={t(
'pipelines.agentRunner.mcpToolsScopeTooltip',
)}
/>
)}
{sourceGroup.key === 'skill' && (
<InfoTooltip
label={t(
'pipelines.localAgent.skillToolsScopeTooltip',
'pipelines.agentRunner.skillToolsScopeTooltip',
)}
/>
)}
@@ -987,7 +1047,7 @@ export default function ToolResourceSelectors({
{availableToolGroups.length === 0 && (
<div className="flex h-24 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.localAgent.noToolsSelected')}
{t('pipelines.agentRunner.noToolsSelected')}
</p>
</div>
)}
@@ -1012,7 +1072,7 @@ export default function ToolResourceSelectors({
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{t('pipelines.localAgent.selectKnowledgeBases')}
{t('pipelines.agentRunner.selectKnowledgeBases')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 space-y-2 overflow-y-auto pr-2">
@@ -9,10 +9,6 @@ interface MessageDetailsCardProps {
export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
const { t } = useTranslation();
const isLocalAgent = [
'local-agent',
'plugin:langbot-team/LocalAgent/default',
].includes(details.message?.runnerName ?? '');
// Parse query variables JSON string
const queryVariables = useMemo(() => {
@@ -205,49 +201,45 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
</div>
)}
{/* Query Variables Section - Only show for non-local-agent runners */}
{queryVariables &&
Object.keys(queryVariables).length > 0 &&
!isLocalAgent && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<Braces className="w-4 h-4 mr-2" />
{t('monitoring.queryVariables.title')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{Object.entries(queryVariables).map(([key, value]) => (
<div key={key} className="bg-background rounded p-2">
<div className="text-muted-foreground">{key}</div>
<div
className="font-medium text-foreground truncate"
title={
typeof value === 'string' ? value : JSON.stringify(value)
}
>
{value === null || value === undefined ? (
<span className="text-muted-foreground italic">null</span>
) : typeof value === 'string' ? (
value || (
<span className="text-muted-foreground italic">
empty
</span>
)
) : (
JSON.stringify(value)
)}
</div>
{/* Query Variables Section */}
{queryVariables && Object.keys(queryVariables).length > 0 && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<Braces className="w-4 h-4 mr-2" />
{t('monitoring.queryVariables.title')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{Object.entries(queryVariables).map(([key, value]) => (
<div key={key} className="bg-background rounded p-2">
<div className="text-muted-foreground">{key}</div>
<div
className="font-medium text-foreground truncate"
title={
typeof value === 'string' ? value : JSON.stringify(value)
}
>
{value === null || value === undefined ? (
<span className="text-muted-foreground italic">null</span>
) : typeof value === 'string' ? (
value || (
<span className="text-muted-foreground italic">
empty
</span>
)
) : (
JSON.stringify(value)
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* No data message */}
{(!details.llmCalls || details.llmCalls.length === 0) &&
(!details.errors || details.errors.length === 0) &&
(isLocalAgent ||
!queryVariables ||
Object.keys(queryVariables).length === 0) && (
(!queryVariables || Object.keys(queryVariables).length === 0) && (
<div className="text-sm text-muted-foreground text-center py-4">
{t('monitoring.messageDetails.noData')}
</div>
@@ -35,6 +35,7 @@ export default function PipelineDetailContent({
const [activeTab, setActiveTab] = useState('config');
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
function handleFinish() {
refreshPipelines();
@@ -53,7 +54,7 @@ export default function PipelineDetailContent({
<h1 className="text-xl font-semibold">
{t('pipelines.createPipeline')}
</h1>
<Button type="submit" form="pipeline-form">
<Button type="submit" form="pipeline-form" disabled={formSaving}>
{t('common.submit')}
</Button>
</div>
@@ -68,6 +69,7 @@ export default function PipelineDetailContent({
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
onSavingChange={setFormSaving}
/>
</div>
</div>
@@ -89,7 +91,7 @@ export default function PipelineDetailContent({
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty}
disabled={!formDirty || formSaving}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
@@ -140,6 +142,7 @@ export default function PipelineDetailContent({
onDeletePipeline={handleDeletePipeline}
onCancel={() => navigate(routeBase)}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
/>
</TabsContent>
@@ -59,6 +59,7 @@ export default function PipelineFormComponent({
onDeletePipeline,
onCancel,
onDirtyChange,
onSavingChange,
}: {
pipelineId?: string;
isEditMode: boolean;
@@ -69,11 +70,14 @@ export default function PipelineFormComponent({
onDeletePipeline: () => void;
onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
}) {
const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState(false);
const isSavingRef = useRef(false);
const formSchema = isEditMode
? z.object({
@@ -176,16 +180,20 @@ export default function PipelineFormComponent({
output: {},
},
});
const dynamicFormSystemContext = useMemo(
() => ({ pipeline_id: pipelineId }),
[pipelineId],
);
// Track unsaved changes by comparing current form values against a saved snapshot
const savedSnapshotRef = useRef<string>('');
// Track which dynamic form stages have completed their initial mount emission.
const initializedStagesRef = useRef<Set<string>>(new Set());
const watchedValues = form.watch();
const hasUnsavedChanges = useMemo(() => {
const hasUnsavedChanges = (() => {
if (!isEditMode || !savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [isEditMode, watchedValues]);
})();
// Keep a ref so that non-reactive callbacks (handleDynamicFormEmit) can
// read the latest dirty state without stale closures.
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
@@ -257,12 +265,16 @@ export default function PipelineFormComponent({
}
function handleCreate(values: FormValues) {
if (isSavingRef.current) return;
const pipeline: Pipeline = {
config: {},
description: values.basic.description ?? '',
name: values.basic.name,
emoji: values.basic.emoji,
};
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.createPipeline(pipeline)
.then((resp) => {
@@ -272,10 +284,17 @@ export default function PipelineFormComponent({
})
.catch((err) => {
toast.error(t('pipelines.createError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
}
function handleModify(values: FormValues) {
if (isSavingRef.current) return;
const submittedSnapshot = JSON.stringify(values);
const realConfig = {
ai: values.ai,
trigger: values.trigger,
@@ -295,15 +314,23 @@ export default function PipelineFormComponent({
// uuid: pipelineId || '',
// is_default: false,
};
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.updatePipeline(pipelineId || '', pipeline)
.then(() => {
savedSnapshotRef.current = JSON.stringify(form.getValues());
savedSnapshotRef.current = submittedSnapshot;
onFinish();
toast.success(t('pipelines.saveSuccess'));
})
.catch((err) => {
toast.error(t('pipelines.saveError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
}
@@ -390,6 +417,7 @@ export default function PipelineFormComponent({
stage.name
] || {}
}
systemContext={dynamicFormSystemContext}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
@@ -424,6 +452,7 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={stageInitialValues}
systemContext={dynamicFormSystemContext}
onSubmit={(values) => {
handleRunnerConfigEmit(stage.name, values);
}}
@@ -451,6 +480,7 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={stageInitialValues}
systemContext={dynamicFormSystemContext}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
@@ -746,7 +776,7 @@ export default function PipelineFormComponent({
</Button>
)}
<Button type="submit" form="pipeline-form">
<Button type="submit" form="pipeline-form" disabled={isSaving}>
{isEditMode ? t('common.save') : t('common.submit')}
</Button>
</div>