hyacinth-ai 0.9.19 → 0.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,14 @@ export interface SubAgentContext {
25
25
  sandboxRoot?: string;
26
26
  /** 创建带独立 userId 的 Provider(子Agent 之间 + 与主Agent 的 KVCache 隔离) */
27
27
  createSubProvider(userId: string): Provider;
28
+ /** 异步子Agent 结果推送队列(主 loop 的 pendingAsyncResults,完成后自动注入对话) */
29
+ pendingAsyncResults?: Array<{
30
+ handle: string;
31
+ agentName: string;
32
+ status: 'completed' | 'failed';
33
+ result?: string;
34
+ error?: string;
35
+ }>;
28
36
  }
29
37
  /** 注册正在运行的子 Agent loop */
30
38
  export declare function registerRunningLoop(instanceId: string, loop: AgentLoop): void;
@@ -32,6 +40,29 @@ export declare function registerRunningLoop(instanceId: string, loop: AgentLoop)
32
40
  export declare function unregisterRunningLoop(instanceId: string): void;
33
41
  /** 中断指定子 Agent */
34
42
  export declare function interruptSubAgentLoop(instanceId: string): boolean;
43
+ export interface AsyncSubAgentTask {
44
+ handle: string;
45
+ agentName: string;
46
+ instanceId: string;
47
+ task: string;
48
+ status: 'running' | 'completed' | 'failed';
49
+ startTime: string;
50
+ endTime?: string;
51
+ result?: string;
52
+ error?: string;
53
+ }
54
+ /** 注册异步子 Agent 任务,返回 handle */
55
+ export declare function registerAsyncTask(agentName: string, instanceId: string, task: string): string;
56
+ /** 标记异步任务为已完成 */
57
+ export declare function completeAsyncTask(handle: string, result: string): void;
58
+ /** 标记异步任务为失败 */
59
+ export declare function failAsyncTask(handle: string, error: string): void;
60
+ /** 获取所有异步任务列表 */
61
+ export declare function listAsyncTasks(): AsyncSubAgentTask[];
62
+ /** 按 handle 获取异步任务 */
63
+ export declare function getAsyncTask(handle: string): AsyncSubAgentTask | undefined;
64
+ /** 等待所有运行中的异步子 Agent 任务完成(用于优雅关闭) */
65
+ export declare function waitForAsyncTasks(timeoutMs?: number): Promise<void>;
35
66
  /** 销毁子 Agent session 目录(清理 conversation/events/stats/meta) */
36
67
  export declare function destroySubAgentSession(parentSessionDir: string, instanceId: string): Promise<boolean>;
37
68
  /**
@@ -73,6 +104,14 @@ export declare class DelegateToAgentTool implements Tool {
73
104
  type: "string";
74
105
  description: string;
75
106
  };
107
+ async: {
108
+ type: "boolean";
109
+ description: string;
110
+ };
111
+ across_turns: {
112
+ type: "boolean";
113
+ description: string;
114
+ };
76
115
  };
77
116
  required: string[];
78
117
  };
@@ -81,7 +120,14 @@ export declare class DelegateToAgentTool implements Tool {
81
120
  constructor(agentRegistry: AgentRegistry, parentContext: SubAgentContext);
82
121
  execute(args: Record<string, unknown>): Promise<string>;
83
122
  /**
84
- * 委托模式:单个子 Agent 执行任务
123
+ * 异步委托:在后台启动子 Agent,立即返回任务句柄。
124
+ * 子 Agent 的 loop.run() 在后台 Promise 中执行,完成后结果写入 AsyncSubAgentTask。
125
+ */
126
+ private executeAsync;
127
+ /** 后台执行子 Agent 任务 */
128
+ private runAsyncInBackground;
129
+ /**
130
+ * 委托模式:单个子 Agent 执行任务(同步阻塞)
85
131
  */
86
132
  private executeDelegate;
87
133
  /**
@@ -33,6 +33,57 @@ export function interruptSubAgentLoop(instanceId) {
33
33
  loop.interrupt();
34
34
  return true;
35
35
  }
36
+ const asyncTasks = new Map();
37
+ let asyncTaskCounter = 0;
38
+ /** 注册异步子 Agent 任务,返回 handle */
39
+ export function registerAsyncTask(agentName, instanceId, task) {
40
+ const handle = `sub_${String(++asyncTaskCounter).padStart(3, '0')}`;
41
+ asyncTasks.set(handle, {
42
+ handle,
43
+ agentName,
44
+ instanceId,
45
+ task,
46
+ status: 'running',
47
+ startTime: new Date().toISOString(),
48
+ });
49
+ return handle;
50
+ }
51
+ /** 标记异步任务为已完成 */
52
+ export function completeAsyncTask(handle, result) {
53
+ const t = asyncTasks.get(handle);
54
+ if (t) {
55
+ t.status = 'completed';
56
+ t.endTime = new Date().toISOString();
57
+ t.result = result;
58
+ }
59
+ }
60
+ /** 标记异步任务为失败 */
61
+ export function failAsyncTask(handle, error) {
62
+ const t = asyncTasks.get(handle);
63
+ if (t) {
64
+ t.status = 'failed';
65
+ t.endTime = new Date().toISOString();
66
+ t.error = error;
67
+ }
68
+ }
69
+ /** 获取所有异步任务列表 */
70
+ export function listAsyncTasks() {
71
+ return [...asyncTasks.values()];
72
+ }
73
+ /** 按 handle 获取异步任务 */
74
+ export function getAsyncTask(handle) {
75
+ return asyncTasks.get(handle);
76
+ }
77
+ /** 等待所有运行中的异步子 Agent 任务完成(用于优雅关闭) */
78
+ export async function waitForAsyncTasks(timeoutMs = 5000) {
79
+ const start = Date.now();
80
+ while (Date.now() - start < timeoutMs) {
81
+ const running = listAsyncTasks().filter(t => t.status === 'running');
82
+ if (running.length === 0)
83
+ return;
84
+ await new Promise(resolve => setTimeout(resolve, 200));
85
+ }
86
+ }
36
87
  /** 销毁子 Agent session 目录(清理 conversation/events/stats/meta) */
