Browse Source

docs: agent-invoke 流式会话设计文档

- 复用 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
npmrun 2 months ago
parent
commit
5eeec63816
  1. 4
      .codegraph/daemon.pid
  2. 575
      docs/superpowers/specs/2026-08-12-agent-invoke-streaming-design.md
  3. 3
      packages/common/config/index.ts
  4. BIN
      packages/drizzle-pkg/db.sqlite
  5. 45
      packages/drizzle-pkg/lib/schema/a2a.ts
  6. 21
      packages/drizzle-pkg/migrations/0004_public_dracula.sql
  7. 2790
      packages/drizzle-pkg/migrations/meta/0004_snapshot.json
  8. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  9. 131
      server/api/agents/[agentSlug]/a2a.post.ts
  10. 23
      server/api/agents/[agentSlug]/agent-card.get.ts
  11. 103
      server/service/a2a/client.ts
  12. 105
      server/service/a2a/converter.ts
  13. 4
      server/service/a2a/index.ts
  14. 341
      server/service/a2a/service.ts
  15. 206
      server/service/a2a/types.ts
  16. 4
      server/service/agent-tool/executors/agent-invoke/agent-invoke.ts
  17. 117
      server/service/agent/collaboration.ts

4
.codegraph/daemon.pid

@ -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
}

575
docs/superpowers/specs/2026-08-12-agent-invoke-streaming-design.md

@ -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),面板如何处理?建议只展示最后一个,或支持多个面板堆叠(后续迭代)。

3
packages/common/config/index.ts

@ -52,6 +52,9 @@ export const API_ALLOWLIST: RouteRule[] = [
{ path: "/api/agents/:agentSlug/chat/cleanup-approvals", methods: ["POST"] },
{ path: "/api/agents/:agentSlug/feedback", methods: ["POST"] },
{ path: "/api/agents/:agentSlug/migrate", methods: ["POST"] },
/** A2A 协议 — Agent Card 公开(规范要求),JSON-RPC 端点走身份解析 */
{ path: "/api/agents/:agentSlug/agent-card", methods: ["GET"] },
{ path: "/api/agents/:agentSlug/a2a", methods: ["POST"] },
{ path: "/api/agent-tools", methods: ["GET"] },
{ path: "/api/llm/chat/tools", methods: ["GET"] },
];

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

45
packages/drizzle-pkg/lib/schema/a2a.ts

@ -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;

21
packages/drizzle-pkg/migrations/0004_public_dracula.sql

@ -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`);

2790
packages/drizzle-pkg/migrations/meta/0004_snapshot.json

File diff suppressed because it is too large

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -29,6 +29,13 @@
"when": 1786287044322,
"tag": "0003_adorable_dragon_lord",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1786459321606,
"tag": "0004_public_dracula",
"breakpoints": true
}
]
}

131
server/api/agents/[agentSlug]/a2a.post.ts

@ -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}`);
}
});

23
server/api/agents/[agentSlug]/agent-card.get.ts

@ -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;
});

103
server/service/a2a/client.ts

@ -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 };
}
}

105
server/service/a2a/converter.ts

@ -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";
}

4
server/service/a2a/index.ts

@ -0,0 +1,4 @@
export * from "./types";
export * from "./converter";
export * from "./service";
export * from "./client";

341
server/service/a2a/service.ts

@ -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 };

206
server/service/a2a/types.ts

@ -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";

4
server/service/agent-tool/executors/agent-invoke/agent-invoke.ts

@ -2,7 +2,7 @@ import { z } from "zod";
import type { JSONSchema7 } from "json-schema";
import type { ToolExecutor, ToolResult } from "../../registry";
import type { AgentInvokeToolConfig } from "./config";
import { invokeAgent } from "../../../agent/collaboration";
import { invokeAgentViaA2A } from "../../../a2a/client";
import { listAgents } from "../../../agent/agent";
export const agentInvokeInputSchema = z.object({
@ -78,7 +78,7 @@ export const agentInvokeExecutor: ToolExecutor<AgentInvokeToolConfig> = {
const data: AgentInvokeInput = parsed.data;
const currentDepth = ctx.recursionDepth ?? 0;
const result = await invokeAgent(
const result = await invokeAgentViaA2A(
{
agentSlug: data.agentSlug,
input: data.input,

117
server/service/agent/collaboration.ts

@ -1,17 +1,16 @@
import { generateText, stepCountIs } from "ai";
/**
* @deprecated 此模块已废弃,请使用 server/service/a2a/client.ts 中的 invokeAgentViaA2A()。
*
* 本文件保留为薄包装,仅用于向后兼容。底层已切换到 A2A Service 层。
* 新代码请直接 import { invokeAgentViaA2A } from "#server/service/a2a/client"。
*/
import type { AgentInvocation, AgentInvocationResult } from "./types";
import { getAgentBySlug } from "./agent";
import { getAgentToolsForChatByAgentId } from "#server/service/agent-tool";
import { resolveModelForUser, resolveModelAny, toLanguageModel } from "#server/service/llm/model-resolver";
import { getConfigGlobal } from "#server/utils/context";
import { invokeAgentViaA2A } from "../a2a/client";
import log4js from "logger";
const logger = log4js.getLogger("APP");
const DEFAULT_MAX_RECURSION_DEPTH = 3;
const DEFAULT_TIMEOUT_MS = 60_000;
const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
export async function invokeAgent(
invocation: AgentInvocation,
options?: {
@ -20,101 +19,9 @@ export async function invokeAgent(
maxOutputTokens?: number;
},
): Promise<AgentInvocationResult> {
const { agentSlug, input, context } = invocation;
const maxRecursionDepth = options?.maxRecursionDepth ?? DEFAULT_MAX_RECURSION_DEPTH;
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const maxOutputTokens = options?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
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 globalDefaultModelId = (await getConfigGlobal("agentDefaultModelId")) ?? null;
const targetModelId = agent.defaultModelId ?? globalDefaultModelId;
if (!targetModelId) {
return { agentSlug, output: "", ok: false, error: `agent ${agentSlug} has no defaultModelId and no global default model configured` };
}
const resolved = userId
? await resolveModelForUser(targetModelId, userId)
: await resolveModelAny(targetModelId);
if (!resolved) {
return { agentSlug, output: "", ok: false, error: `model ${targetModelId} not resolvable for agent ${agentSlug}` };
}
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;
logger.info(
"[COLLAB] invokeAgent agentSlug=%s agentId=%d callerAgentId=%s recursionDepth=%d tools=%d maxSteps=%d",
agentSlug,
agent.id,
callerAgentId,
recursionDepth,
Object.keys(tools).length,
maxSteps,
logger.warn(
"[COLLAB] invokeAgent is deprecated, use invokeAgentViaA2A from a2a/client instead. agentSlug=%s",
invocation.agentSlug,
);
try {
const result = await generateText({
model: languageModel,
system: agent.systemPrompt,
prompt: input,
...(Object.keys(tools).length > 0
? {
tools,
stopWhen: stepCountIs(maxSteps),
}
: {}),
maxOutputTokens,
abortSignal: AbortSignal.timeout(timeoutMs),
});
const output = result.text ?? "";
logger.info(
"[COLLAB] invokeAgent done agentSlug=%s outputLen=%d usage=%j",
agentSlug,
output.length,
result.usage,
);
return { agentSlug, output, ok: true };
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
logger.error("[COLLAB] invokeAgent error agentSlug=%s error=%s", agentSlug, errorMsg);
return { agentSlug, output: "", ok: false, error: errorMsg };
}
return invokeAgentViaA2A(invocation, options);
}

Loading…
Cancel
Save