feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import type {
|
||||
ChatArtifactAttachment,
|
||||
ChatTimelineArtifactItem,
|
||||
ChatTimelineItem,
|
||||
ChatTimelineItemBase,
|
||||
ChatTimelineKnowledgeHit,
|
||||
ChatTimelineMessageItem,
|
||||
ChatTimelineMessagePart,
|
||||
ChatTimelineSkillInvocationStatus,
|
||||
ChatTimelineStatusItem,
|
||||
ChatTimelineStatusStatus,
|
||||
ChatTimelineStatusTone,
|
||||
@@ -13,6 +17,17 @@ import type {
|
||||
ChatTimelineToolStatus,
|
||||
} from './types';
|
||||
|
||||
type ChatTimelineTurnMetadata = Partial<
|
||||
Pick<
|
||||
ChatTimelineItemBase,
|
||||
| 'roundCompleted'
|
||||
| 'roundId'
|
||||
| 'turnFinishedAt'
|
||||
| 'turnStartedAt'
|
||||
| 'turnSucceeded'
|
||||
>
|
||||
>;
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
@@ -73,6 +88,24 @@ function ensureMessageTail(
|
||||
Object.assign(last, metadata);
|
||||
return last;
|
||||
}
|
||||
const placeholder =
|
||||
role === 'assistant' && metadata?.roundId
|
||||
? [...items]
|
||||
.reverse()
|
||||
.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' &&
|
||||
item.role === 'assistant' &&
|
||||
item.roundId === metadata.roundId &&
|
||||
item.status !== 'done' &&
|
||||
item.parts.length === 0,
|
||||
)
|
||||
: undefined;
|
||||
if (placeholder) {
|
||||
placeholder.status = status;
|
||||
Object.assign(placeholder, metadata);
|
||||
return placeholder;
|
||||
}
|
||||
const item: ChatTimelineMessageItem = {
|
||||
id: createId(role),
|
||||
role,
|
||||
@@ -138,19 +171,18 @@ function updateThinkingStatus(
|
||||
);
|
||||
}
|
||||
|
||||
function finishLastAssistantMessage(items: ChatTimelineItem[]) {
|
||||
finishAssistantMessage(items, true);
|
||||
}
|
||||
|
||||
function finishAssistantMessage(
|
||||
items: ChatTimelineItem[],
|
||||
roundCompleted: boolean,
|
||||
roundId?: string,
|
||||
) {
|
||||
const lastMessage = [...items]
|
||||
.reverse()
|
||||
.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
item.type === 'message' &&
|
||||
item.role === 'assistant' &&
|
||||
(!roundId || item.roundId === roundId),
|
||||
);
|
||||
if (!lastMessage) {
|
||||
return;
|
||||
@@ -167,6 +199,7 @@ function findToolItem(
|
||||
toolCallId?: string,
|
||||
taskId?: string,
|
||||
sourceToolCallId?: string,
|
||||
approvalId?: string,
|
||||
) {
|
||||
const identities = new Set(
|
||||
[toolCallId, sourceToolCallId]
|
||||
@@ -174,13 +207,16 @@ function findToolItem(
|
||||
.filter(Boolean),
|
||||
);
|
||||
const normalizedTaskId = normalizeText(taskId).trim();
|
||||
if (identities.size === 0 && !normalizedTaskId) {
|
||||
const normalizedApprovalId = normalizeText(approvalId).trim();
|
||||
if (identities.size === 0 && !normalizedTaskId && !normalizedApprovalId) {
|
||||
return undefined;
|
||||
}
|
||||
return items.find(
|
||||
(item): item is ChatTimelineToolItem =>
|
||||
item.type === 'tool' &&
|
||||
((Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) ||
|
||||
((Boolean(normalizedApprovalId) &&
|
||||
item.approval?.approvalId === normalizedApprovalId) ||
|
||||
(Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) ||
|
||||
(item.toolCallId ? identities.has(item.toolCallId) : false)),
|
||||
);
|
||||
}
|
||||
@@ -211,9 +247,33 @@ function doneStatusLabel(item: ChatTimelineStatusItem) {
|
||||
return item.label.replace(/^正在/, '已');
|
||||
}
|
||||
|
||||
function finishRunningStatusItems(items: ChatTimelineItem[]) {
|
||||
function skillTerminalLabel(
|
||||
label: string,
|
||||
status: Extract<ChatTimelineStatusStatus, 'cancelled' | 'incomplete'>,
|
||||
) {
|
||||
const name = label.replace(/^正在调用\s*/, '').trim() || '技能';
|
||||
return status === 'cancelled' ? `已停止调用 ${name}` : `调用 ${name} 未完成`;
|
||||
}
|
||||
|
||||
function finishRunningStatusItems(
|
||||
items: ChatTimelineItem[],
|
||||
roundId?: string,
|
||||
skillTerminalStatus: Extract<
|
||||
ChatTimelineStatusStatus,
|
||||
'cancelled' | 'incomplete'
|
||||
> = 'incomplete',
|
||||
) {
|
||||
items.forEach((item) => {
|
||||
if (item.type !== 'status' || item.status !== 'running') {
|
||||
if (
|
||||
item.type !== 'status' ||
|
||||
item.status !== 'running' ||
|
||||
(roundId && item.roundId !== roundId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (item.icon === 'skill') {
|
||||
item.status = skillTerminalStatus;
|
||||
item.label = skillTerminalLabel(item.label, skillTerminalStatus);
|
||||
return;
|
||||
}
|
||||
item.status = 'done';
|
||||
@@ -221,9 +281,36 @@ function finishRunningStatusItems(items: ChatTimelineItem[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function skillStatusPresentation(status: ChatTimelineSkillInvocationStatus) {
|
||||
switch (status) {
|
||||
case 'CANCELLED': {
|
||||
return {
|
||||
prefix: '已停止调用',
|
||||
status: 'cancelled' as const,
|
||||
};
|
||||
}
|
||||
case 'FAILED': {
|
||||
return { prefix: '调用', status: 'error' as const, suffix: '失败' };
|
||||
}
|
||||
case 'INCOMPLETE': {
|
||||
return {
|
||||
prefix: '调用',
|
||||
status: 'incomplete' as const,
|
||||
suffix: '未完成',
|
||||
};
|
||||
}
|
||||
case 'RUNNING': {
|
||||
return { prefix: '正在调用', status: 'running' as const };
|
||||
}
|
||||
case 'SUCCESS': {
|
||||
return { prefix: '已调用', status: 'done' as const };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function upsertStatus(
|
||||
items: ChatTimelineItem[],
|
||||
payload: {
|
||||
payload: ChatTimelineTurnMetadata & {
|
||||
label: string;
|
||||
presentation?: ChatTimelineStatusItem['presentation'];
|
||||
status: ChatTimelineStatusStatus;
|
||||
@@ -237,6 +324,7 @@ function upsertStatus(
|
||||
found.presentation = payload.presentation ?? found.presentation;
|
||||
found.status = payload.status;
|
||||
found.tone = payload.tone ?? found.tone;
|
||||
applyTurnMetadata(found, payload);
|
||||
return found;
|
||||
}
|
||||
const item: ChatTimelineStatusItem = {
|
||||
@@ -249,20 +337,20 @@ function upsertStatus(
|
||||
tone: payload.tone ?? 'muted',
|
||||
type: 'status',
|
||||
};
|
||||
applyTurnMetadata(item, payload);
|
||||
items.push(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function upsertTool(
|
||||
items: ChatTimelineItem[],
|
||||
payload: {
|
||||
payload: ChatTimelineTurnMetadata & {
|
||||
approval?: ChatTimelineToolApprovalPayload;
|
||||
approvalId?: string;
|
||||
input?: unknown;
|
||||
mode?: ChatTimelineToolMode;
|
||||
output?: unknown;
|
||||
rejectReason?: string;
|
||||
requestId?: string;
|
||||
resumeToken?: string;
|
||||
sourceToolCallId?: string;
|
||||
status?: ChatTimelineToolStatus;
|
||||
taskId?: string;
|
||||
@@ -279,6 +367,7 @@ function upsertTool(
|
||||
toolCallId,
|
||||
taskId,
|
||||
payload.sourceToolCallId,
|
||||
payload.approvalId ?? payload.approval?.approvalId,
|
||||
);
|
||||
const approval = payload.approval ?? found?.approval;
|
||||
const mode =
|
||||
@@ -310,6 +399,7 @@ function upsertTool(
|
||||
found.taskId = taskId || found.taskId;
|
||||
found.toolCallId = toolCallId || found.toolCallId;
|
||||
found.toolName = toolName || found.toolName;
|
||||
applyTurnMetadata(found, payload);
|
||||
return found;
|
||||
}
|
||||
|
||||
@@ -330,11 +420,49 @@ function upsertTool(
|
||||
toolName: toolName || '工具调用',
|
||||
type: 'tool',
|
||||
};
|
||||
applyTurnMetadata(toolItem, payload);
|
||||
items.push(toolItem);
|
||||
return toolItem;
|
||||
}
|
||||
|
||||
function applyTurnMetadata(
|
||||
item: ChatTimelineItemBase,
|
||||
metadata?: ChatTimelineTurnMetadata,
|
||||
) {
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
if (metadata.roundId) {
|
||||
item.roundId = metadata.roundId;
|
||||
}
|
||||
if (metadata.roundCompleted !== undefined) {
|
||||
item.roundCompleted = metadata.roundCompleted;
|
||||
}
|
||||
if (metadata.turnSucceeded !== undefined) {
|
||||
item.turnSucceeded = metadata.turnSucceeded;
|
||||
}
|
||||
if (metadata.turnStartedAt !== undefined) {
|
||||
item.turnStartedAt = Math.min(
|
||||
item.turnStartedAt ?? metadata.turnStartedAt,
|
||||
metadata.turnStartedAt,
|
||||
);
|
||||
}
|
||||
if (metadata.turnFinishedAt !== undefined) {
|
||||
item.turnFinishedAt = Math.max(
|
||||
item.turnFinishedAt ?? metadata.turnFinishedAt,
|
||||
metadata.turnFinishedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ChatTimelineBuilder = {
|
||||
ensureAssistantTurn(
|
||||
items: ChatTimelineItem[],
|
||||
metadata?: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
ensureMessageTail(items, 'assistant', 'streaming', metadata);
|
||||
},
|
||||
|
||||
appendUserMessage(
|
||||
items: ChatTimelineItem[],
|
||||
content?: unknown,
|
||||
@@ -410,12 +538,45 @@ export const ChatTimelineBuilder = {
|
||||
appendTextPart(message, text);
|
||||
},
|
||||
|
||||
replaceMessageContent(items: ChatTimelineItem[], content?: unknown) {
|
||||
replaceMessageContent(
|
||||
items: ChatTimelineItem[],
|
||||
content?: unknown,
|
||||
metadata?: Partial<ChatTimelineMessageItem>,
|
||||
) {
|
||||
const text = normalizeText(content);
|
||||
if (!text) {
|
||||
return;
|
||||
const message =
|
||||
(metadata?.id
|
||||
? items.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.id === metadata.id,
|
||||
)
|
||||
: undefined) ||
|
||||
[...items]
|
||||
.reverse()
|
||||
.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' &&
|
||||
item.role === 'assistant' &&
|
||||
(!metadata?.roundId || item.roundId === metadata.roundId),
|
||||
) ||
|
||||
ensureMessageTail(items, 'assistant', 'done', metadata);
|
||||
for (let index = items.length - 1; index >= 0; index--) {
|
||||
const item = items[index];
|
||||
if (
|
||||
item === message ||
|
||||
item?.type !== 'message' ||
|
||||
item.role !== 'assistant' ||
|
||||
(metadata?.roundId && item.roundId !== metadata.roundId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
item.parts = item.parts.filter((part) => part.type !== 'text');
|
||||
if (item.parts.length === 0) {
|
||||
items.splice(index, 1);
|
||||
}
|
||||
}
|
||||
const message = ensureMessageTail(items, 'assistant', 'done');
|
||||
Object.assign(message, metadata);
|
||||
message.status = 'done';
|
||||
updateThinkingStatus(message, 'end');
|
||||
replaceTextPart(message, text);
|
||||
},
|
||||
@@ -423,8 +584,10 @@ export const ChatTimelineBuilder = {
|
||||
appendToolApproval(
|
||||
items: ChatTimelineItem[],
|
||||
payload: ChatTimelineToolApprovalPayload,
|
||||
metadata?: ChatTimelineTurnMetadata,
|
||||
) {
|
||||
upsertTool(items, {
|
||||
...metadata,
|
||||
approval: payload,
|
||||
input: payload.input,
|
||||
mode: 'approval',
|
||||
@@ -436,7 +599,8 @@ export const ChatTimelineBuilder = {
|
||||
|
||||
upsertToolCall(
|
||||
items: ChatTimelineItem[],
|
||||
payload: {
|
||||
payload: ChatTimelineTurnMetadata & {
|
||||
approvalId?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
sourceToolCallId?: string;
|
||||
@@ -452,6 +616,7 @@ export const ChatTimelineBuilder = {
|
||||
items,
|
||||
payload.status === 'success' ? 'done' : 'running',
|
||||
payload.statusKey,
|
||||
payload,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -466,9 +631,11 @@ export const ChatTimelineBuilder = {
|
||||
items: ChatTimelineItem[],
|
||||
status: ChatTimelineStatusStatus,
|
||||
statusKey?: string,
|
||||
metadata?: ChatTimelineTurnMetadata,
|
||||
) {
|
||||
finishAssistantMessage(items, false);
|
||||
finishAssistantMessage(items, false, metadata?.roundId);
|
||||
upsertStatus(items, {
|
||||
...metadata,
|
||||
label: status === 'running' ? '正在检索知识库' : '已检索知识库',
|
||||
status,
|
||||
statusKey: knowledgeRetrievalStatusKey(statusKey),
|
||||
@@ -476,9 +643,32 @@ export const ChatTimelineBuilder = {
|
||||
});
|
||||
},
|
||||
|
||||
upsertSkillInvocationStatus(
|
||||
items: ChatTimelineItem[],
|
||||
payload: ChatTimelineTurnMetadata & {
|
||||
displayName?: string;
|
||||
status: ChatTimelineSkillInvocationStatus;
|
||||
statusKey: string;
|
||||
},
|
||||
) {
|
||||
const displayName = normalizeText(payload.displayName).trim() || '技能';
|
||||
const presentation = skillStatusPresentation(payload.status);
|
||||
const label = [presentation.prefix, displayName, presentation.suffix]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
finishAssistantMessage(items, false, payload.roundId);
|
||||
upsertStatus(items, {
|
||||
...payload,
|
||||
label,
|
||||
status: presentation.status,
|
||||
statusKey: payload.statusKey,
|
||||
tone: payload.status === 'FAILED' ? 'danger' : 'muted',
|
||||
}).icon = 'skill';
|
||||
},
|
||||
|
||||
upsertMemoryCompressionStatus(
|
||||
items: ChatTimelineItem[],
|
||||
payload?: {
|
||||
payload?: ChatTimelineTurnMetadata & {
|
||||
compressed?: boolean;
|
||||
label?: string;
|
||||
phase?: string;
|
||||
@@ -491,7 +681,7 @@ export const ChatTimelineBuilder = {
|
||||
? 'done'
|
||||
: 'running';
|
||||
const statusKey = payload?.statusKey || 'memory-compression';
|
||||
finishAssistantMessage(items, false);
|
||||
finishAssistantMessage(items, false, payload?.roundId);
|
||||
if (status === 'done' && payload?.compressed === false) {
|
||||
removeStatusItem(items, statusKey);
|
||||
return;
|
||||
@@ -501,6 +691,7 @@ export const ChatTimelineBuilder = {
|
||||
? payload?.label || '正在整理上下文'
|
||||
: payload?.label || '已整理上下文';
|
||||
upsertStatus(items, {
|
||||
...payload,
|
||||
label,
|
||||
status,
|
||||
statusKey,
|
||||
@@ -511,9 +702,8 @@ export const ChatTimelineBuilder = {
|
||||
|
||||
markToolApproving(
|
||||
items: ChatTimelineItem[],
|
||||
payload: {
|
||||
requestId?: string;
|
||||
resumeToken?: string;
|
||||
payload: ChatTimelineTurnMetadata & {
|
||||
approvalId?: string;
|
||||
toolCallId?: string;
|
||||
},
|
||||
) {
|
||||
@@ -526,10 +716,9 @@ export const ChatTimelineBuilder = {
|
||||
|
||||
markToolRejected(
|
||||
items: ChatTimelineItem[],
|
||||
payload: {
|
||||
payload: ChatTimelineTurnMetadata & {
|
||||
approvalId?: string;
|
||||
reason?: string;
|
||||
requestId?: string;
|
||||
resumeToken?: string;
|
||||
toolCallId?: string;
|
||||
},
|
||||
) {
|
||||
@@ -544,6 +733,7 @@ export const ChatTimelineBuilder = {
|
||||
appendKnowledge(
|
||||
items: ChatTimelineItem[],
|
||||
knowledgeItems: ChatTimelineKnowledgeHit[],
|
||||
metadata?: ChatTimelineTurnMetadata,
|
||||
) {
|
||||
if (knowledgeItems.length === 0) {
|
||||
return;
|
||||
@@ -552,9 +742,12 @@ export const ChatTimelineBuilder = {
|
||||
.reverse()
|
||||
.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' && item.role === 'assistant',
|
||||
item.type === 'message' &&
|
||||
item.role === 'assistant' &&
|
||||
(!metadata?.roundId || item.roundId === metadata.roundId),
|
||||
);
|
||||
if (lastAssistantMessage) {
|
||||
applyTurnMetadata(lastAssistantMessage, metadata);
|
||||
lastAssistantMessage.knowledgeItems = [
|
||||
...(lastAssistantMessage.knowledgeItems || []),
|
||||
...knowledgeItems,
|
||||
@@ -562,36 +755,119 @@ export const ChatTimelineBuilder = {
|
||||
return;
|
||||
}
|
||||
const last = items[items.length - 1];
|
||||
if (last?.type === 'knowledge') {
|
||||
if (
|
||||
last?.type === 'knowledge' &&
|
||||
(!metadata?.roundId || last.roundId === metadata.roundId)
|
||||
) {
|
||||
applyTurnMetadata(last, metadata);
|
||||
last.items.push(...knowledgeItems);
|
||||
return;
|
||||
}
|
||||
items.push({
|
||||
const item = {
|
||||
id: createId('knowledge'),
|
||||
createdAt: Date.now(),
|
||||
items: knowledgeItems,
|
||||
type: 'knowledge',
|
||||
});
|
||||
type: 'knowledge' as const,
|
||||
};
|
||||
applyTurnMetadata(item, metadata);
|
||||
items.push(item);
|
||||
},
|
||||
|
||||
appendError(items: ChatTimelineItem[], message?: unknown) {
|
||||
upsertArtifact(
|
||||
items: ChatTimelineItem[],
|
||||
artifact: ChatArtifactAttachment,
|
||||
metadata?: ChatTimelineTurnMetadata,
|
||||
) {
|
||||
const artifactId = normalizeText(artifact.artifactId).trim();
|
||||
const fileName = normalizeText(artifact.fileName).trim();
|
||||
if (!artifactId || !fileName) {
|
||||
return;
|
||||
}
|
||||
const existing = items.find(
|
||||
(item): item is ChatTimelineArtifactItem =>
|
||||
item.type === 'artifact' && item.artifactId === artifactId,
|
||||
);
|
||||
if (existing) {
|
||||
existing.downloadUrl = artifact.downloadUrl;
|
||||
existing.fileName = fileName;
|
||||
existing.mimeType = artifact.mimeType;
|
||||
existing.sha256 = artifact.sha256;
|
||||
existing.size = artifact.size;
|
||||
existing.status = artifact.status;
|
||||
applyTurnMetadata(existing, metadata);
|
||||
return;
|
||||
}
|
||||
const item = {
|
||||
artifactId,
|
||||
createdAt: Date.now(),
|
||||
downloadUrl: artifact.downloadUrl,
|
||||
fileName,
|
||||
id: `artifact:${artifactId}`,
|
||||
mimeType: artifact.mimeType,
|
||||
sha256: artifact.sha256,
|
||||
size: artifact.size,
|
||||
status: artifact.status,
|
||||
type: 'artifact' as const,
|
||||
};
|
||||
applyTurnMetadata(item, metadata);
|
||||
items.push(item);
|
||||
},
|
||||
|
||||
appendError(
|
||||
items: ChatTimelineItem[],
|
||||
message?: unknown,
|
||||
metadata?: ChatTimelineTurnMetadata,
|
||||
) {
|
||||
const text = normalizeText(message) || '请求失败';
|
||||
const last = items[items.length - 1];
|
||||
if (last?.type === 'message' && last.role === 'assistant') {
|
||||
const last = [...items]
|
||||
.reverse()
|
||||
.find(
|
||||
(item): item is ChatTimelineMessageItem =>
|
||||
item.type === 'message' &&
|
||||
item.role === 'assistant' &&
|
||||
(!metadata?.roundId || item.roundId === metadata.roundId),
|
||||
);
|
||||
if (last) {
|
||||
updateThinkingStatus(last, 'error');
|
||||
last.status = 'error';
|
||||
}
|
||||
items.push({
|
||||
const item = {
|
||||
id: createId('error'),
|
||||
createdAt: Date.now(),
|
||||
message: text,
|
||||
type: 'error',
|
||||
});
|
||||
type: 'error' as const,
|
||||
};
|
||||
applyTurnMetadata(item, metadata);
|
||||
items.push(item);
|
||||
},
|
||||
|
||||
finalize(items: ChatTimelineItem[]) {
|
||||
finishRunningStatusItems(items);
|
||||
finishLastAssistantMessage(items);
|
||||
finalize(
|
||||
items: ChatTimelineItem[],
|
||||
metadata?: ChatTimelineTurnMetadata,
|
||||
options?: {
|
||||
runningSkillStatus?: Extract<
|
||||
ChatTimelineStatusStatus,
|
||||
'cancelled' | 'incomplete'
|
||||
>;
|
||||
},
|
||||
) {
|
||||
finishRunningStatusItems(
|
||||
items,
|
||||
metadata?.roundId,
|
||||
options?.runningSkillStatus,
|
||||
);
|
||||
finishAssistantMessage(
|
||||
items,
|
||||
metadata?.turnSucceeded ?? true,
|
||||
metadata?.roundId,
|
||||
);
|
||||
if (metadata?.roundId) {
|
||||
for (const item of items) {
|
||||
if (item.roundId === metadata.roundId) {
|
||||
applyTurnMetadata(item, metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
replaceRoundAssistant(
|
||||
|
||||
Reference in New Issue
Block a user