37
88
  export async function destroySubAgentSession(parentSessionDir, instanceId) {
38
89
  const subDir = path.join(parentSessionDir, 'sub-agents', instanceId);
@@ -57,6 +108,11 @@ export async function createSubAgentLoop(agentDef, task, parentContext) {
57
108
  const instanceId = agentDef.instanceId ?? `${agentDef.name}-default`;
58
109
  const subSessionDir = path.join(parentContext.sessionDir, 'sub-agents', instanceId);
59
110
  const ttlMinutes = agentDef.sessionTtlMinutes ?? 10;
111
+ // 并发保护:同一 instanceId 已有活跃 loop 时拒绝,防止会话文件并发写入冲突
112
+ if (runningLoops.has(instanceId)) {
113
+ throw new Error(`子 Agent "${agentDef.name}" (${instanceId}) 正在执行中,不能同时委派第二个任务。` +
114
+ '等待当前任务完成后重试,或使用 spawn_sub_agent 创建独立实例来并行执行。');
115
+ }
60
116
  // TTL 检查:过期则清理重建
61
117
  let isNew = false;
62
118
  try {
@@ -163,6 +219,7 @@ export class DelegateToAgentTool {
163
219
  '"parallel"(并行分工):同时启动多个子 Agent 并行执行,汇总结果。' +
164
220
  '编排场景:当你有一个复杂计划时,自己负责规划和决策,将其中可并行的子任务分别委派给多个子 Agent 同步执行——spawn_sub_agent 可克隆多份实例,配合不同的 instance_id 并发调度,大幅缩短总耗时。' +
165
221
  '会话复用:子 Agent 会话在 TTL 窗口内持久化(默认 10 分钟),相同 instance_id 再次委派时自动恢复完整对话记忆,无需重新交代背景。' +
222
+ '异步执行:设置 async=true 后子 Agent 在后台运行,主 Agent 立即获得任务句柄(如 sub_001)并可继续其他工作。之后用 list_sub_agent_tasks 查看所有异步任务状态,get_sub_agent_result <handle> 获取已完成任务的结果。默认 async=false(同步阻塞,等待结果返回)。' +
166
223
  '使用 list_sub_agents 查看可用 Agent 及其实例 ID,create_sub_agent 创建新 Agent,spawn_sub_agent 克隆以支持并行,destroy_sub_agent 清理不再需要的实例。';
167
224
  inputSchema = {
168
225
  type: 'object',
@@ -183,6 +240,14 @@ export class DelegateToAgentTool {
183
240
  type: 'string',
184
241
  description: '可选的附加上下文(如相关代码片段、文件路径、背景信息)。',
185
242
  },
243
+ async: {
244
+ type: 'boolean',
245
+ description: '是否异步执行。默认 false(阻塞等待结果)。设为 true 时立即返回任务句柄(如 sub_001),子 Agent 在后台执行,主 Agent 可继续其他工具调用。',
246
+ },
247
+ across_turns: {
248
+ type: 'boolean',
249
+ description: '异步结果是否允许跨 turn。默认 false——子Agent完成后结果在当前 turn 内自动注入对话,LLM 立即看到并继续处理。设为 true 时结果不自动注入,需手动用 get_sub_agent_result 获取(适用于需要跨多轮对话的后台任务)。仅在 async=true 时生效。',
250
+ },
186
251
  },
187
252
  required: ['task'],
188
253
  };
@@ -197,6 +262,8 @@ export class DelegateToAgentTool {
197
262
  const agentName = args.agent_name;
198
263
  const task = args.task;
199
264
  const context = args.context;
265
+ const runAsync = args.async === true;
266
+ const acrossTurns = args.across_turns === true;
200
267
  if (!agentName && !instanceId) {
201
268
  return 'Error: either agent_name or instance_id is required. Use list_sub_agents to see available agents and their instance IDs.';
202
269
  }
@@ -219,19 +286,23 @@ export class DelegateToAgentTool {
219
286
  if (!agentDef) {
220
287
  return 'Error: no sub-agent found matching the provided criteria.';
221
288
  }
289
+ // 异步模式下不支持 adversarial 和 parallel(这两种本身就是多Agent协同,异步应逐个个委派)
290
+ if (runAsync && agentDef.collaborationMode !== 'delegate') {
291
+ return `错误:异步模式仅支持 collaborationMode="delegate"。当前模式为 "${agentDef.collaborationMode}"。请用 spawn_sub_agent 克隆独立实例,然后对每个实例分别用 async=true 委派。`;
292
+ }
222
293
  // 构造完整任务描述
223
294
  const fullTask = context ? `${task}\n\nAdditional Context:\n${context}` : task;
224
295
  try {
296
+ if (runAsync) {
297
+ return await this.executeAsync(agentDef, fullTask, acrossTurns);
298
+ }
225
299
  if (agentDef.collaborationMode === 'adversarial') {
226
- // 对抗审查模式:找一个不同角色的子 Agent 同时审查
227
300
  return await this.executeAdversarial(agentDef, fullTask);
228
301
  }
229
302
  else if (agentDef.collaborationMode === 'parallel') {
230
- // 并行分工模式:所有 parallel 模式的子 Agent 并行执行
231
303
  return await this.executeParallel(agentDef, fullTask);
232
304
  }
233
305
  else {
234
- // 委托模式:直接委托给子 Agent
235
306
  return await this.executeDelegate(agentDef, fullTask);
236
307
  }
237
308
  }
@@ -241,7 +312,54 @@ export class DelegateToAgentTool {
241
312
  }
242
313
  }
243
314
  /**
244
- * 委托模式:单个子 Agent 执行任务
315
+ * 异步委托:在后台启动子 Agent,立即返回任务句柄。
316
+ * 子 Agent 的 loop.run() 在后台 Promise 中执行,完成后结果写入 AsyncSubAgentTask。
317
+ */
318
+ async executeAsync(agentDef, task, acrossTurns) {
319
+ const instanceId = agentDef.instanceId;
320
+ const handle = registerAsyncTask(agentDef.name, instanceId, task);
321
+ // 在后台启动子 Agent,不阻塞。catch 兜底防止未处理的 Promise rejection
322
+ this.runAsyncInBackground(handle, agentDef, task, acrossTurns).catch((err) => {
323
+ const message = err instanceof Error ? err.message : String(err);
324
+ failAsyncTask(handle, message);
325
+ if (!acrossTurns) {
326
+ this.parentContext.pendingAsyncResults?.push({ handle, agentName: agentDef.name, status: 'failed', error: message });
327
+ }
328
+ });
329
+ const hint = acrossTurns
330
+ ? `跨 turn 模式——任务完成后需手动用 get_sub_agent_result ${handle} 获取结果。`
331
+ : '回合内模式——子Agent完成后结果将自动注入当前对话。';
332
+ return `异步子 Agent 任务已启动。\n句柄: ${handle}\nAgent: ${agentDef.name} (${instanceId})\n${hint}\n任务: ${task.slice(0, 100)}${task.length > 100 ? '...' : ''}`;
333
+ }
334
+ /** 后台执行子 Agent 任务 */
335
+ async runAsyncInBackground(handle, agentDef, task, acrossTurns) {
336
+ const instanceId = agentDef.instanceId;
337
+ try {
338
+ const { loop, sessionDir, isNew } = await createSubAgentLoop(agentDef, task, this.parentContext);
339
+ registerRunningLoop(instanceId, loop);
340
+ if (!isNew) {
341
+ await fs.utimes(path.join(sessionDir, 'meta.json'), new Date(), new Date()).catch(() => { });
342
+ }
343
+ await loop.run(task);
344
+ unregisterRunningLoop(instanceId);
345
+ const result = await this.collectResult(agentDef, task);
346
+ completeAsyncTask(handle, result);
347
+ if (!acrossTurns) {
348
+ // 回合内模式:推送到主 loop 队列,当前 turn 自动注入结果
349
+ this.parentContext.pendingAsyncResults?.push({ handle, agentName: agentDef.name, status: 'completed', result });
350
+ }
351
+ }
352
+ catch (error) {
353
+ unregisterRunningLoop(instanceId);
354
+ const message = error instanceof Error ? error.message : String(error);
355
+ failAsyncTask(handle, message);
356
+ if (!acrossTurns) {
357
+ this.parentContext.pendingAsyncResults?.push({ handle, agentName: agentDef.name, status: 'failed', error: message });
358
+ }
359
+ }
360
+ }
361
+ /**
362
+ * 委托模式:单个子 Agent 执行任务(同步阻塞)
245
363
  */
246
364
  async executeDelegate(agentDef, task) {
247
365
  const instanceId = agentDef.instanceId;
@@ -0,0 +1,25 @@
1
+ import type { Tool } from '../tools/interface.js';
2
+ import type { ChannelManager } from './manager.js';
3
+ /**
4
+ * MessageDispatcher — 统一分发层。
5
+ * 查目标渠道 → 调其 send() → 返回结果。
6
+ */
7
+ export declare class MessageDispatcher {
8
+ private channelManager;
9
+ constructor(channelManager: ChannelManager);
10
+ send(args: {
11
+ channel: string;
12
+ to: string;
13
+ targetType?: 'user' | 'chat';
14
+ text?: string;
15
+ images?: Array<{
16
+ data: string;
17
+ media_type: string;
18
+ }>;
19
+ }): Promise<string>;
20
+ }
21
+ /**
22
+ * send_channel_message 工具——暴露给 Agent 的跨渠道发送能力。
23
+ */
24
+ export declare function createSendChannelMessageTool(dispatcher: MessageDispatcher): Tool;
25
+ //# sourceMappingURL=dispatcher.d.ts.map
@@ -0,0 +1,96 @@
1
+ // ============================================================
2
+ // MessageDispatcher — 跨渠道消息分发层
3
+ // ============================================================
4
+ //
5
+ // 允许 Agent 从任意会话借用其他渠道的发送能力。
6
+ // 消息是一次性的——不创建 session、不写 conversation、不影响任何渠道的对话状态。
7
+ // 被借用的渠道对"谁借了道"完全无感。
8
+ // ============================================================
9
+ /**
10
+ * MessageDispatcher — 统一分发层。
11
+ * 查目标渠道 → 调其 send() → 返回结果。
12
+ */
13
+ export class MessageDispatcher {
14
+ channelManager;
15
+ constructor(channelManager) {
16
+ this.channelManager = channelManager;
17
+ }
18
+ async send(args) {
19
+ const state = this.channelManager.get(args.channel);
20
+ if (!state || state.status !== 'active') {
21
+ return `渠道 "${args.channel}" 未连接或未启动。可用渠道: ${this.channelManager.getStatusSummary().map(s => s.id).join(', ') || '无'}`;
22
+ }
23
+ const handler = state.handler;
24
+ if (!handler.send) {
25
+ return `渠道 "${args.channel}" (${handler.name}) 不支持主动发送。`;
26
+ }
27
+ const target = {
28
+ type: args.targetType ?? 'user',
29
+ id: args.to,
30
+ };
31
+ try {
32
+ const result = await handler.send(target, {
33
+ content: args.text ?? '',
34
+ images: args.images,
35
+ });
36
+ return result;
37
+ }
38
+ catch (err) {
39
+ return `发送失败: ${err instanceof Error ? err.message : String(err)}`;
40
+ }
41
+ }
42
+ }
43
+ /**
44
+ * send_channel_message 工具——暴露给 Agent 的跨渠道发送能力。
45
+ */
46
+ export function createSendChannelMessageTool(dispatcher) {
47
+ return {
48
+ name: 'send_channel_message',
49
+ description: '借用指定渠道发送消息到目标用户或群聊。纯借用渠道能力——不创建会话、不写入对话记录、不影响其他渠道。' +
50
+ '适用于从当前渠道(如 TUI)通过飞书/微信等渠道向用户发送通知、图片或文件。',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ channel: {
55
+ type: 'string',
56
+ description: '目标渠道 ID。可用 channel_info 查看已连接渠道。如 "feishu"、"clawbot"。',
57
+ },
58
+ to: {
59
+ type: 'string',
60
+ description: '目标用户 ID 或群聊 ID。飞书为 open_id 或 chat_id,微信为 wxid。',
61
+ },
62
+ target_type: {
63
+ type: 'string',
64
+ enum: ['user', 'chat'],
65
+ description: '目标类型。user=私聊,chat=群聊。默认 user。',
66
+ },
67
+ text: {
68
+ type: 'string',
69
+ description: '要发送的文本内容。',
70
+ },
71
+ images: {
72
+ type: 'array',
73
+ items: {
74
+ type: 'object',
75
+ properties: {
76
+ data: { type: 'string', description: '图片的 base64 数据' },
77
+ media_type: { type: 'string', description: 'MIME 类型,如 image/png' },
78
+ },
79
+ },
80
+ description: '要发送的图片列表(base64 + MIME 类型)。',
81
+ },
82
+ },
83
+ required: ['channel', 'to'],
84
+ },
85
+ async execute(args) {
86
+ return dispatcher.send({
87
+ channel: args.channel,
88
+ to: args.to,
89
+ targetType: args.target_type ?? 'user',
90
+ text: args.text || undefined,
91
+ images: args.images,
92
+ });
93
+ },
94
+ };
95
+ }
96
+ //# sourceMappingURL=dispatcher.js.map
@@ -45,6 +45,13 @@ export interface ChannelReply {
45
45
  }>;
46
46
  metadata?: Record<string, unknown>;
47
47
  }
48
+ /** 主动发送的目标(跨渠道借用能力时指定接收方) */
49
+ export interface ChannelTarget {
50
+ /** 目标类型 */
51
+ type: 'user' | 'chat';
52
+ /** 用户 ID 或群聊 ID */
53
+ id: string;
54
+ }
48
55
  /** 输出处理器(渠道消息收集用) */
49
56
  export interface ChannelOutputHandler {
50
57
  onText?(content: string): void;
@@ -137,6 +144,16 @@ export interface ChannelHandler {
137
144
  * 获取渠道状态
138
145
  */
139
146
  getStatus(): ChannelStatus;
147
+ /**
148
+ * 主动发送消息到渠道(跨 session,不关联任何 AgentLoop、不落盘)。
149
+ * 用于从其他渠道借用本渠道的发送能力(如 TUI 中的 Agent 通过飞书发消息)。
150
+ * 未实现 = 该渠道不支持被外部借用。
151
+ *
152
+ * @param target 目标用户或群聊
153
+ * @param content 消息内容
154
+ * @returns 发送结果描述
155
+ */
156
+ send?(target: ChannelTarget, content: ChannelReply): Promise<string>;
140
157
  }
141
158
  export type ChannelStatus = 'registered' | 'starting' | 'active' | 'stopped' | 'error';
142
159
  export interface ChannelState {
@@ -1,4 +1,4 @@
1
- import type { ChannelHandler, ChannelEvent, ChannelReply, ChannelConfig, ChannelStatus, ChannelMessageEvent, AgentFactory, ReplyFn } from '../../interface.js';
1
+ import type { ChannelHandler, ChannelEvent, ChannelReply, ChannelTarget, ChannelConfig, ChannelStatus, ChannelMessageEvent, AgentFactory, ReplyFn } from '../../interface.js';
2
2
  export declare class FeishuChannel implements ChannelHandler {
3
3
  readonly id = "feishu";
4
4
  readonly name = "Feishu (\u98DE\u4E66)";
@@ -32,6 +32,8 @@ export declare class FeishuChannel implements ChannelHandler {
32
32
  onEvent(handler: (event: ChannelEvent) => Promise<void>): void;
33
33
  reply(sessionId: string, reply: ChannelReply): Promise<void>;
34
34
  getStatus(): ChannelStatus;
35
+ send(target: ChannelTarget, content: ChannelReply): Promise<string>;
36
+ private uploadImage;
35
37
  /** 获取 tenant access token(缓存,提前 60s 刷新,token 有效期 2h) */
36
38
  private getTenantAccessToken;
37
39
  /**
@@ -14,7 +14,7 @@
14
14
  import { resolveFeishuConfig, validateFeishuConfig, } from './feishu-config.js';
15
15
  import { FeishuTransport } from './feishu-transport.js';
16
16
  import { parseFeishuMessageEvent, FeishuDedupeStore, checkDmAccess, checkGroupAccess, } from './feishu-event.js';
17
- import { sendText, sendCard, getSenderInfo } from './feishu-send.js';
17
+ import { sendText, sendCard, sendImage, getSenderInfo } from './feishu-send.js';
18
18
  import { ChannelSessionPool, createCollectHandler } from './feishu-session.js';
19
19
  import { FeishuMessageQueue } from './feishu-message-queue.js';
20
20
  import { generateSessionId } from '../../../memory/session.js';
@@ -176,6 +176,109 @@ export class FeishuChannel {
176
176
  getStatus() {
177
177
  return this.status;
178
178
  }
179
+ // ================================================================
180
+ // send() — 跨渠道借用能力入口
181
+ // ================================================================
182
+ //
183
+ // 设计意图:
184
+ // 每个渠道有自己独特的发送能力(飞书能发卡片和图片、微信能发文件、
185
+ // TUI 只能显示文本)。send() 将这些能力暴露给外部,使得其他渠道
186
+ // 的 Agent 可以借用本渠道的能力发送消息。
187
+ //
188
+ // 行为保证("纯借用"语义):
189
+ // - 不创建 session —— 消息是一次性的,没有对应的 AgentLoop
190
+ // - 不写 conversation.jsonl —— 不污染任何渠道的对话历史
191
+ // - 不影响 ChannelHandler 内部状态 —— sessionMap、消息队列等完全不变
192
+ // - 被借用方对"谁借的"完全无感 —— 不需要知道调用方是哪个渠道
193
+ //
194
+ // 调用链:
195
+ // Agent → send_channel_message 工具 → MessageDispatcher
196
+ // → ChannelManager.get('feishu') → FeishuChannel.send()
197
+ //
198
+ // 支持的消息类型(按优先级):
199
+ // 1. 卡片消息(content.metadata.card 存在时)—— 飞书交互式卡片
200
+ // 2. 文本消息(默认)—— Post 格式,支持 Markdown 子集
201
+ // 3. 图片消息(content.images 存在时)—— 先上传获取 image_key,再发送
202
+ // ↑ 图片在文本/卡片之后独立发送,失败不影响前面的消息
203
+ //
204
+ // 注意:
205
+ // - 图片上传需要 tenant_access_token(通过 getTenantAccessToken 获取)
206
+ // - 每条图片独立上传 + 发送,某张失败不阻塞其他图片
207
+ // - 图片 base64 直接传给飞书 API,不在本地落盘
208
+ // ================================================================
209
+ async send(target, content) {
210
+ if (this.status !== 'active')
211
+ return '飞书渠道未连接,无法发送';
212
+ const config = this.config;
213
+ // 飞书 SDK 的 receive_id_type 由 ID 前缀推断(ou_→open_id, oc_→chat_id)
214
+ // sendText/sendCard/sendImage 内部调用 resolveSendTarget 自动处理
215
+ const to = target.type === 'chat' ? `chat:${target.id}` : `user:${target.id}`;
216
+ try {
217
+ // ① 主消息:卡片优先,否则文本
218
+ if (content.metadata?.card) {
219
+ await sendCard(config, { to, card: content.metadata.card });
220
+ }
221
+ else {
222
+ await sendText(config, { to, text: content.content });
223
+ }
224
+ // ② 图片:在文本/卡片之后独立发送
225
+ // 飞书的图片消息和文本消息是两条独立的消息,不支持图文混排
226
+ if (content.images && content.images.length > 0) {
227
+ const token = await this.getTenantAccessToken();
228
+ if (token) {
229
+ for (const img of content.images) {
230
+ try {
231
+ // 飞书图片发送两步:上传拿 image_key → 调用 im.message.create 发送
232
+ const imageKey = await this.uploadImage(token, img.data);
233
+ await sendImage(config, { to, imageKey });
234
+ }
235
+ catch {
236
+ // 某张图片失败不阻塞其他图片和整体流程
237
+ }
238
+ }
239
+ }
240
+ }
241
+ return `已通过飞书发送到 ${to}`;
242
+ }
243
+ catch (err) {
244
+ return `飞书发送失败: ${err instanceof Error ? err.message : String(err)}`;
245
+ }
246
+ }
247
+ // ================================================================
248
+ // uploadImage() — 飞书图片上传
249
+ // ================================================================
250
+ //
251
+ // 飞书发送图片必须先上传到飞书服务器获取 image_key,
252
+ // 再用 image_key 调用 im.message.create(msg_type='image')发送。
253
+ //
254
+ // API: POST https://open.feishu.cn/open-apis/im/v1/images
255
+ // 参数: image_type='message'(消息用图,非头像),image=base64(不含 data:xxx;base64, 前缀)
256
+ // 返回: { code: 0, data: { image_key: 'img_xxx' } }
257
+ //
258
+ // 注意:
259
+ // - token 由调用方传入(来自 getTenantAccessToken 的缓存结果)
260
+ // - mediaType 参数保留供未来扩展(如格式校验),当前仅透传 base64
261
+ // - 飞书 image_key 有时效性(约 2 小时),不持久化缓存
262
+ // ================================================================
263
+ async uploadImage(token, base64Data, _mediaType) {
264
+ const domain = this.config?.domain ?? 'https://open.feishu.cn';
265
+ const resp = await fetch(`${domain}/open-apis/im/v1/images`, {
266
+ method: 'POST',
267
+ headers: {
268
+ 'Authorization': `Bearer ${token}`,
269
+ 'Content-Type': 'application/json',
270
+ },
271
+ body: JSON.stringify({
272
+ image_type: 'message',
273
+ image: base64Data,
274
+ }),
275
+ });
276
+ const json = await resp.json();
277
+ if (json.code !== 0 || !json.data?.image_key) {
278
+ throw new Error(`飞书图片上传失败: code=${json.code}`);
279
+ }
280
+ return json.data.image_key;
281
+ }
179
282
  /** 获取 tenant access token(缓存,提前 60s 刷新,token 有效期 2h) */
180
283
  async getTenantAccessToken() {
181
284
  if (this.cachedToken && Date.now() < this.tokenExpiresAt - 60_000) {
@@ -41,4 +41,11 @@ export declare function getSenderInfo(config: FeishuChannelConfig, userId: strin
41
41
  name?: string;
42
42
  avatar?: string;
43
43
  } | null>;
44
+ /**
45
+ * 发送图片消息(需先通过上传 API 获取 image_key)。
46
+ */
47
+ export declare function sendImage(config: FeishuChannelConfig, params: {
48
+ to: string;
49
+ imageKey: string;
50
+ }): Promise<FeishuSendResult>;
44
51
  //# sourceMappingURL=feishu-send.d.ts.map
@@ -229,4 +229,27 @@ export async function getSenderInfo(config, userId, userIdType = 'open_id') {
229
229
  return null;
230
230
  }
231
231
  }
232
+ /**
233
+ * 发送图片消息(需先通过上传 API 获取 image_key)。
234
+ */
235
+ export async function sendImage(config, params) {
236
+ const client = await createFeishuClient(config);
237
+ const { receiveId, receiveIdType } = resolveSendTarget(params.to);
238
+ const content = JSON.stringify({ image_key: params.imageKey });
239
+ const res = await client.im.message.create({
240
+ params: { receive_id_type: receiveIdType },
241
+ data: {
242
+ receive_id: receiveId,
243
+ content,
244
+ msg_type: 'image',
245
+ },
246
+ });
247
+ if (res.code !== 0) {
248
+ throw new Error(`飞书图片发送失败: ${res.msg || `code ${res.code}`}`);
249
+ }
250
+ return {
251
+ messageId: res.data?.message_id ?? '',
252
+ chatId: receiveId,
253
+ };
254
+ }
232
255
  //# sourceMappingURL=feishu-send.js.map
@@ -424,12 +424,15 @@ export async function createAgent(options, supervisor) {
424
424
  for (const agent of agents) {
425
425
  agentRegistry.register(agent);
426
426
  }
427
+ // 异步子Agent 结果队列——先创建引用,后续赋给 loop
428
+ const pendingAsyncResults = [];
427
429
  const delegateTool = new DelegateToAgentTool(agentRegistry, {
428
430
  modelRouter,
429
431
  toolRegistry,
430
432
  sessionDir,
431
433
  maxContextTokens: maxContext,
432
434
  dependencyAnalyzer,
435
+ pendingAsyncResults,
433
436
  createSubProvider: (userId) => {
434
437
  if (!subProviderApiKey || !subProviderBaseType) {
435
438
  throw new Error('Cannot create sub-agent provider: main provider not configured.');
@@ -517,6 +520,7 @@ export async function createAgent(options, supervisor) {
517
520
  const loop = new AgentLoop(provider, contextComposer, compressor, orchestrator, toolExecutor, toolRegistry, conversationStore, eventStore, statsManager, sessionDir, summaryStore, effectiveMaxTurns, maxContext, outputHandler, skillRegistry, mcpSystem.getBridge(), dependencyAnalyzer, agentRegistry, effectivePersonaDir, flowRegistry, providerRouter, new Set(config.safety?.dangerousTools ?? ['write', 'bash']), new Set(), configCenter, modelRouter, turnRecorder);
518
521
  // 注入知识库状态引用
519
522
  loop.kbState = kbState;
523
+ loop.pendingAsyncResults = pendingAsyncResults; // 异步子Agent结果队列(和 delegateTool 共享引用)
520
524
  loopRef = loop; // wire fallback notification
521
525
  // 注入旁路 Provider 引用(供模式切换时 setBypassUserId 使用)
522
526
  if (bypassProvider)
@@ -970,6 +970,12 @@ export async function runTui(provider, sessionId, shouldContinue, maxTurns, maxC
970
970
  }
971
971
  // 启动所有渠道(每个渠道自行处理消息)
972
972
  await channelManager.startAll(agentFactory);
973
+ // 注册跨渠道发送工具(TUI 模式)
974
+ if (agent) {
975
+ const { MessageDispatcher, createSendChannelMessageTool } = await import('../channels/dispatcher.js');
976
+ const dispatcher = new MessageDispatcher(channelManager);
977
+ agent.toolRegistry.register(createSendChannelMessageTool(dispatcher));
978
+ }
973
979
  // ── ASCII Art loader ──────────────────────────────────────────────
974
980
  async function loadAsciiArt(maxWidth = 54) {
975
981
  const asciiDir = path.join(os.homedir(), '.agent', 'ascii');
@@ -1,5 +1,6 @@
1
1
  import { ProcessManager } from './manager.js';
2
2
  import { LocalModelManager } from './local-model.js';
3
+ import { waitForAsyncTasks } from '../agents/delegate-tool.js';
3
4
  /**
4
5
  * LifecycleSupervisor — 统一管理所有受管进程的生命周期。
5
6
  *
@@ -184,6 +185,8 @@ export class LifecycleSupervisor {
184
185
  if (this.backgroundRegistry) {
185
186
  await this.backgroundRegistry.shutdownAll().catch(() => { });
186
187
  }
188
+ // 等待异步子 Agent 任务完成(最多等 5 秒)
189
+ await waitForAsyncTasks(5000).catch(() => { });
187
190
  const results = this.entities.map((e) => e.manager.stop().catch(() => {
188
191
  // 单个关闭失败不影响其他
189
192
  }));
@@ -151,6 +151,14 @@ export declare class AgentLoop {
151
151
  name: string;
152
152
  firedAt: string;
153
153
  }>;
154
+ /** 异步子Agent 完成后的结果队列(delegate-tool 写入,主循环消费) */
155
+ pendingAsyncResults: Array<{
156
+ handle: string;
157
+ agentName: string;
158
+ status: 'completed' | 'failed';
159
+ result?: string;
160
+ error?: string;
161
+ }>;
154
162
  /** 当前正在执行的定时任务名(供 TUI 显示上下文),run 前设置,run 后清除 */
155
163
  pendingTaskName: string | null;
156
164
  /** 知识库状态引用(factory 注入) */
@@ -203,6 +203,8 @@ export class AgentLoop {
203
203
  schedulerInitialized = false;
204
204
  /** 定时任务触发后待注入对话的通知 */
205
205
  pendingTaskNotifications = [];
206
+ /** 异步子Agent 完成后的结果队列(delegate-tool 写入,主循环消费) */
207
+ pendingAsyncResults = [];
206
208
  /** 当前正在执行的定时任务名(供 TUI 显示上下文),run 前设置,run 后清除 */
207
209
  pendingTaskName = null;
208
210
  /** 知识库状态引用(factory 注入) */
@@ -1159,6 +1161,23 @@ export class AgentLoop {
1159
1161
  turnCount++;
1160
1162
  if (result.toolCalled)
1161
1163
  toolWasCalled = true;
1164
+ // ── 异步子Agent 结果回合内注入 ─────────────────────────
1165
+ // delegate-tool 中异步任务完成后会将结果推送到此队列。
1166
+ // 本轮迭代结束后检查:有已完成的结果→注入对话→强制继续迭代,
1167
+ // 让 LLM 在当前 turn 内拿到结果并做出反应,无需跨 turn 手动查。
1168
+ if (this.pendingAsyncResults.length > 0) {
1169
+ const results = this.pendingAsyncResults.splice(0);
1170
+ for (const r of results) {
1171
+ const content = r.status === 'completed'
1172
+ ? `[异步子Agent ${r.handle} (${r.agentName}) 已完成]\n${r.result ?? ''}`
1173
+ : `[异步子Agent ${r.handle} (${r.agentName}) 执行失败]\n${r.error ?? ''}`;
1174
+ await this.conversationStore.append(this.sessionDir, {
1175
+ role: 'user',
1176
+ content: [{ type: 'text', text: content }],
1177
+ });
1178
+ }
1179
+ result.stop = false; // 强制继续迭代,让 LLM 看到注入的异步结果
1180
+ }
1162
1181
  // 更新 stats
1163
1182
  await this.statsManager.increment(this.sessionDir, 'turn_count', 1);
1164
1183
  // ── 旁路Agent 迭代审查(每次 LLM 回复后) ──────────
@@ -3,7 +3,7 @@ import { getActiveRouterName } from '../context/profiles.js';
3
3
  import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import { createGetConfigTool, createUpdateConfigTool, createConfigSchemaTool, createResetConfigTool, } from '../tools/config.js';
6
- import { createSwitchProviderTool, createListProvidersTool, createProviderInfoTool, createSwitchToAutoRouteTool, createToggleToolTool, createListToolsTool, createToggleSkillTool, createListSkillsTool, createToggleSubAgentTool, createListSubAgentsTool, createSpawnSubAgentTool, createCreateSubAgentTool, createUpdateSubAgentTool, createInterruptTool, createSessionStatsTool, createCurrentSessionTool, createListSessionsTool, createNewSessionTool, createSwitchSessionTool, createDeleteSessionTool, createAllowToolTool, createDisallowToolTool, createListAllowlistTool, createAddTaskTool, createRemoveTaskTool, createListTasksTool, createToggleTaskTool, createMcpStatusTool, createListModelChannelsTool, createAddModelChannelTool, createRemoveModelChannelTool, createSetChannelRoleTool, createSetChannelModelTool, createResetChannelModelTool, createChannelInfoTool, } from '../tools/runtime-control.js';
6
+ import { createSwitchProviderTool, createListProvidersTool, createProviderInfoTool, createSwitchToAutoRouteTool, createToggleToolTool, createListToolsTool, createToggleSkillTool, createListSkillsTool, createToggleSubAgentTool, createListSubAgentsTool, createSpawnSubAgentTool, createCreateSubAgentTool, createUpdateSubAgentTool, createListSubAgentTasksTool, createGetSubAgentResultTool, createInterruptTool, createSessionStatsTool, createCurrentSessionTool, createListSessionsTool, createNewSessionTool, createSwitchSessionTool, createDeleteSessionTool, createAllowToolTool, createDisallowToolTool, createListAllowlistTool, createAddTaskTool, createRemoveTaskTool, createListTasksTool, createToggleTaskTool, createMcpStatusTool, createListModelChannelsTool, createAddModelChannelTool, createRemoveModelChannelTool, createSetChannelRoleTool, createSetChannelModelTool, createResetChannelModelTool, createChannelInfoTool, } from '../tools/runtime-control.js';
7
7
  /**
8
8
  * 工具注册表
9
9
  * 负责注册、查询工具,以及生成 LLM 格式的工具定义
@@ -110,6 +110,9 @@ export class ToolRegistry extends GenericRegistry {
110
110
  this.register(createSpawnSubAgentTool(agentRegistry));
111
111
  this.register(createCreateSubAgentTool(agentRegistry, cwd));
112
112
  this.register(createUpdateSubAgentTool(agentRegistry));
113
+ // ── 异步子 Agent 任务工具 (2) ─────────────────────────────────
114
+ this.register(createListSubAgentTasksTool());
115
+ this.register(createGetSubAgentResultTool());
113
116
  // destroy_sub_agent needs factory.ts sessionDir; register in factory.ts after loop is created
114
117
  // (handled by importing and registering createDestroySubAgentTool directly in factory.ts)
115
118
  // ── Session control tools (7) ────────────────────────
@@ -51,6 +51,14 @@ export declare function createToggleSubAgentTool(agentRegistry: AgentRegistry, c
51
51
  * list_sub_agents — list all registered sub-agents with their enabled/disabled status and instance IDs.
52
52
  */
53
53
  export declare function createListSubAgentsTool(agentRegistry: any): Tool;
54
+ /**
55
+ * list_sub_agent_tasks — 列出所有异步子 Agent 任务及其状态。
56
+ */
57
+ export declare function createListSubAgentTasksTool(): Tool;
58
+ /**
59
+ * get_sub_agent_result — 获取异步子 Agent 任务的执行结果。
60
+ */
61
+ export declare function createGetSubAgentResultTool(): Tool;
54
62
  /**
55
63
  * spawn_sub_agent — clone an existing sub-agent to create a parallel instance (分身).
56
64
  */
@@ -1,5 +1,6 @@
1
1
  import { clearPromptCache } from '../prompts/loader.js';
2
2
  import { switchRouter } from '../context/profiles.js';
3
+ import { listAsyncTasks, getAsyncTask } from '../agents/delegate-tool.js';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import os from 'node:os';
@@ -359,6 +360,69 @@ export function createListSubAgentsTool(agentRegistry) {
359
360
  },
360
361
  };
361
362
  }
363
+ // ── 异步子 Agent 任务管理工具 ──
364
+ /**
365
+ * list_sub_agent_tasks — 列出所有异步子 Agent 任务及其状态。
366
+ */
367
+ export function createListSubAgentTasksTool() {
368
+ return {
369
+ name: 'list_sub_agent_tasks',
370
+ description: '列出所有通过 delegate_to_agent(async=true) 启动的异步子 Agent 任务。包含句柄、Agent 名称、任务描述、状态(running/completed/failed)、启动时间和结果摘要。',
371
+ inputSchema: { type: 'object', properties: {} },
372
+ async execute(_args) {
373
+ const tasks = listAsyncTasks();
374
+ if (tasks.length === 0) {
375
+ return '暂无异步子 Agent 任务。使用 delegate_to_agent 并设置 async=true 来启动异步任务。';
376
+ }
377
+ const lines = [`异步子 Agent 任务(共 ${tasks.length} 个):`];
378
+ for (const t of tasks) {
379
+ const statusLabel = t.status === 'running' ? '执行中' : t.status === 'completed' ? '已完成' : '失败';
380
+ const statusIcon = t.status === 'running' ? '🔄' : t.status === 'completed' ? '✅' : '❌';
381
+ lines.push(` ${statusIcon} ${t.handle} [${statusLabel}] ${t.agentName} (instance: ${t.instanceId}): ${t.task.slice(0, 60)}${t.task.length > 60 ? '...' : ''}`);
382
+ if (t.status !== 'running' && t.result) {
383
+ const summary = t.result.split('\n').find(l => l.trim())?.slice(0, 80) ?? '';
384
+ lines.push(` ↳ ${summary}${summary.length >= 80 ? '...' : ''}`);
385
+ }
386
+ }
387
+ lines.push('');
388
+ lines.push('使用 get_sub_agent_result <handle> 获取已完成任务的完整结果。');
389
+ return lines.join('\n');
390
+ },
391
+ };
392
+ }
393
+ /**
394
+ * get_sub_agent_result — 获取异步子 Agent 任务的执行结果。
395
+ */
396
+ export function createGetSubAgentResultTool() {
397
+ return {
398
+ name: 'get_sub_agent_result',
399
+ description: '获取指定异步子 Agent 任务的执行结果。running 时返回仍在执行中,completed 时返回完整结果,failed 时返回错误信息。句柄从 list_sub_agent_tasks 获取。',
400
+ inputSchema: {
401
+ type: 'object',
402
+ properties: {
403
+ handle: {
404
+ type: 'string',
405
+ description: '异步任务句柄(如 sub_001),从 list_sub_agent_tasks 获取。',
406
+ },
407
+ },
408
+ required: ['handle'],
409
+ },
410
+ async execute(args) {
411
+ const handle = args.handle;
412
+ const task = getAsyncTask(handle);
413
+ if (!task) {
414
+ return `错误:未找到异步任务 "${handle}"。使用 list_sub_agent_tasks 查看所有任务及其句柄。`;
415
+ }
416
+ if (task.status === 'running') {
417
+ return `任务 ${handle}(${task.agentName})仍在执行中。任务:${task.task.slice(0, 100)}${task.task.length > 100 ? '...' : ''}\n启动时间:${task.startTime}\n请稍后再次调用 get_sub_agent_result 查询。`;
418
+ }
419
+ if (task.status === 'failed') {
420
+ return `任务 ${handle}(${task.agentName})执行失败。\n错误:${task.error}\n任务:${task.task}`;
421
+ }
422
+ return `任务 ${handle}(${task.agentName})已完成。\n\n${task.result}`;
423
+ },
424
+ };
425
+ }
362
426
  /**
363
427
  * spawn_sub_agent — clone an existing sub-agent to create a parallel instance (分身).
364
428
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyacinth-ai",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
4
4
  "description": "Hyacinth 🪻 — multi-provider AI agent with tool system and context management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",