Browse Source
- 复用 agentMessages 表 + 新增来源字段 (source/a2aTaskId/callerAgentId/callerSessionId) - sendTask 从 generateText 改为 streamText + streamBuffer - 新增 A2A SSE 端点供前端侧边面板订阅 - 新增 AgentInvokePanel 侧边面板组件,只读流式展示 - 扩展 ToolContext/executeAgentTool 传递 sessionId Co-authored-by: CodeFree <codefree@chinatelcom.cn>feat/agent-independence
17 changed files with 4370 additions and 109 deletions
@ -1,6 +1,6 @@ |
|||
{ |
|||
"pid": 2002, |
|||
"pid": 2264, |
|||
"version": "0.9.7", |
|||
"socketPath": "/home/dash/code/nuxt-app/.codegraph/daemon.sock", |
|||
"startedAt": 1786289048581 |
|||
"startedAt": 1786457265175 |
|||
} |
|||
|
|||
@ -0,0 +1,575 @@ |
|||
# Agent-Invoke 流式会话设计 |
|||
|
|||
**日期**: 2026-08-12 |
|||
**状态**: Draft |
|||
**关联模块**: `server/service/a2a/`, `server/service/agent-tool/executors/agent-invoke/`, `app/components/agent/`, `app/composables/useAgentChat.ts` |
|||
|
|||
## 1. 背景与目标 |
|||
|
|||
### 1.1 当前状态 |
|||
|
|||
`agent-invoke` 工具允许一个 agent 通过 A2A 协议调用另一个 agent 处理子任务。当前实现存在以下问题: |
|||
|
|||
- `sendTask`(`server/service/a2a/service.ts:128`)使用 `generateText` 同步调用 LLM,**无流式输出** |
|||
- A2A 调用产生的对话消息**未保存**到 `agentMessages` 表,调用结束后无痕迹 |
|||
- 前端无法感知 A2A 调用过程,只在 tool call 完成后看到最终结果文本 |
|||
- `a2aTaskSessions` 表仅记录 task 元数据(callerContext、state),不记录对话消息 |
|||
|
|||
### 1.2 目标 |
|||
|
|||
1. **流式展示**:A2A 调用过程中,被调用 agent 的回复实时流式展示在前端侧边面板 |
|||
2. **数据持久化**:A2A 调用产生的 user/assistant 消息保存到 `agentMessages` 表,记录调用来源信息 |
|||
3. **会话隔离**:A2A 产生的会话不在前端会话列表展示(仅用于审计/日志) |
|||
4. **只读面板**:侧边面板只读展示,用户不能在面板中继续对话 |
|||
|
|||
### 1.3 非目标 |
|||
|
|||
- 不支持用户在侧边面板中与被调用 agent 继续对话 |
|||
- 不修改 A2A 协议的 JSON-RPC 接口(仅改内部 service 层) |
|||
- 不实现 A2A 调用的 streaming SSE 公开端点(仅内部订阅) |
|||
|
|||
## 2. 架构设计 |
|||
|
|||
### 2.1 数据流总览 |
|||
|
|||
``` |
|||
调用方前端 ← SSE ← 调用方 streamText (chat-engine) |
|||
└─ tool-call: agent-invoke (args: {agentSlug, input}) |
|||
└─ agentInvokeExecutor.execute() |
|||
└─ invokeAgentViaA2A() |
|||
└─ createTask() → 创建 session + a2aTaskSessions 记录 |
|||
↓ 返回 taskId |
|||
└─ sendTask() → streamText (被调用 agent) |
|||
└─ onChunk → appendChunk(taskId, chunk) |
|||
└─ onFinish → saveMessage (source='a2a') |
|||
└─ 返回 { output, ok, taskId } |
|||
└─ 返回 ToolResult (metadata.taskId) |
|||
└─ 调用方 streamText 继续,tool-output-available |
|||
|
|||
前端侧: |
|||
tool-call (agent-invoke) 开始 |
|||
→ useAgentChat 检测到 toolName=agent-invoke |
|||
→ 自动打开 AgentInvokePanel |
|||
→ AgentInvokePanel 通过 EventSource 订阅 /api/agents/a2a/stream?taskId=xxx |
|||
← SSE chunks → 实时展示被调用 agent 回复 |
|||
tool-output-available |
|||
→ 面板可关闭,展示完成状态 |
|||
``` |
|||
|
|||
### 2.2 关键设计决策 |
|||
|
|||
#### 决策 1:streamBuffer 以 taskId 为 key |
|||
|
|||
现有 `stream-buffer.ts` 以 `sessionId` 为 key。A2A 场景下,被调用 agent 的 session 对前端不可见,但 taskId 是调用方已知的(通过 tool call metadata 传递)。 |
|||
|
|||
**方案**:streamBuffer 支持以 `taskId` 为 key 创建独立 buffer,与 session buffer 隔离。新增 `createStreamBufferByKey(key, meta)` / `appendChunkByKey(key, chunk)` / `subscribeToBufferByKey(key, ...)` 等函数,或直接复用现有函数但传入 `a2a_${taskId}` 作为 key。 |
|||
|
|||
#### 决策 2:taskId 传递给前端 |
|||
|
|||
`agentInvokeExecutor.execute()` 是同步等待 `invokeAgentViaA2A` 返回的。但前端需要在 tool 执行**期间**就订阅 SSE。 |
|||
|
|||
**方案**:`invokeAgentViaA2A` 在 `createTask` 完成后、`sendTask` 开始前,通过调用方 streamBuffer 推送一个自定义 SSE 事件 `a2a-task-started`,包含 `{ taskId, calleeAgentSlug, calleeAgentName }`。前端在收到 `tool-call` chunk(toolName=agent-invoke)后,等待后续的 `a2a-task-started` 事件获取 taskId,然后打开面板订阅。 |
|||
|
|||
**替代方案(更简单)**:executor 在 execute 开始时立即创建 task(先 createTask 拿到 taskId),将 taskId 作为 tool call 的 `args` 的一部分通过 streamText 的 `tool-input-available` 事件传递给前端。但这需要改 executor 接口。 |
|||
|
|||
**最终选择**:通过调用方 streamBuffer 推送 `a2a-task-started` 事件。原因:不改 executor 接口,复用现有 streamBuffer 机制。 |
|||
|
|||
#### 决策 3:消息保存与来源记录 |
|||
|
|||
在 `agentMessages` 表新增字段记录来源: |
|||
|
|||
| 字段 | 类型 | 默认值 | 说明 | |
|||
|------|------|--------|------| |
|||
| `source` | text | `'user'` | 消息来源:`'user'`(用户直接对话)\| `'a2a'`(A2A 调用) | |
|||
| `a2aTaskId` | text | null | A2A task ID,仅 a2a 消息有值 | |
|||
| `callerAgentId` | integer | null | 调用方 agent ID,references agents.id | |
|||
| `callerSessionId` | text | null | 调用方会话 ID | |
|||
|
|||
**会话列表过滤**:`listSessions` 查询时,排除只含 `source='a2a'` 消息的 session。实现方式:在 `a2aTaskSessions` 表新增 `visibleInSidebar` 字段(integer, default 0),`listSessions` 查询时 LEFT JOIN `a2aTaskSessions` 并过滤 `visibleInSidebar = 0` 的 session。 |
|||
|
|||
## 3. 详细设计 |
|||
|
|||
### 3.1 数据层变更 |
|||
|
|||
#### 3.1.1 Schema 变更 |
|||
|
|||
**文件**: `packages/drizzle-pkg/lib/schema/agent.ts` — `agentMessages` 表新增字段: |
|||
|
|||
```typescript |
|||
source: text("source", { length: 10 }).notNull().default("user"), |
|||
a2aTaskId: text("a2a_task_id", { length: 64 }), |
|||
callerAgentId: integer("caller_agent_id").references(() => agents.id, { onDelete: "set null" }), |
|||
callerSessionId: text("caller_session_id"), |
|||
``` |
|||
|
|||
新增索引:`idx_agent_messages_source` ON (source) |
|||
|
|||
**文件**: `packages/drizzle-pkg/lib/schema/a2a.ts` — `a2aTaskSessions` 表新增字段: |
|||
|
|||
```typescript |
|||
visibleInSidebar: integer("visible_in_sidebar").notNull().default(0), |
|||
``` |
|||
|
|||
#### 3.1.2 Migration |
|||
|
|||
使用 drizzle-kit 生成 migration: |
|||
|
|||
```bash |
|||
bun run db:generate |
|||
bun run db:migrate |
|||
``` |
|||
|
|||
#### 3.1.3 查询层变更 |
|||
|
|||
**`server/service/agent/session.ts` — `listSessions`**: |
|||
|
|||
查询条件增加:排除 `a2aTaskSessions.visibleInSidebar = 0` 的 session。 |
|||
|
|||
```sql |
|||
-- 原查询 |
|||
SELECT * FROM agent_sessions WHERE user_id = ? AND deleted_at IS NULL |
|||
|
|||
-- 新查询 |
|||
SELECT s.* FROM agent_sessions s |
|||
LEFT JOIN a2a_task_sessions a ON a.session_id = s.id |
|||
WHERE s.user_id = ? AND s.deleted_at IS NULL |
|||
AND (a.visible_in_sidebar = 1 OR a.id IS NULL) |
|||
``` |
|||
|
|||
### 3.2 后端流式改造 |
|||
|
|||
#### 3.2.1 `invokeAgentViaA2A` 改造 |
|||
|
|||
**文件**: `server/service/a2a/client.ts` |
|||
|
|||
在 `createTask` 完成后、`sendTask` 开始前,通过调用方 streamBuffer 推送 `a2a-task-started` 事件。 |
|||
|
|||
需要新增参数 `callerSessionId`(调用方会话 ID),用于找到调用方的 streamBuffer 并推送事件。 |
|||
|
|||
```typescript |
|||
export async function invokeAgentViaA2A( |
|||
invocation: AgentInvocation, |
|||
options?: { |
|||
maxRecursionDepth?: number; |
|||
timeoutMs?: number; |
|||
maxOutputTokens?: number; |
|||
callerSessionId?: string; // 新增 |
|||
}, |
|||
): Promise<AgentInvocationResult> { |
|||
// ... createTask 后 |
|||
const task = await createTask(...); |
|||
|
|||
// 推送 a2a-task-started 事件到调用方 streamBuffer |
|||
if (options?.callerSessionId) { |
|||
const event = `data: ${JSON.stringify({ |
|||
type: "a2a-task-started", |
|||
taskId: task.id, |
|||
calleeAgentSlug: agentSlug, |
|||
calleeAgentName: agent.name, |
|||
})}\n\n`; |
|||
appendChunk(options.callerSessionId, new TextEncoder().encode(event)); |
|||
} |
|||
|
|||
// sendTask 改为流式 |
|||
const completedTask = await sendTask(task.id, a2aContext); |
|||
// ... |
|||
} |
|||
``` |
|||
|
|||
返回结果新增 `taskId`: |
|||
|
|||
```typescript |
|||
return { agentSlug, output, ok: true, taskId: task.id }; |
|||
``` |
|||
|
|||
#### 3.2.2 `sendTask` 改造 |
|||
|
|||
**文件**: `server/service/a2a/service.ts` |
|||
|
|||
从 `generateText` 改为 `streamText`,流式过程中: |
|||
1. 创建以 `a2a_${taskId}` 为 key 的 streamBuffer |
|||
2. `onChunk` 将文本增量编码为 SSE 格式写入 buffer |
|||
3. `onFinish` 保存 user/assistant 消息到 `agentMessages`(带来源字段) |
|||
|
|||
```typescript |
|||
export async function sendTask(taskId, context): Promise<A2ATask> { |
|||
// ... 前置校验同原逻辑 |
|||
|
|||
// 创建 A2A streamBuffer |
|||
const a2aBufferKey = `a2a_${taskId}`; |
|||
createStreamBuffer(a2aBufferKey, { modelId: resolved.dbId }); |
|||
|
|||
// 保存 user 消息(source='a2a') |
|||
const userSortOrder = (await getMaxSortOrder(taskRow.sessionId)) + 1; |
|||
await saveMessage({ |
|||
sessionId: taskRow.sessionId, |
|||
role: "user", |
|||
content: inputText, |
|||
sortOrder: userSortOrder, |
|||
// 新增字段 |
|||
source: "a2a", |
|||
a2aTaskId: taskId, |
|||
callerAgentId: context.callerAgentId, |
|||
callerSessionId: context.callerSessionId, // 需在 A2AInvocationContext 新增 |
|||
}); |
|||
|
|||
const result = streamText({ |
|||
model: languageModel, |
|||
system: agent.systemPrompt, |
|||
prompt: inputText, |
|||
tools, |
|||
stopWhen: stepCountIs(maxSteps), |
|||
maxOutputTokens, |
|||
abortSignal: combinedSignal, |
|||
onChunk: ({ chunk }) => { |
|||
if (chunk.type === "text-delta") { |
|||
const sseChunk = `data: ${JSON.stringify({ |
|||
type: "text-delta", |
|||
textDelta: chunk.text, |
|||
})}\n\n`; |
|||
appendChunk(a2aBufferKey, new TextEncoder().encode(sseChunk)); |
|||
} |
|||
}, |
|||
onFinish: async ({ text, usage }) => { |
|||
// 保存 assistant 消息 |
|||
const assistantSortOrder = (await getMaxSortOrder(taskRow.sessionId)) + 1; |
|||
await saveMessage({ |
|||
sessionId: taskRow.sessionId, |
|||
role: "assistant", |
|||
content: text ?? "", |
|||
modelId: resolved.dbId, |
|||
inputTokens: usage?.inputTokens ?? null, |
|||
outputTokens: usage?.outputTokens ?? null, |
|||
sortOrder: assistantSortOrder, |
|||
source: "a2a", |
|||
a2aTaskId: taskId, |
|||
callerAgentId: context.callerAgentId, |
|||
callerSessionId: context.callerSessionId, |
|||
}); |
|||
markBufferDone(a2aBufferKey); |
|||
await updateTaskState(taskId, "completed"); |
|||
}, |
|||
}); |
|||
|
|||
// 等待流式完成 |
|||
await result.text; |
|||
|
|||
const updatedRow = await getTaskRow(taskId); |
|||
return rowToTask(updatedRow!, textToA2AMessage(output, "agent")); |
|||
} |
|||
``` |
|||
|
|||
#### 3.2.3 `A2AInvocationContext` 类型扩展 |
|||
|
|||
**文件**: `server/service/a2a/types.ts` |
|||
|
|||
```typescript |
|||
export interface A2AInvocationContext { |
|||
// ... 现有字段 |
|||
callerSessionId?: string; // 新增:调用方会话 ID |
|||
} |
|||
``` |
|||
|
|||
#### 3.2.4 `agentInvokeExecutor` 改造 |
|||
|
|||
**文件**: `server/service/agent-tool/executors/agent-invoke/agent-invoke.ts` |
|||
|
|||
`execute` 方法传入 `callerSessionId`: |
|||
|
|||
```typescript |
|||
async execute( |
|||
input: unknown, |
|||
config: AgentInvokeToolConfig, |
|||
ctx: { userId: number | null; agentId?: number | null; recursionDepth?: number; sessionId?: string }, |
|||
): Promise<ToolResult> { |
|||
// ... 现有逻辑 |
|||
const result = await invokeAgentViaA2A( |
|||
{ |
|||
agentSlug: data.agentSlug, |
|||
input: data.input, |
|||
context: { |
|||
userId: ctx.userId, |
|||
callerAgentId: ctx.agentId ?? null, |
|||
recursionDepth: currentDepth + 1, |
|||
}, |
|||
}, |
|||
{ |
|||
maxRecursionDepth: config.maxRecursionDepth, |
|||
timeoutMs: config.timeoutMs, |
|||
maxOutputTokens: config.maxOutputTokens, |
|||
callerSessionId: ctx.sessionId, // 新增 |
|||
}, |
|||
); |
|||
|
|||
return { |
|||
success: true, |
|||
data: output, |
|||
metadata: { |
|||
durationMs: Date.now() - start, |
|||
agentSlug: data.agentSlug, |
|||
recursionDepth: currentDepth + 1, |
|||
taskId: result.taskId, // 新增 |
|||
}, |
|||
}; |
|||
} |
|||
``` |
|||
|
|||
**已确认**:executor 的 `ctx`(`ToolContext`)当前**不包含** `sessionId`。需要扩展以下接口: |
|||
|
|||
1. `ToolContext`(`server/service/agent-tool/registry.ts:11`)新增 `sessionId?: string` |
|||
2. `executeAgentTool`(`server/service/agent-tool/index.ts:302`)新增 `sessionId?: string` 参数,传入 ctx |
|||
3. `getAgentToolsForChatByAgentId`(`server/service/agent-tool/index.ts:546`)新增 `sessionId?: string` 参数,传递给内部 `executeAgentTool` 调用 |
|||
4. `chat-engine.ts` 调用 `getAgentToolsForChatByAgentId` 时传入 `sessionId`(`chat-engine.ts` 已有 `sessionId` 变量,line 191) |
|||
|
|||
#### 3.2.5 新增 SSE 端点 |
|||
|
|||
**文件**: `server/api/agents/a2a/stream.get.ts` |
|||
|
|||
```typescript |
|||
export default defineEventHandler(async (event) => { |
|||
const query = getQuery(event); |
|||
const taskId = query.taskId as string; |
|||
|
|||
if (!taskId) { |
|||
throw createError({ statusCode: 400, statusMessage: "缺少 taskId" }); |
|||
} |
|||
|
|||
// 鉴权:验证调用方有权访问该 task |
|||
// (通过 a2aTaskSessions 查询 callerAgentId / callerSessionId 匹配当前用户) |
|||
|
|||
const a2aBufferKey = `a2a_${taskId}`; |
|||
const buf = getStreamBuffer(a2aBufferKey); |
|||
|
|||
if (!buf) { |
|||
return R.success({ active: false, reason: "no-buffer" }); |
|||
} |
|||
|
|||
// 复用 stream.get.ts 的 ReadableStream 模式 |
|||
const stream = new ReadableStream({ |
|||
start(controller) { |
|||
for (const chunk of buf.chunks) { |
|||
controller.enqueue(chunk); |
|||
} |
|||
if (buf.done) { |
|||
controller.close(); |
|||
return; |
|||
} |
|||
const unsubscribe = subscribeToBuffer(a2aBufferKey, ...); |
|||
event.node.req.on("close", () => { unsubscribe(); }); |
|||
}, |
|||
}); |
|||
|
|||
return new Response(stream, { |
|||
headers: { |
|||
"Content-Type": "text/event-stream; charset=utf-8", |
|||
"Cache-Control": "no-cache", |
|||
"Connection": "keep-alive", |
|||
"X-Stream-Resume": "true", |
|||
}, |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
### 3.3 前端变更 |
|||
|
|||
#### 3.3.1 新增 `AgentInvokePanel` 组件 |
|||
|
|||
**文件**: `app/components/agent/AgentInvokePanel.vue` |
|||
|
|||
```vue |
|||
<script setup lang="ts"> |
|||
const props = defineProps<{ |
|||
taskId: string; |
|||
calleeAgentSlug: string; |
|||
calleeAgentName: string; |
|||
}>(); |
|||
|
|||
const emit = defineEmits<{ |
|||
close: []; |
|||
}>(); |
|||
|
|||
const streamedText = ref(""); |
|||
const isDone = ref(false); |
|||
const error = ref<string | null>(null); |
|||
|
|||
let eventSource: EventSource | null = null; |
|||
|
|||
watchEffect(() => { |
|||
if (!props.taskId) return; |
|||
|
|||
eventSource = new EventSource( |
|||
`/api/agents/a2a/stream?taskId=${encodeURIComponent(props.taskId)}`, |
|||
{ withCredentials: true } |
|||
); |
|||
|
|||
eventSource.onmessage = (e) => { |
|||
try { |
|||
const chunk = JSON.parse(e.data); |
|||
if (chunk.type === "text-delta") { |
|||
streamedText.value += chunk.textDelta; |
|||
} else if (chunk.type === "done") { |
|||
isDone.value = true; |
|||
eventSource?.close(); |
|||
} else if (chunk.type === "error") { |
|||
error.value = chunk.message; |
|||
eventSource?.close(); |
|||
} |
|||
} catch {} |
|||
}; |
|||
|
|||
eventSource.onerror = () => { |
|||
if (!isDone.value) error.value = "连接中断"; |
|||
eventSource?.close(); |
|||
}; |
|||
}); |
|||
|
|||
onUnmounted(() => eventSource?.close()); |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="agent-invoke-panel"> |
|||
<div class="panel-header"> |
|||
<Icon name="lucide:bot" /> |
|||
<span>{{ calleeAgentName }}</span> |
|||
<span class="status">{{ isDone ? '完成' : '生成中…' }}</span> |
|||
<button @click="emit('close')"> |
|||
<Icon name="lucide:x" /> |
|||
</button> |
|||
</div> |
|||
<div class="panel-body"> |
|||
<AgentMarkdown :content="streamedText" v-if="streamedText" /> |
|||
<div v-else-if="error" class="error">{{ error }}</div> |
|||
<div v-else class="loading"> |
|||
<Icon name="lucide:loader-circle" class="spin" /> |
|||
<span>等待回复…</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
</parameter> |
|||
``` |
|||
|
|||
#### 3.3.2 `useAgentChat.ts` 改造 |
|||
|
|||
在处理 `tool-call` chunk 时,检测 `toolName === 'agent-invoke'`,自动打开面板。 |
|||
|
|||
新增响应式状态: |
|||
|
|||
```typescript |
|||
const invokePanelState = ref<{ |
|||
visible: boolean; |
|||
taskId: string; |
|||
calleeAgentSlug: string; |
|||
calleeAgentName: string; |
|||
} | null>(null); |
|||
``` |
|||
|
|||
在 chunk 处理逻辑中: |
|||
|
|||
```typescript |
|||
case "tool-input-available": { |
|||
// ... 现有逻辑 |
|||
|
|||
// 检测 agent-invoke 工具调用 |
|||
if (chunk.toolName === "agent-invoke") { |
|||
// 解析 args 获取 calleeAgentSlug |
|||
const args = typeof chunk.args === "string" ? JSON.parse(chunk.args) : chunk.args; |
|||
// 先展示面板(taskId 暂空,等待 a2a-task-started 事件) |
|||
invokePanelState.value = { |
|||
visible: true, |
|||
taskId: "", |
|||
calleeAgentSlug: args?.agentSlug ?? "", |
|||
calleeAgentName: args?.agentSlug ?? "", |
|||
}; |
|||
} |
|||
break; |
|||
} |
|||
|
|||
case "a2a-task-started": { |
|||
// 更新面板的 taskId,开始 SSE 订阅 |
|||
if (invokePanelState.value) { |
|||
invokePanelState.value.taskId = chunk.taskId; |
|||
invokePanelState.value.calleeAgentName = chunk.calleeAgentName; |
|||
} |
|||
break; |
|||
} |
|||
|
|||
case "tool-output-available": { |
|||
// ... 现有逻辑 |
|||
// agent-invoke 完成后,面板标记为完成(但不自动关闭,用户手动关闭) |
|||
} |
|||
``` |
|||
|
|||
#### 3.3.3 `AgentChatArea.vue` 改造 |
|||
|
|||
挂载 `AgentInvokePanel`: |
|||
|
|||
```vue |
|||
<template> |
|||
<!-- 现有内容 --> |
|||
<AgentInvokePanel |
|||
v-if="invokePanelState?.visible" |
|||
:task-id="invokePanelState.taskId" |
|||
:callee-agent-slug="invokePanelState.calleeAgentSlug" |
|||
:callee-agent-name="invokePanelState.calleeAgentName" |
|||
@close="invokePanelState = null" |
|||
/> |
|||
</template> |
|||
``` |
|||
|
|||
### 3.4 错误处理 |
|||
|
|||
| 场景 | 处理 | |
|||
|------|------| |
|||
| A2A 调用超时 | streamBuffer 推送 error 事件,面板展示错误,executor 返回失败 | |
|||
| 被调用 agent 不存在/不可调用 | executor 返回失败(现有逻辑),面板不打开 | |
|||
| SSE 连接中断 | 面板展示"连接中断",用户可手动关闭 | |
|||
| 前端刷新 | streamBuffer 支持 resume(复用现有 stream.get.ts 模式),但 A2A 场景下 taskId 丢失,面板不恢复(可接受) | |
|||
| 递归深度超限 | executor 返回失败(现有逻辑),面板不打开 | |
|||
|
|||
### 3.5 测试策略 |
|||
|
|||
#### 3.5.1 单元测试 |
|||
|
|||
- `sendTask` 流式输出:mock streamText,验证 onChunk 写入 buffer、onFinish 保存消息 |
|||
- `invokeAgentViaA2A` 推送 `a2a-task-started` 事件:验证 appendChunk 被调用 |
|||
- `listSessions` 过滤 A2A 会话:插入 A2A 消息,验证会话列表不包含 |
|||
|
|||
#### 3.5.2 E2E 测试 |
|||
|
|||
- 配置两个 agent(通用助手 + 编程助手),通用助手配置 agent-invoke 工具 |
|||
- 发送消息触发 agent-invoke,验证侧边面板自动打开并展示流式回复 |
|||
- 验证 A2A 消息保存到数据库(source='a2a') |
|||
- 验证会话列表不展示 A2A 会话 |
|||
|
|||
## 4. 影响范围 |
|||
|
|||
### 4.1 修改文件清单 |
|||
|
|||
| 文件 | 变更类型 | 说明 | |
|||
|------|----------|------| |
|||
| `packages/drizzle-pkg/lib/schema/agent.ts` | 修改 | agentMessages 新增 source/a2aTaskId/callerAgentId/callerSessionId | |
|||
| `packages/drizzle-pkg/lib/schema/a2a.ts` | 修改 | a2aTaskSessions 新增 visibleInSidebar | |
|||
| `server/service/agent-tool/registry.ts` | 修改 | ToolContext 新增 sessionId 字段 | |
|||
| `server/service/agent-tool/index.ts` | 修改 | executeAgentTool + getAgentToolsForChatByAgentId 新增 sessionId 参数 | |
|||
| `server/service/a2a/types.ts` | 修改 | A2AInvocationContext 新增 callerSessionId | |
|||
| `server/service/a2a/client.ts` | 修改 | invokeAgentViaA2A 推送 a2a-task-started 事件,返回 taskId | |
|||
| `server/service/a2a/service.ts` | 修改 | sendTask 改为 streamText,保存消息 | |
|||
| `server/service/agent-tool/executors/agent-invoke/agent-invoke.ts` | 修改 | execute 传入 callerSessionId,metadata 返回 taskId | |
|||
| `server/service/agent/session.ts` | 修改 | listSessions 过滤 A2A 会话 | |
|||
| `server/service/agent/chat-engine.ts` | 修改 | 调用 getAgentToolsForChatByAgentId 时传入 sessionId | |
|||
| `server/api/agents/a2a/stream.get.ts` | 新增 | A2A SSE 端点 | |
|||
| `app/components/agent/AgentInvokePanel.vue` | 新增 | 侧边面板组件 | |
|||
| `app/composables/useAgentChat.ts` | 修改 | 检测 agent-invoke tool call,管理面板状态 | |
|||
| `app/components/agent/AgentChatArea.vue` | 修改 | 挂载 AgentInvokePanel | |
|||
|
|||
### 4.2 需要确认的依赖 |
|||
|
|||
- ~~executor 的 `ctx` 是否已包含 `sessionId`?~~ **已确认不包含**,需扩展 `ToolContext`、`executeAgentTool`、`getAgentToolsForChatByAgentId` 接口(见 3.2.4) |
|||
- `AgentInvocationResult` 类型需要新增 `taskId` 字段 |
|||
|
|||
## 5. 开放问题 |
|||
|
|||
1. ~~**executor ctx.sessionId**~~:**已解决** — 需扩展 `ToolContext`、`executeAgentTool`、`getAgentToolsForChatByAgentId` 接口。 |
|||
2. **streamBuffer key 命名**:使用 `a2a_${taskId}` 作为 key,与现有 sessionId 格式 `as_xxx` 不冲突。 |
|||
3. **并发 A2A 调用**:同一调用方同时发起多个 agent-invoke(多步 tool call),面板如何处理?建议只展示最后一个,或支持多个面板堆叠(后续迭代)。 |
|||
Binary file not shown.
@ -0,0 +1,45 @@ |
|||
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"; |
|||
import { agents } from "./agent"; |
|||
import { agentSessions } from "./agent"; |
|||
|
|||
/** |
|||
* A2A Task ↔ Session 映射表 |
|||
* |
|||
* 记录 A2A task ID 与内部 agentSession ID 的对应关系, |
|||
* 以及调用链信息(caller / callee agent)、task 状态。 |
|||
* |
|||
* 设计说明: |
|||
* - 不改动现有 agentSessions schema,通过映射表关联 |
|||
* - taskId 为 A2A 协议层生成的 UUID,对调用方暴露 |
|||
* - sessionId 为内部 session ID,不直接暴露给 A2A 调用方 |
|||
*/ |
|||
export const a2aTaskSessions = sqliteTable( |
|||
"a2a_task_sessions", |
|||
{ |
|||
id: integer("id").primaryKey({ autoIncrement: true }), |
|||
taskId: text("task_id", { length: 64 }).notNull().unique(), |
|||
sessionId: text("session_id") |
|||
.notNull() |
|||
.references(() => agentSessions.id, { onDelete: "cascade" }), |
|||
callerAgentId: integer("caller_agent_id").references(() => agents.id, { onDelete: "set null" }), |
|||
calleeAgentId: integer("callee_agent_id") |
|||
.notNull() |
|||
.references(() => agents.id, { onDelete: "cascade" }), |
|||
state: text("state", { length: 20 }).notNull().default("submitted"), |
|||
callerContext: text("caller_context"), |
|||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), |
|||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }) |
|||
.defaultNow() |
|||
.$onUpdate(() => new Date()) |
|||
.notNull(), |
|||
completedAt: integer("completed_at", { mode: "timestamp_ms" }), |
|||
}, |
|||
(table) => [ |
|||
uniqueIndex("a2a_task_sessions_task_id_uniq").on(table.taskId), |
|||
index("a2a_task_sessions_session_idx").on(table.sessionId), |
|||
index("a2a_task_sessions_callee_idx").on(table.calleeAgentId), |
|||
index("a2a_task_sessions_state_idx").on(table.state), |
|||
], |
|||
); |
|||
|
|||
export type A2aTaskSessionRow = typeof a2aTaskSessions.$inferSelect; |
|||
@ -0,0 +1,21 @@ |
|||
CREATE TABLE `a2a_task_sessions` ( |
|||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, |
|||
`task_id` text(64) NOT NULL, |
|||
`session_id` text NOT NULL, |
|||
`caller_agent_id` integer, |
|||
`callee_agent_id` integer NOT NULL, |
|||
`state` text(20) DEFAULT 'submitted' NOT NULL, |
|||
`caller_context` text, |
|||
`created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL, |
|||
`updated_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL, |
|||
`completed_at` integer, |
|||
FOREIGN KEY (`session_id`) REFERENCES `agent_sessions`(`id`) ON UPDATE no action ON DELETE cascade, |
|||
FOREIGN KEY (`caller_agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null, |
|||
FOREIGN KEY (`callee_agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE cascade |
|||
); |
|||
--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `a2a_task_sessions_task_id_unique` ON `a2a_task_sessions` (`task_id`);--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `a2a_task_sessions_task_id_uniq` ON `a2a_task_sessions` (`task_id`);--> statement-breakpoint |
|||
CREATE INDEX `a2a_task_sessions_session_idx` ON `a2a_task_sessions` (`session_id`);--> statement-breakpoint |
|||
CREATE INDEX `a2a_task_sessions_callee_idx` ON `a2a_task_sessions` (`callee_agent_id`);--> statement-breakpoint |
|||
CREATE INDEX `a2a_task_sessions_state_idx` ON `a2a_task_sessions` (`state`); |
|||
File diff suppressed because it is too large
@ -0,0 +1,131 @@ |
|||
import { defineWrappedResponseHandler } from "#server/utils/handler"; |
|||
import { R } from "#server/utils/response"; |
|||
import { getAgentBySlug } from "#server/service/agent/agent"; |
|||
import { resolveAgentIdentity } from "#server/service/agent/identity"; |
|||
import { createTask, sendTask, getTask, cancelTask } from "#server/service/a2a/service"; |
|||
import { DEFAULT_A2A_CONFIG, JSONRPC_ERROR_CODES, A2A_METHODS } from "#server/service/a2a/types"; |
|||
import type { |
|||
JSONRPCRequest, |
|||
JSONRPCResponse, |
|||
JSONRPCError, |
|||
A2ATaskSendParams, |
|||
A2ATaskGetParams, |
|||
A2ATaskCancelParams, |
|||
A2AInvocationContext, |
|||
A2AMessage, |
|||
} from "#server/service/a2a/types"; |
|||
|
|||
function rpcError(id: string | number | null, code: number, message: string, data?: unknown): JSONRPCResponse { |
|||
const error: JSONRPCError = { code, message }; |
|||
if (data !== undefined) error.data = data; |
|||
return { jsonrpc: "2.0", id, error }; |
|||
} |
|||
|
|||
function rpcResult<T>(id: string | number | null, result: T): JSONRPCResponse { |
|||
return { jsonrpc: "2.0", id, result }; |
|||
} |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
const agentSlug = getRouterParam(event, "agentSlug"); |
|||
if (!agentSlug) { |
|||
return rpcError(null, JSONRPC_ERROR_CODES.INVALID_PARAMS, "missing agentSlug"); |
|||
} |
|||
|
|||
const agent = await getAgentBySlug(agentSlug); |
|||
if (!agent) { |
|||
return rpcError(null, JSONRPC_ERROR_CODES.AGENT_NOT_CALLABLE, `agent not found: ${agentSlug}`); |
|||
} |
|||
|
|||
const body = await readBody<JSONRPCRequest>(event); |
|||
if (!body || body.jsonrpc !== "2.0" || !body.method) { |
|||
return rpcError(body?.id ?? null, JSONRPC_ERROR_CODES.INVALID_REQUEST, "invalid JSON-RPC 2.0 request"); |
|||
} |
|||
|
|||
const isInternal = event.node.req.headers["x-a2a-internal"] === "true"; |
|||
const internalCallerAgentId = event.node.req.headers["x-a2a-caller-agent-id"]; |
|||
const internalRecursionDepth = event.node.req.headers["x-a2a-recursion-depth"]; |
|||
|
|||
let userId: number | null; |
|||
let tempToken: string | null; |
|||
if (isInternal) { |
|||
userId = null; |
|||
tempToken = null; |
|||
} else { |
|||
const identity = await resolveAgentIdentity(event); |
|||
userId = identity.userId; |
|||
tempToken = identity.tempToken; |
|||
} |
|||
|
|||
const callerAgentId = isInternal |
|||
? (internalCallerAgentId ? Number(internalCallerAgentId) : null) |
|||
: null; |
|||
const recursionDepth = isInternal |
|||
? (internalRecursionDepth ? Number(internalRecursionDepth) : 0) |
|||
: 0; |
|||
|
|||
const { id } = body; |
|||
|
|||
switch (body.method) { |
|||
case A2A_METHODS.TASKS_SEND: { |
|||
const params = body.params as A2ATaskSendParams; |
|||
if (!params?.message || !params?.id) { |
|||
return rpcError(id, JSONRPC_ERROR_CODES.INVALID_PARAMS, "missing required params: id, message"); |
|||
} |
|||
|
|||
const { task } = await createTask({ |
|||
calleeAgentSlug: agentSlug, |
|||
message: params.message as A2AMessage, |
|||
callerAgentId, |
|||
userId, |
|||
tempToken, |
|||
existingSessionId: params.sessionId, |
|||
}); |
|||
|
|||
const ctx: A2AInvocationContext = { |
|||
callerAgentId, |
|||
calleeAgentId: agent.id, |
|||
calleeAgentSlug: agentSlug, |
|||
userId, |
|||
sessionId: task.sessionId, |
|||
recursionDepth, |
|||
maxRecursionDepth: DEFAULT_A2A_CONFIG.maxRecursionDepth, |
|||
timeoutMs: DEFAULT_A2A_CONFIG.timeoutMs, |
|||
maxOutputTokens: DEFAULT_A2A_CONFIG.maxOutputTokens, |
|||
}; |
|||
|
|||
const completedTask = await sendTask(task.id, ctx); |
|||
return rpcResult(id, completedTask); |
|||
} |
|||
|
|||
case A2A_METHODS.TASKS_GET: { |
|||
const params = body.params as A2ATaskGetParams; |
|||
if (!params?.id) { |
|||
return rpcError(id, JSONRPC_ERROR_CODES.INVALID_PARAMS, "missing required param: id"); |
|||
} |
|||
const task = await getTask(params.id); |
|||
if (!task) { |
|||
return rpcError(id, JSONRPC_ERROR_CODES.TASK_NOT_FOUND, `task not found: ${params.id}`); |
|||
} |
|||
return rpcResult(id, task); |
|||
} |
|||
|
|||
case A2A_METHODS.TASKS_CANCEL: { |
|||
const params = body.params as A2ATaskCancelParams; |
|||
if (!params?.id) { |
|||
return rpcError(id, JSONRPC_ERROR_CODES.INVALID_PARAMS, "missing required param: id"); |
|||
} |
|||
const canceled = await cancelTask(params.id); |
|||
if (!canceled) { |
|||
return rpcError(id, JSONRPC_ERROR_CODES.TASK_NOT_CANCELABLE, `task not cancelable: ${params.id}`); |
|||
} |
|||
return rpcResult(id, { id: params.id, canceled: true }); |
|||
} |
|||
|
|||
case A2A_METHODS.TASKS_SEND_SUBSCRIBE: |
|||
case A2A_METHODS.TASKS_SUBSCRIBE: |
|||
return rpcError(id, JSONRPC_ERROR_CODES.NOT_IMPLEMENTED, "streaming not implemented yet"); |
|||
|
|||
default: |
|||
return rpcError(id, JSONRPC_ERROR_CODES.METHOD_NOT_FOUND, `method not found: ${body.method}`); |
|||
} |
|||
}); |
|||
@ -0,0 +1,23 @@ |
|||
import { defineWrappedResponseHandler } from "#server/utils/handler"; |
|||
import { R } from "#server/utils/response"; |
|||
import { getAgentBySlug } from "#server/service/agent/agent"; |
|||
import { agentRowToCard } from "#server/service/a2a/converter"; |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
const agentSlug = getRouterParam(event, "agentSlug"); |
|||
if (!agentSlug) { |
|||
return R.error("参数无效", null); |
|||
} |
|||
|
|||
const agent = await getAgentBySlug(agentSlug); |
|||
if (!agent) { |
|||
return R.error("agent 不存在", null); |
|||
} |
|||
|
|||
const protocol = event.node.req.headers["x-forwarded-proto"] ?? "http"; |
|||
const host = event.node.req.headers.host ?? "localhost"; |
|||
const baseUrl = `${protocol}://${host}`; |
|||
|
|||
const card = agentRowToCard(agent, baseUrl); |
|||
return card; |
|||
}); |
|||
@ -0,0 +1,103 @@ |
|||
/** |
|||
* A2A 内部客户端 |
|||
* |
|||
* 替代 collaboration.ts 的 invokeAgent(),提供与原接口兼容的 |
|||
* agent 间调用能力,底层走 A2A Service 层。 |
|||
* |
|||
* 设计原则: |
|||
* - 接口签名与 invokeAgent 完全兼容,便于无缝替换 |
|||
* - 递归深度、超时、自调用阻断通过 A2AInvocationContext 传递 |
|||
* - 未来可切换为真实 HTTP 调用本机 A2A 端点(接口不变) |
|||
*/ |
|||
|
|||
import type { AgentInvocation, AgentInvocationResult } from "../agent/types"; |
|||
import { getAgentBySlug } from "../agent/agent"; |
|||
import { createTask, sendTask } from "./service"; |
|||
import { textToA2AMessage, a2aMessageToText } from "./converter"; |
|||
import { DEFAULT_A2A_CONFIG, JSONRPC_ERROR_CODES } from "./types"; |
|||
import type { A2AInvocationContext } from "./types"; |
|||
import log4js from "logger"; |
|||
|
|||
const logger = log4js.getLogger("APP"); |
|||
|
|||
export async function invokeAgentViaA2A( |
|||
invocation: AgentInvocation, |
|||
options?: { |
|||
maxRecursionDepth?: number; |
|||
timeoutMs?: number; |
|||
maxOutputTokens?: number; |
|||
}, |
|||
): Promise<AgentInvocationResult> { |
|||
const { agentSlug, input, context } = invocation; |
|||
const maxRecursionDepth = options?.maxRecursionDepth ?? DEFAULT_A2A_CONFIG.maxRecursionDepth; |
|||
const timeoutMs = options?.timeoutMs ?? DEFAULT_A2A_CONFIG.timeoutMs; |
|||
const maxOutputTokens = options?.maxOutputTokens ?? DEFAULT_A2A_CONFIG.maxOutputTokens; |
|||
|
|||
const userId = context?.userId ?? null; |
|||
const callerAgentId = context?.callerAgentId ?? null; |
|||
const recursionDepth = context?.recursionDepth ?? 0; |
|||
|
|||
const agent = await getAgentBySlug(agentSlug); |
|||
if (!agent) { |
|||
return { agentSlug, output: "", ok: false, error: `agent not found: ${agentSlug}` }; |
|||
} |
|||
|
|||
if (!agent.isCallable) { |
|||
return { agentSlug, output: "", ok: false, error: `agent not callable: ${agentSlug}` }; |
|||
} |
|||
|
|||
if (callerAgentId !== null && callerAgentId === agent.id) { |
|||
return { agentSlug, output: "", ok: false, error: `self-invocation blocked: agent ${agentSlug} cannot invoke itself` }; |
|||
} |
|||
|
|||
if (recursionDepth >= maxRecursionDepth) { |
|||
return { |
|||
agentSlug, |
|||
output: "", |
|||
ok: false, |
|||
error: `max recursion depth (${maxRecursionDepth}) reached`, |
|||
}; |
|||
} |
|||
|
|||
const message = textToA2AMessage(input, "user"); |
|||
|
|||
try { |
|||
const { task } = await createTask({ |
|||
calleeAgentSlug: agentSlug, |
|||
message, |
|||
callerAgentId, |
|||
userId, |
|||
}); |
|||
|
|||
const a2aContext: A2AInvocationContext = { |
|||
callerAgentId, |
|||
calleeAgentId: agent.id, |
|||
calleeAgentSlug: agentSlug, |
|||
userId, |
|||
sessionId: task.sessionId, |
|||
recursionDepth, |
|||
maxRecursionDepth, |
|||
timeoutMs, |
|||
maxOutputTokens, |
|||
}; |
|||
|
|||
const completedTask = await sendTask(task.id, a2aContext); |
|||
|
|||
const output = completedTask.message |
|||
? a2aMessageToText(completedTask.message) |
|||
: ""; |
|||
|
|||
logger.info( |
|||
"[A2A-CLIENT] invokeAgentViaA2A done agentSlug=%s taskId=%s outputLen=%d", |
|||
agentSlug, |
|||
task.id, |
|||
output.length, |
|||
); |
|||
|
|||
return { agentSlug, output, ok: true }; |
|||
} catch (err) { |
|||
const errorMsg = err instanceof Error ? err.message : String(err); |
|||
logger.error("[A2A-CLIENT] invokeAgentViaA2A error agentSlug=%s error=%s", agentSlug, errorMsg); |
|||
return { agentSlug, output: "", ok: false, error: errorMsg }; |
|||
} |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
/** |
|||
* A2A 消息格式转换层 |
|||
* |
|||
* 负责 A2A 协议类型与内部业务类型之间的双向转换: |
|||
* - Agent 数据行 ↔ Agent Card |
|||
* - A2A Message ↔ 纯文本输入(用于 generateText) |
|||
* - 纯文本输出 ↔ A2A Message |
|||
* - A2A Part ↔ StoredPart(预留,当前仅 TextPart) |
|||
*/ |
|||
|
|||
import type { AgentRow } from "../agent/types"; |
|||
import type { StoredPart } from "../agent/stored-part"; |
|||
import type { |
|||
A2AAgentCard, |
|||
A2ASkill, |
|||
A2AMessage, |
|||
A2APart, |
|||
A2ATextPart, |
|||
A2ADataPart, |
|||
A2AFilePart, |
|||
} from "./types"; |
|||
import { A2A_PROTOCOL_VERSION } from "./types"; |
|||
|
|||
// ============ Agent Row → Agent Card ============
|
|||
|
|||
export function agentRowToCard(agent: AgentRow, baseUrl: string): A2AAgentCard { |
|||
const url = `${baseUrl}/api/agents/${agent.slug}/a2a`; |
|||
|
|||
const skills: A2ASkill[] = [ |
|||
{ |
|||
id: agent.slug, |
|||
name: agent.name, |
|||
description: agent.description ?? agent.systemPrompt.slice(0, 200), |
|||
tags: agent.isCallable ? ["callable", "agent"] : ["agent"], |
|||
inputModes: ["text/plain"], |
|||
outputModes: ["text/plain"], |
|||
}, |
|||
]; |
|||
|
|||
return { |
|||
name: agent.name, |
|||
description: agent.description ?? "", |
|||
url, |
|||
version: "1.0.0", |
|||
protocolVersion: A2A_PROTOCOL_VERSION, |
|||
capabilities: { |
|||
streaming: true, |
|||
pushNotifications: false, |
|||
stateTransition: true, |
|||
}, |
|||
defaultInputModes: ["text/plain"], |
|||
defaultOutputModes: ["text/plain"], |
|||
skills, |
|||
}; |
|||
} |
|||
|
|||
// ============ A2A Message ↔ 纯文本 ============
|
|||
|
|||
export function a2aMessageToText(message: A2AMessage): string { |
|||
const textParts = message.parts.filter((p): p is A2ATextPart => p.type === "text"); |
|||
if (textParts.length === 0) return ""; |
|||
return textParts.map((p) => p.text).join("\n"); |
|||
} |
|||
|
|||
export function textToA2AMessage(text: string, role: "user" | "agent" = "agent"): A2AMessage { |
|||
return { |
|||
role, |
|||
parts: [{ type: "text", text }], |
|||
}; |
|||
} |
|||
|
|||
// ============ A2A Part ↔ StoredPart(预留) ============
|
|||
|
|||
export function a2aPartsToStoredParts(parts: A2APart[]): StoredPart[] { |
|||
return parts |
|||
.filter((p): p is A2ATextPart => p.type === "text") |
|||
.map((p, i) => ({ |
|||
id: `a2a-part-${i}`, |
|||
type: "text", |
|||
text: p.text, |
|||
})); |
|||
} |
|||
|
|||
export function storedPartsToA2AParts(parts: StoredPart[]): A2APart[] { |
|||
return parts |
|||
.filter((p) => p.type === "text" && p.text !== undefined) |
|||
.map((p) => ({ |
|||
type: "text" as const, |
|||
text: p.text!, |
|||
})); |
|||
} |
|||
|
|||
// ============ Part 类型守卫 ============
|
|||
|
|||
export function isTextPart(part: A2APart): part is A2ATextPart { |
|||
return part.type === "text"; |
|||
} |
|||
|
|||
export function isDataPart(part: A2APart): part is A2ADataPart { |
|||
return part.type === "data"; |
|||
} |
|||
|
|||
export function isFilePart(part: A2APart): part is A2AFilePart { |
|||
return part.type === "file"; |
|||
} |
|||
@ -0,0 +1,4 @@ |
|||
export * from "./types"; |
|||
export * from "./converter"; |
|||
export * from "./service"; |
|||
export * from "./client"; |
|||
@ -0,0 +1,341 @@ |
|||
/** |
|||
* A2A Service 核心层 |
|||
* |
|||
* 负责 A2A task 的生命周期管理,与 chat-engine 解耦: |
|||
* - createTask: 创建 task(创建 session + 映射记录) |
|||
* - sendTask: 同步执行 task(调用 generateText) |
|||
* - getTask: 查询 task 状态 |
|||
* - cancelTask: 取消 task(对接 abort-manager) |
|||
* - subscribeTask: streaming(预留) |
|||
* |
|||
* 复用现有模块: |
|||
* - agent/agent.ts: getAgentBySlug, getAgentById |
|||
* - agent/session.ts: createSession, getSessionById |
|||
* - agent-tool/index.ts: getAgentToolsForChatByAgentId |
|||
* - agent/abort-manager.ts: registerAbortController, unregisterAbortController |
|||
* - llm/model-resolver: resolveModelForUser, resolveModelAny, toLanguageModel |
|||
* - utils/context: getConfigGlobal |
|||
*/ |
|||
|
|||
import { generateText, stepCountIs } from "ai"; |
|||
import { dbGlobal } from "drizzle-pkg/lib/db"; |
|||
import { a2aTaskSessions } from "drizzle-pkg/lib/schema/a2a"; |
|||
import { eq, and } from "drizzle-orm"; |
|||
import log4js from "logger"; |
|||
|
|||
import { getAgentBySlug, getAgentById } from "../agent/agent"; |
|||
import { createSession, getSessionById } from "../agent/session"; |
|||
import { registerAbortController, unregisterAbortController, abortSessionStream } from "../agent/abort-manager"; |
|||
import { getAgentToolsForChatByAgentId } from "#server/service/agent-tool"; |
|||
import { resolveModelForUser, resolveModelAny, toLanguageModel } from "#server/service/llm/model-resolver"; |
|||
import { getConfigGlobal } from "#server/utils/context"; |
|||
|
|||
import { a2aMessageToText, textToA2AMessage } from "./converter"; |
|||
import { |
|||
DEFAULT_A2A_CONFIG, |
|||
A2A_PROTOCOL_VERSION, |
|||
JSONRPC_ERROR_CODES, |
|||
} from "./types"; |
|||
import type { |
|||
A2ATask, |
|||
A2ATaskState, |
|||
A2AMessage, |
|||
A2AInvocationContext, |
|||
A2AServiceConfig, |
|||
} from "./types"; |
|||
|
|||
const logger = log4js.getLogger("APP"); |
|||
|
|||
// ============ 辅助:生成 Task ID ============
|
|||
|
|||
function generateTaskId(): string { |
|||
return `a2a_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; |
|||
} |
|||
|
|||
// ============ 辅助:row → A2ATask ============
|
|||
|
|||
function rowToTask(row: typeof a2aTaskSessions.$inferSelect, message?: A2AMessage): A2ATask { |
|||
return { |
|||
id: row.taskId, |
|||
sessionId: row.sessionId, |
|||
state: row.state as A2ATaskState, |
|||
message, |
|||
createdAt: row.createdAt.getTime(), |
|||
updatedAt: row.updatedAt.getTime(), |
|||
}; |
|||
} |
|||
|
|||
// ============ createTask ============
|
|||
|
|||
export async function createTask(params: { |
|||
calleeAgentSlug: string; |
|||
message: A2AMessage; |
|||
callerAgentId?: number | null; |
|||
userId?: number | null; |
|||
tempToken?: string | null; |
|||
existingSessionId?: string; |
|||
}): Promise<{ task: A2ATask; agent: NonNullable<Awaited<ReturnType<typeof getAgentBySlug>>> }> { |
|||
const { calleeAgentSlug, message, callerAgentId = null, userId = null, tempToken = null } = params; |
|||
|
|||
const agent = await getAgentBySlug(calleeAgentSlug); |
|||
if (!agent) { |
|||
throw createA2AError(JSONRPC_ERROR_CODES.AGENT_NOT_CALLABLE, `agent not found: ${calleeAgentSlug}`); |
|||
} |
|||
|
|||
let sessionId: string; |
|||
if (params.existingSessionId) { |
|||
const existing = await getSessionById(params.existingSessionId); |
|||
if (!existing) { |
|||
throw createA2AError(JSONRPC_ERROR_CODES.INTERNAL_ERROR, `session not found: ${params.existingSessionId}`); |
|||
} |
|||
sessionId = existing.id; |
|||
} else { |
|||
const session = await createSession({ |
|||
userId, |
|||
tempToken, |
|||
agentId: agent.id, |
|||
modelId: agent.defaultModelId, |
|||
enableThinking: agent.enableThinking, |
|||
enableTools: agent.enableTools, |
|||
systemPrompt: agent.systemPrompt, |
|||
}); |
|||
sessionId = session.id; |
|||
} |
|||
|
|||
const inputText = a2aMessageToText(message); |
|||
const taskId = generateTaskId(); |
|||
const now = new Date(); |
|||
const [row] = await dbGlobal |
|||
.insert(a2aTaskSessions) |
|||
.values({ |
|||
taskId, |
|||
sessionId, |
|||
callerAgentId, |
|||
calleeAgentId: agent.id, |
|||
state: "submitted", |
|||
callerContext: inputText, |
|||
createdAt: now, |
|||
updatedAt: now, |
|||
}) |
|||
.returning(); |
|||
|
|||
const task = rowToTask(row!, message); |
|||
return { task, agent }; |
|||
} |
|||
|
|||
// ============ sendTask ============
|
|||
|
|||
export async function sendTask( |
|||
taskId: string, |
|||
context: A2AInvocationContext, |
|||
): Promise<A2ATask> { |
|||
const { calleeAgentId, calleeAgentSlug, userId, recursionDepth, maxRecursionDepth, timeoutMs, maxOutputTokens } = context; |
|||
|
|||
const taskRow = await getTaskRow(taskId); |
|||
if (!taskRow) { |
|||
throw createA2AError(JSONRPC_ERROR_CODES.TASK_NOT_FOUND, `task not found: ${taskId}`); |
|||
} |
|||
|
|||
if (calleeAgentId === null) { |
|||
throw createA2AError(JSONRPC_ERROR_CODES.AGENT_NOT_CALLABLE, `calleeAgentId is null for task ${taskId}`); |
|||
} |
|||
|
|||
const agent = await getAgentById(calleeAgentId); |
|||
if (!agent) { |
|||
throw createA2AError(JSONRPC_ERROR_CODES.AGENT_NOT_CALLABLE, `agent not found by id: ${calleeAgentId}`); |
|||
} |
|||
|
|||
if (!agent.isCallable) { |
|||
await updateTaskState(taskId, "failed"); |
|||
throw createA2AError(JSONRPC_ERROR_CODES.AGENT_NOT_CALLABLE, `agent not callable: ${calleeAgentSlug}`); |
|||
} |
|||
|
|||
if (context.callerAgentId !== null && context.callerAgentId === calleeAgentId) { |
|||
await updateTaskState(taskId, "failed"); |
|||
throw createA2AError(JSONRPC_ERROR_CODES.SELF_INVOCATION_BLOCKED, `self-invocation blocked: agent ${calleeAgentSlug} cannot invoke itself`); |
|||
} |
|||
|
|||
if (recursionDepth >= maxRecursionDepth) { |
|||
await updateTaskState(taskId, "failed"); |
|||
throw createA2AError(JSONRPC_ERROR_CODES.MAX_RECURSION_REACHED, `max recursion depth (${maxRecursionDepth}) reached`); |
|||
} |
|||
|
|||
const inputText = taskRow.callerContext ?? ""; |
|||
if (!inputText) { |
|||
throw createA2AError(JSONRPC_ERROR_CODES.INVALID_PARAMS, `task ${taskId} has no input message`); |
|||
} |
|||
|
|||
await updateTaskState(taskId, "working"); |
|||
|
|||
const globalDefaultModelId = (await getConfigGlobal("agentDefaultModelId")) ?? null; |
|||
const targetModelId = agent.defaultModelId ?? globalDefaultModelId; |
|||
if (!targetModelId) { |
|||
await updateTaskState(taskId, "failed"); |
|||
throw createA2AError(JSONRPC_ERROR_CODES.INTERNAL_ERROR, `agent ${calleeAgentSlug} has no defaultModelId and no global default model configured`); |
|||
} |
|||
|
|||
const resolved = userId |
|||
? await resolveModelForUser(targetModelId, userId) |
|||
: await resolveModelAny(targetModelId); |
|||
if (!resolved) { |
|||
await updateTaskState(taskId, "failed"); |
|||
throw createA2AError(JSONRPC_ERROR_CODES.INTERNAL_ERROR, `model ${targetModelId} not resolvable for agent ${calleeAgentSlug}`); |
|||
} |
|||
|
|||
const languageModel = toLanguageModel(resolved); |
|||
|
|||
const enableTools = agent.enableTools === 1; |
|||
const { tools } = await getAgentToolsForChatByAgentId({ |
|||
agentId: agent.id, |
|||
userId, |
|||
userRole: null, |
|||
enableTools, |
|||
recursionDepth, |
|||
}); |
|||
|
|||
const maxSteps = agent.maxStepCount ?? 8; |
|||
|
|||
const abortController = new AbortController(); |
|||
const timeoutSignal = AbortSignal.timeout(timeoutMs); |
|||
const combinedSignal = anySignal([abortController.signal, timeoutSignal]); |
|||
registerAbortController(taskRow.sessionId, abortController); |
|||
|
|||
logger.info( |
|||
"[A2A] sendTask taskId=%s agentSlug=%s agentId=%d callerAgentId=%s recursionDepth=%d tools=%d maxSteps=%d", |
|||
taskId, |
|||
calleeAgentSlug, |
|||
agent.id, |
|||
context.callerAgentId, |
|||
recursionDepth, |
|||
Object.keys(tools).length, |
|||
maxSteps, |
|||
); |
|||
|
|||
try { |
|||
const result = await generateText({ |
|||
model: languageModel, |
|||
system: agent.systemPrompt, |
|||
prompt: inputText, |
|||
...(Object.keys(tools).length > 0 |
|||
? { |
|||
tools, |
|||
stopWhen: stepCountIs(maxSteps), |
|||
} |
|||
: {}), |
|||
maxOutputTokens, |
|||
abortSignal: combinedSignal, |
|||
}); |
|||
|
|||
const output = result.text ?? ""; |
|||
const responseMessage = textToA2AMessage(output, "agent"); |
|||
|
|||
await updateTaskState(taskId, "completed"); |
|||
|
|||
logger.info( |
|||
"[A2A] sendTask done taskId=%s agentSlug=%s outputLen=%d usage=%j", |
|||
taskId, |
|||
calleeAgentSlug, |
|||
output.length, |
|||
result.usage, |
|||
); |
|||
|
|||
const updatedRow = await getTaskRow(taskId); |
|||
return rowToTask(updatedRow!, responseMessage); |
|||
} catch (err) { |
|||
const errorMsg = err instanceof Error ? err.message : String(err); |
|||
logger.error("[A2A] sendTask error taskId=%s agentSlug=%s error=%s", taskId, calleeAgentSlug, errorMsg); |
|||
|
|||
const isTimeout = errorMsg.includes("timeout") || errorMsg.includes("abort"); |
|||
await updateTaskState(taskId, isTimeout ? "failed" : "failed"); |
|||
|
|||
throw createA2AError( |
|||
isTimeout ? JSONRPC_ERROR_CODES.TIMEOUT : JSONRPC_ERROR_CODES.INTERNAL_ERROR, |
|||
errorMsg, |
|||
); |
|||
} finally { |
|||
unregisterAbortController(taskRow.sessionId); |
|||
} |
|||
} |
|||
|
|||
// ============ getTask ============
|
|||
|
|||
export async function getTask(taskId: string): Promise<A2ATask | null> { |
|||
const row = await getTaskRow(taskId); |
|||
if (!row) return null; |
|||
return rowToTask(row); |
|||
} |
|||
|
|||
// ============ cancelTask ============
|
|||
|
|||
export async function cancelTask(taskId: string): Promise<boolean> { |
|||
const row = await getTaskRow(taskId); |
|||
if (!row) return false; |
|||
|
|||
const cancelableStates: A2ATaskState[] = ["submitted", "working", "input-required"]; |
|||
if (!cancelableStates.includes(row.state as A2ATaskState)) { |
|||
return false; |
|||
} |
|||
|
|||
const aborted = abortSessionStream(row.sessionId); |
|||
await updateTaskState(taskId, "canceled"); |
|||
return aborted; |
|||
} |
|||
|
|||
// ============ subscribeTask(预留) ============
|
|||
|
|||
export async function subscribeTask( |
|||
_taskId: string, |
|||
_onChunk: (chunk: string) => void, |
|||
_onDone: (task: A2ATask) => void, |
|||
_onError: (error: Error) => void, |
|||
): Promise<void> { |
|||
throw createA2AError(JSONRPC_ERROR_CODES.NOT_IMPLEMENTED, "subscribeTask not implemented yet"); |
|||
} |
|||
|
|||
// ============ 内部辅助函数 ============
|
|||
|
|||
async function getTaskRow(taskId: string) { |
|||
const [row] = await dbGlobal |
|||
.select() |
|||
.from(a2aTaskSessions) |
|||
.where(eq(a2aTaskSessions.taskId, taskId)) |
|||
.limit(1); |
|||
return row ?? null; |
|||
} |
|||
|
|||
async function updateTaskState(taskId: string, state: A2ATaskState): Promise<void> { |
|||
const updateData: Record<string, unknown> = { |
|||
state, |
|||
updatedAt: new Date(), |
|||
}; |
|||
if (state === "completed" || state === "failed" || state === "canceled") { |
|||
updateData.completedAt = new Date(); |
|||
} |
|||
await dbGlobal |
|||
.update(a2aTaskSessions) |
|||
.set(updateData) |
|||
.where(eq(a2aTaskSessions.taskId, taskId)); |
|||
} |
|||
|
|||
function createA2AError(code: number, message: string): Error { |
|||
const err = new Error(message); |
|||
(err as any).code = code; |
|||
return err; |
|||
} |
|||
|
|||
function anySignal(signals: AbortSignal[]): AbortSignal { |
|||
const controller = new AbortController(); |
|||
for (const signal of signals) { |
|||
if (signal.aborted) { |
|||
controller.abort(); |
|||
break; |
|||
} |
|||
signal.addEventListener("abort", () => controller.abort(), { once: true }); |
|||
} |
|||
return controller.signal; |
|||
} |
|||
|
|||
// ============ 导出配置 ============
|
|||
|
|||
export { DEFAULT_A2A_CONFIG, A2A_PROTOCOL_VERSION }; |
|||
export type { A2AServiceConfig }; |
|||
@ -0,0 +1,206 @@ |
|||
/** |
|||
* A2A (Agent-to-Agent) 协议类型定义 |
|||
* |
|||
* 遵循 A2A 协议规范,定义 Agent Card、Task、Message、Part 等 |
|||
* 核心数据结构,以及 JSON-RPC 2.0 请求/响应类型。 |
|||
* |
|||
* 设计原则: |
|||
* - 协议类型与业务逻辑解耦,纯类型定义 |
|||
* - 预留扩展点(streaming、push notification、外部 agent) |
|||
* - 内部调用与外部调用共享同一套类型 |
|||
*/ |
|||
|
|||
// ============ Agent Card ============
|
|||
|
|||
export interface A2AAgentCard { |
|||
name: string; |
|||
description: string; |
|||
url: string; |
|||
version: string; |
|||
protocolVersion: string; |
|||
capabilities: A2ACapabilities; |
|||
defaultInputModes: string[]; |
|||
defaultOutputModes: string[]; |
|||
skills: A2ASkill[]; |
|||
} |
|||
|
|||
export interface A2ACapabilities { |
|||
streaming: boolean; |
|||
pushNotifications: boolean; |
|||
stateTransition: boolean; |
|||
} |
|||
|
|||
export interface A2ASkill { |
|||
id: string; |
|||
name: string; |
|||
description: string; |
|||
tags: string[]; |
|||
inputModes?: string[]; |
|||
outputModes?: string[]; |
|||
} |
|||
|
|||
// ============ Task ============
|
|||
|
|||
export type A2ATaskState = |
|||
| "submitted" |
|||
| "working" |
|||
| "input-required" |
|||
| "completed" |
|||
| "canceled" |
|||
| "failed"; |
|||
|
|||
export interface A2ATask { |
|||
id: string; |
|||
sessionId: string; |
|||
state: A2ATaskState; |
|||
message?: A2AMessage; |
|||
artifacts?: A2AArtifact[]; |
|||
createdAt: number; |
|||
updatedAt: number; |
|||
} |
|||
|
|||
export interface A2AArtifact { |
|||
id: string; |
|||
name?: string; |
|||
description?: string; |
|||
parts: A2APart[]; |
|||
} |
|||
|
|||
// ============ Message & Part ============
|
|||
|
|||
export interface A2AMessage { |
|||
role: "user" | "agent"; |
|||
parts: A2APart[]; |
|||
taskId?: string; |
|||
contextId?: string; |
|||
messageId?: string; |
|||
} |
|||
|
|||
export type A2APart = A2ATextPart | A2ADataPart | A2AFilePart; |
|||
|
|||
export interface A2ATextPart { |
|||
type: "text"; |
|||
text: string; |
|||
metadata?: Record<string, unknown>; |
|||
} |
|||
|
|||
export interface A2ADataPart { |
|||
type: "data"; |
|||
data: Record<string, unknown>; |
|||
metadata?: Record<string, unknown>; |
|||
} |
|||
|
|||
export interface A2AFilePart { |
|||
type: "file"; |
|||
file: { |
|||
name?: string; |
|||
mimeType?: string; |
|||
bytes?: string; |
|||
uri?: string; |
|||
}; |
|||
metadata?: Record<string, unknown>; |
|||
} |
|||
|
|||
// ============ JSON-RPC 2.0 ============
|
|||
|
|||
export interface JSONRPCRequest<T = unknown> { |
|||
jsonrpc: "2.0"; |
|||
id: string | number | null; |
|||
method: string; |
|||
params?: T; |
|||
} |
|||
|
|||
export interface JSONRPCResponse<T = unknown> { |
|||
jsonrpc: "2.0"; |
|||
id: string | number | null; |
|||
result?: T; |
|||
error?: JSONRPCError; |
|||
} |
|||
|
|||
export interface JSONRPCError { |
|||
code: number; |
|||
message: string; |
|||
data?: unknown; |
|||
} |
|||
|
|||
// JSON-RPC 标准错误码
|
|||
export const JSONRPC_ERROR_CODES = { |
|||
PARSE_ERROR: -32700, |
|||
INVALID_REQUEST: -32600, |
|||
METHOD_NOT_FOUND: -32601, |
|||
INVALID_PARAMS: -32602, |
|||
INTERNAL_ERROR: -32603, |
|||
// A2A 应用层错误码(-32000 ~ -32099)
|
|||
TASK_NOT_FOUND: -32001, |
|||
TASK_NOT_CANCELABLE: -32002, |
|||
AGENT_NOT_CALLABLE: -32003, |
|||
MAX_RECURSION_REACHED: -32004, |
|||
SELF_INVOCATION_BLOCKED: -32005, |
|||
TIMEOUT: -32006, |
|||
NOT_IMPLEMENTED: -32099, |
|||
} as const; |
|||
|
|||
// ============ A2A 方法名 ============
|
|||
|
|||
export const A2A_METHODS = { |
|||
TASKS_SEND: "tasks/send", |
|||
TASKS_SEND_SUBSCRIBE: "tasks/sendSubscribe", |
|||
TASKS_GET: "tasks/get", |
|||
TASKS_CANCEL: "tasks/cancel", |
|||
TASKS_SUBSCRIBE: "tasks/subscribe", |
|||
} as const; |
|||
|
|||
// ============ tasks/send 请求参数 ============
|
|||
|
|||
export interface A2ATaskSendParams { |
|||
id: string; |
|||
message: A2AMessage; |
|||
sessionId?: string; |
|||
metadata?: Record<string, unknown>; |
|||
} |
|||
|
|||
// ============ tasks/get 请求参数 ============
|
|||
|
|||
export interface A2ATaskGetParams { |
|||
id: string; |
|||
historyLength?: number; |
|||
} |
|||
|
|||
// ============ tasks/cancel 请求参数 ============
|
|||
|
|||
export interface A2ATaskCancelParams { |
|||
id: string; |
|||
metadata?: Record<string, unknown>; |
|||
} |
|||
|
|||
// ============ 内部调用上下文 ============
|
|||
|
|||
export interface A2AInvocationContext { |
|||
callerAgentId: number | null; |
|||
calleeAgentId: number | null; |
|||
calleeAgentSlug: string; |
|||
userId: number | null; |
|||
sessionId?: string; |
|||
recursionDepth: number; |
|||
maxRecursionDepth: number; |
|||
timeoutMs: number; |
|||
maxOutputTokens: number; |
|||
} |
|||
|
|||
// ============ A2A Service 配置 ============
|
|||
|
|||
export interface A2AServiceConfig { |
|||
maxRecursionDepth: number; |
|||
timeoutMs: number; |
|||
maxOutputTokens: number; |
|||
} |
|||
|
|||
export const DEFAULT_A2A_CONFIG: A2AServiceConfig = { |
|||
maxRecursionDepth: 3, |
|||
timeoutMs: 60_000, |
|||
maxOutputTokens: 8192, |
|||
}; |
|||
|
|||
// ============ A2A 协议版本 ============
|
|||
|
|||
export const A2A_PROTOCOL_VERSION = "0.3.0"; |
|||
Loading…
Reference in new issue