@modesto_ma/dsh-channels 0.2.0 → 0.4.0

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.
Files changed (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
@@ -0,0 +1,2345 @@
1
+ /**
2
+ * Model-facing Feishu tools: `feishu_attachment_fetch` (lazy attachment
3
+ * download) and `feishu_topic_messages` (quoted/topic history reads), plus the
4
+ * per-route authorization whitelist they share.
5
+ *
6
+ * Product shape (Roadmap attachment_pipeline_v2): a triggered message creates
7
+ * the Session immediately; files stay unloaded until the model asks for them.
8
+ * This module is the "ask" side: the model names a Feishu message id (and
9
+ * optionally one `file_key`), the tool downloads the resources through the
10
+ * existing channel wrappers, streams files into the DSH native attachment
11
+ * library (`ctx.attachments`) and returns content blocks the model can use.
12
+ *
13
+ * Security model: a message id is NOT a capability. Every call is authorized
14
+ * against a per-route whitelist of messages the channel has already admitted
15
+ * as relevant:
16
+ *
17
+ * - the triggering message / a quoted message registered by the admission and
18
+ * quote layers through {@link AttachmentFetchToolRuntime.noteKnownMessage};
19
+ * - silently registered attachment messages from the `pending_attachments`
20
+ * registry (same `(chat, thread?, sender)` scope rules);
21
+ * - messages this tool already resolved (successful or failed attempts), so a
22
+ * later turn in the same session keeps its authorized resources.
23
+ *
24
+ * The whitelist is an in-memory LRU per route; the pending registry is the
25
+ * durable source for admitted attachment messages (silent registrations and,
26
+ * once the admission task wires it, triggering messages). A prompt-injected
27
+ * message id from another chat, user, or topic therefore cannot download
28
+ * anything. The whitelist is not replayed from Session history: after a host
29
+ * restart the admission/manifest layer re-registers the current turn's
30
+ * messages, while older attachments remain reachable through the durable
31
+ * pending registry until its TTL.
32
+ *
33
+ * Failure model: every per-resource failure is a structured value (never a
34
+ * thrown raw error) so one bad resource does not abort the call and session
35
+ * history stays readable (the P0 `[object Object]` lesson). Cancellation and
36
+ * the whole-call timeout are reported with explicit codes instead.
37
+ *
38
+ * @module dsh-channels/feishu/tools
39
+ */
40
+
41
+ import { z } from 'zod'
42
+ import type { FeishuConfig } from '../config.ts'
43
+ import type { PendingAttachmentRecord, PendingAttachmentResource } from '../domain.ts'
44
+ import type {
45
+ AgentContext, ToolContentBlock, ToolDefinition, ToolExecution,
46
+ } from '../host/agent.ts'
47
+ import type {
48
+ AttachmentStore, FileAttachmentRef, ImageAttachmentRef,
49
+ } from '../host/attachments.ts'
50
+ import type { FeishuChannel, ResourceChunkStream } from '../feishu-channel.ts'
51
+ import { describeFailure } from './inbound-attachments.ts'
52
+ import {
53
+ MessageReader,
54
+ TOPIC_MESSAGES_DEFAULT_LIMIT,
55
+ TOPIC_MESSAGES_MAX_LIMIT,
56
+ clampInt,
57
+ describeReadError,
58
+ knownMessageOfItems,
59
+ messageBelongsToRoute,
60
+ quoteItemsOf,
61
+ topicMessageViewOf,
62
+ } from './message-read.ts'
63
+ import type {
64
+ MessageReadFailureCode, TopicMessageResourceView, TopicMessageView,
65
+ } from './message-read.ts'
66
+ import {
67
+ attachmentFailureReason, FALLBACK_MAX_IMAGE_BYTES, nativeAttachmentStoreOf,
68
+ resolveAttachmentService,
69
+ } from './native-attachments.ts'
70
+ import type {
71
+ AttachmentFailureCode, AttachmentResult, NativeAttachmentStore, StoredFile, StoredImage,
72
+ } from './native-attachments.ts'
73
+ import type { PendingAttachmentScope, PendingAttachmentStore } from './pending-attachments.ts'
74
+
75
+ // ---- Public identity ----------------------------------------------------
76
+
77
+ /** Model-facing tool name; stable across releases (session history references it). */
78
+ export const ATTACHMENT_FETCH_TOOL_NAME = 'feishu_attachment_fetch'
79
+
80
+ /**
81
+ * Default number of known messages kept per route (LRU). Sized well above one
82
+ * history page (≤50 rows): a dense attachment page plus the trigger/quote
83
+ * entries of the current turn must not evict each other.
84
+ */
85
+ export const DEFAULT_KNOWN_MESSAGES_PER_ROUTE = 128
86
+
87
+ /**
88
+ * Maximum time agent setup waits for the host `tools`/`attachments` services
89
+ * before degrading to "tool not mounted". A deployment without those services
90
+ * must not block session creation; if they appear later the pending injection
91
+ * still registers the tool.
92
+ */
93
+ const MOUNT_INJECT_WAIT_MS = 2_000
94
+
95
+ /** Retry wait for later sessions once the first mount timed out (fast-fail). */
96
+ const MOUNT_INJECT_RETRY_WAIT_MS = 250
97
+
98
+ // ---- Route / whitelist vocabulary ---------------------------------------
99
+
100
+ /**
101
+ * The channel-plane identity of one conversation route. The service derives it
102
+ * from the durable `RouteRecord`; all fields are needed to prove that a
103
+ * whitelist entry belongs to the route currently executing the tool.
104
+ */
105
+ export interface AttachmentFetchRoute {
106
+ /** Durable route key (thread id, or the degraded chat route key). */
107
+ readonly key: string
108
+ readonly kind: 'thread' | 'chat'
109
+ readonly chatId: string
110
+ readonly chatType: 'p2p' | 'group'
111
+ readonly senderOpenId: string
112
+ }
113
+
114
+ /** Why a message became known to one route. */
115
+ export type KnownAttachmentSource =
116
+ | 'trigger'
117
+ | 'quote'
118
+ | 'pending'
119
+ | 'tool-result'
120
+ | 'manual'
121
+ /** A message discovered while reading topic history/quote context (P2). */
122
+ | 'history'
123
+
124
+ /** One downloadable resource of a known message. */
125
+ export interface KnownAttachmentResource {
126
+ readonly type: 'image' | 'file'
127
+ readonly fileKey: string
128
+ readonly fileName?: string
129
+ /** Feishu folder entries cannot be downloaded; reported as unsupported. */
130
+ readonly folder?: boolean
131
+ /** Top-level merged-forward container id to download through, when different. */
132
+ readonly containerMessageId?: string
133
+ }
134
+
135
+ /** One message the current route may fetch attachments from. */
136
+ export interface KnownAttachmentMessage {
137
+ readonly messageId: string
138
+ readonly kind: 'message' | 'merge_forward'
139
+ readonly resources: readonly KnownAttachmentResource[]
140
+ /** Sender the message was attributed to when it entered the whitelist. */
141
+ readonly senderOpenId?: string
142
+ }
143
+
144
+ /** Registration input for the whitelist. */
145
+ export interface KnownAttachmentMessageInput extends KnownAttachmentMessage {
146
+ readonly source?: KnownAttachmentSource
147
+ }
148
+
149
+ /** The thread id a route's `(chat, thread?, sender)` scope carries. */
150
+ export function threadIdOfRoute(route: AttachmentFetchRoute): string | undefined {
151
+ return route.kind === 'thread' ? route.key : undefined
152
+ }
153
+
154
+ /**
155
+ * Whether a whitelist entry was registered for exactly this route. Route keys
156
+ * are durable, but records can be replaced (a chat route re-created after a
157
+ * TTL) — re-checking chat, sender, chat type and key kind keeps an old entry
158
+ * from authorizing a new owner.
159
+ */
160
+ function routesMatch(left: AttachmentFetchRoute, right: AttachmentFetchRoute): boolean {
161
+ return left.key === right.key
162
+ && left.kind === right.kind
163
+ && left.chatId === right.chatId
164
+ && left.chatType === right.chatType
165
+ && left.senderOpenId === right.senderOpenId
166
+ }
167
+
168
+ /** Pending-registry scope for one route (`threadId` only for topic routes). */
169
+ export function pendingScopeOfRoute(route: AttachmentFetchRoute): PendingAttachmentScope {
170
+ const threadId = threadIdOfRoute(route)
171
+ return {
172
+ chatId: route.chatId,
173
+ senderOpenId: route.senderOpenId,
174
+ ...(threadId === undefined ? {} : { threadId }),
175
+ }
176
+ }
177
+
178
+ interface WhitelistedMessage {
179
+ readonly route: AttachmentFetchRoute
180
+ readonly message: KnownAttachmentMessage
181
+ readonly source: KnownAttachmentSource
182
+ readonly registeredAt: number
183
+ }
184
+
185
+ /**
186
+ * Per-route LRU of messages the channel has declared relevant.
187
+ *
188
+ * The store is intentionally process-local: silently registered attachments
189
+ * have a durable registry of their own, while trigger/quote knowledge only
190
+ * lives as long as the process (after a restart the admission/manifest layer
191
+ * re-registers the current turn's messages; this tool does not replay Session
192
+ * history into the whitelist).
193
+ */
194
+ export class RouteAttachmentWhitelist {
195
+ private readonly routes = new Map<string, Map<string, WhitelistedMessage>>()
196
+
197
+ constructor(private readonly limit: number = DEFAULT_KNOWN_MESSAGES_PER_ROUTE) {}
198
+
199
+ /** Register (or refresh) one known message for one route. */
200
+ register(
201
+ route: AttachmentFetchRoute,
202
+ message: KnownAttachmentMessage,
203
+ source: KnownAttachmentSource = 'manual',
204
+ now = Date.now(),
205
+ ): void {
206
+ let entries = this.routes.get(route.key)
207
+ if (entries === undefined) {
208
+ entries = new Map()
209
+ this.routes.set(route.key, entries)
210
+ }
211
+ // Re-insertion is the LRU touch: a refreshed message sorts last.
212
+ entries.delete(message.messageId)
213
+ entries.set(message.messageId, {
214
+ route: { ...route },
215
+ message: {
216
+ messageId: message.messageId,
217
+ kind: message.kind,
218
+ resources: message.resources.map(resource => ({ ...resource })),
219
+ ...(message.senderOpenId === undefined ? {} : { senderOpenId: message.senderOpenId }),
220
+ },
221
+ source,
222
+ registeredAt: now,
223
+ })
224
+ while (entries.size > Math.max(1, this.limit)) {
225
+ const oldest = entries.keys().next()
226
+ if (oldest.done === true) break
227
+ entries.delete(oldest.value)
228
+ }
229
+ }
230
+
231
+ /** Look up one message for one route; misses for any other route. */
232
+ lookup(route: AttachmentFetchRoute, messageId: string): KnownAttachmentMessage | undefined {
233
+ const entries = this.routes.get(route.key)
234
+ const hit = entries?.get(messageId)
235
+ if (entries === undefined || hit === undefined) return undefined
236
+ if (!routesMatch(hit.route, route)) return undefined
237
+ // Touch for LRU recency.
238
+ entries.delete(messageId)
239
+ entries.set(messageId, hit)
240
+ return hit.message
241
+ }
242
+
243
+ /** Number of known messages (one route at a time, or all routes). */
244
+ size(routeKey?: string): number {
245
+ if (routeKey !== undefined) return this.routes.get(routeKey)?.size ?? 0
246
+ let total = 0
247
+ for (const entries of this.routes.values()) total += entries.size
248
+ return total
249
+ }
250
+
251
+ /** Every route key currently holding entries (route-GC sweep). */
252
+ routeKeys(): string[] {
253
+ return [...this.routes.keys()]
254
+ }
255
+
256
+ /** Drop every entry of one route (route retirement / tests). */
257
+ forget(routeKey: string): void {
258
+ this.routes.delete(routeKey)
259
+ }
260
+
261
+ /** Drop every route (shutdown / tests). */
262
+ clear(): void {
263
+ this.routes.clear()
264
+ }
265
+ }
266
+
267
+ // ---- Structured failures ------------------------------------------------
268
+
269
+ /** Stable failure codes this tool reports per resource or per call. */
270
+ export type AttachmentFetchFailureCode =
271
+ | AttachmentFailureCode
272
+ | 'ROUTE_NOT_FOUND'
273
+ | 'AUTHORIZATION_DENIED'
274
+ | 'CHANNEL_UNAVAILABLE'
275
+ | 'MESSAGE_READ_FAILED'
276
+ | 'NO_RESOURCES'
277
+ | 'UNKNOWN_FILE_KEY'
278
+ | 'RESOURCE_LIMIT'
279
+ | 'BYTES_LIMIT'
280
+ | 'TOOL_DISABLED'
281
+ | 'TIMEOUT'
282
+ | 'ABORTED'
283
+ | 'DOWNLOAD_FAILED'
284
+ | 'INTERNAL_ERROR'
285
+
286
+ const FETCH_FAILURE_REASONS: Readonly<Record<string, string>> = {
287
+ ROUTE_NOT_FOUND: '当前会话尚未绑定飞书对话,无法获取附件',
288
+ AUTHORIZATION_DENIED: '该消息不在本会话的附件授权范围内(仅可获取触发消息、引用消息或附件清单中登记的消息)',
289
+ CHANNEL_UNAVAILABLE: '飞书连接未运行,无法获取附件',
290
+ MESSAGE_READ_FAILED: '读取飞书消息内容失败',
291
+ NO_RESOURCES: '该消息不包含可下载的图片或文件',
292
+ UNKNOWN_FILE_KEY: '该 file_key 不属于这条消息',
293
+ RESOURCE_LIMIT: '本次消息的附件数量超过单次获取上限',
294
+ BYTES_LIMIT: '本次下载总字节超过单次上限',
295
+ TOOL_DISABLED: '附件获取工具已禁用',
296
+ TIMEOUT: '附件获取超时',
297
+ ABORTED: '附件获取已取消',
298
+ DOWNLOAD_FAILED: '飞书附件下载失败',
299
+ INTERNAL_ERROR: '附件获取失败',
300
+ }
301
+
302
+ /** Model-safe, user-facing reason for one failure code. */
303
+ export function attachmentFetchFailureReason(code: AttachmentFetchFailureCode): string {
304
+ const own = FETCH_FAILURE_REASONS[code]
305
+ if (own !== undefined) return own
306
+ const host = attachmentFailureReason(code as AttachmentFailureCode)
307
+ return typeof host === 'string' && host.length > 0 ? host : FETCH_FAILURE_REASONS.INTERNAL_ERROR ?? '附件获取失败'
308
+ }
309
+
310
+ /** One structured failure: a stable code plus a readable reason. */
311
+ export interface AttachmentFetchFailure {
312
+ readonly ok: false
313
+ readonly code: AttachmentFetchFailureCode
314
+ readonly reason: string
315
+ }
316
+
317
+ function failure(code: AttachmentFetchFailureCode, reason?: string): AttachmentFetchFailure {
318
+ return { ok: false, code, reason: reason ?? attachmentFetchFailureReason(code) }
319
+ }
320
+
321
+ // ---- Tool arguments -----------------------------------------------------
322
+
323
+ /**
324
+ * Runtime argument validation. The host projects
325
+ * {@link ATTACHMENT_FETCH_PARAMETERS} to the model but does not re-validate
326
+ * arguments against it (verified against dsh-tools 0.2.1-rc.2), so this zod
327
+ * schema is the authoritative argument gate; it also gives tests and
328
+ * embedded/non-native callers a typed value.
329
+ */
330
+ export const attachmentFetchArgsSchema = z.object({
331
+ message_id: z.string().min(1),
332
+ file_key: z.string().min(1).optional(),
333
+ }).strict()
334
+
335
+ export type AttachmentFetchArgs = z.infer<typeof attachmentFetchArgsSchema>
336
+
337
+ /** Model-facing parameter schema (hand-written; no DSH runtime import). */
338
+ export const ATTACHMENT_FETCH_PARAMETERS: Record<string, unknown> = {
339
+ type: 'object',
340
+ additionalProperties: false,
341
+ required: ['message_id'],
342
+ properties: {
343
+ message_id: {
344
+ type: 'string',
345
+ description: 'Feishu message id (om_...) of the message whose attachments to fetch.',
346
+ },
347
+ file_key: {
348
+ type: 'string',
349
+ description: 'Optional image/file key of one attachment of that message; omit to fetch every attachment it carries.',
350
+ },
351
+ },
352
+ }
353
+
354
+ // ---- Tool result value --------------------------------------------------
355
+
356
+ /** One fetched (or failed) resource in the tool result value. */
357
+ export interface AttachmentFetchResultItem {
358
+ readonly ok: boolean
359
+ readonly kind: 'image' | 'file'
360
+ readonly name?: string
361
+ readonly file_key?: string
362
+ readonly bytes?: number
363
+ readonly media_type?: string
364
+ readonly width?: number
365
+ readonly height?: number
366
+ /** Read-only host path of one stored image, when the backend maps one. */
367
+ readonly read_path?: string
368
+ /** Deterministic file handle text (name/bytes/digest/read-only path). */
369
+ readonly handle_text?: string
370
+ readonly code?: AttachmentFetchFailureCode
371
+ readonly reason?: string
372
+ readonly attachment?: ImageAttachmentRef | FileAttachmentRef
373
+ }
374
+
375
+ /** Tool output value: lossless JSON by construction (no `undefined` properties). */
376
+ export interface AttachmentFetchValue {
377
+ readonly message_id: string
378
+ readonly ok: boolean
379
+ readonly summary: string
380
+ readonly results: readonly AttachmentFetchResultItem[]
381
+ }
382
+
383
+ /** Model-facing output schema; mirrors {@link AttachmentFetchValue}. */
384
+ export const ATTACHMENT_FETCH_OUTPUT_SCHEMA: Record<string, unknown> = {
385
+ type: 'object',
386
+ additionalProperties: false,
387
+ required: ['message_id', 'ok', 'summary', 'results'],
388
+ properties: {
389
+ message_id: { type: 'string' },
390
+ ok: { type: 'boolean' },
391
+ summary: { type: 'string' },
392
+ results: {
393
+ type: 'array',
394
+ items: {
395
+ type: 'object',
396
+ additionalProperties: false,
397
+ required: ['ok', 'kind'],
398
+ properties: {
399
+ ok: { type: 'boolean' },
400
+ kind: { type: 'string', enum: ['image', 'file'] },
401
+ name: { type: 'string' },
402
+ file_key: { type: 'string' },
403
+ bytes: { type: 'number' },
404
+ media_type: { type: 'string' },
405
+ width: { type: 'number' },
406
+ height: { type: 'number' },
407
+ read_path: { type: 'string' },
408
+ handle_text: { type: 'string' },
409
+ code: { type: 'string' },
410
+ reason: { type: 'string' },
411
+ attachment: { type: 'object' },
412
+ },
413
+ },
414
+ },
415
+ },
416
+ }
417
+
418
+ // ---- Runtime dependency surface -----------------------------------------
419
+
420
+ /** Everything the tool runtime needs, injected by the service. */
421
+ export interface AttachmentFetchRuntimeDeps {
422
+ /** Live plugin configuration (switches, limits). */
423
+ config(): FeishuConfig
424
+ /** Live Feishu channel; undefined while disconnected/shut down. */
425
+ channel(): FeishuChannel | undefined
426
+ /** Durable silent-registration registry (authorization + consumption). */
427
+ pending?(): PendingAttachmentStore | undefined
428
+ /** Resolve the route currently bound to one session. */
429
+ routeOfSession(sessionId: string): AttachmentFetchRoute | undefined
430
+ /** Structured warning sink (mount degradation, unexpected failures). */
431
+ warn(message: string, ...args: unknown[]): void
432
+ /** Clock seam for whitelist recency (tests). */
433
+ now?(): number
434
+ }
435
+
436
+ interface FetchCallContext {
437
+ readonly route: AttachmentFetchRoute
438
+ readonly args: AttachmentFetchArgs
439
+ readonly signal: AbortSignal
440
+ readonly timeout: { value: boolean }
441
+ readonly budget: DownloadBudget
442
+ readonly limits: { readonly maxResources: number }
443
+ readonly native: NativeAttachmentStore
444
+ readonly imageCap: number
445
+ readonly channel: FeishuChannel | undefined
446
+ readonly pending: PendingAttachmentStore | undefined
447
+ }
448
+
449
+ interface DownloadBudget {
450
+ readonly maxBytes: number
451
+ usedBytes: number
452
+ /** Set once a chunk would push the call over its total byte budget. */
453
+ exceeded: boolean
454
+ }
455
+
456
+ /**
457
+ * The `feishu_attachment_fetch` runtime: mounts the tool into Agent scopes and
458
+ * owns the per-route whitelist that the admission/quote layers populate.
459
+ */
460
+ export class AttachmentFetchToolRuntime {
461
+ readonly whitelist: RouteAttachmentWhitelist
462
+ /** Agent scope → its one mount attempt (cordis callbacks get distinct ctx objects). */
463
+ private readonly mountAttempts = new WeakMap<AgentContext, Promise<void>>()
464
+ private warnedMissingTools = false
465
+ private warnedMissingAttachments = false
466
+ private warnedMountFailure = false
467
+ private warnedUnavailable = false
468
+
469
+ constructor(private readonly deps: AttachmentFetchRuntimeDeps) {
470
+ this.whitelist = new RouteAttachmentWhitelist(DEFAULT_KNOWN_MESSAGES_PER_ROUTE)
471
+ }
472
+
473
+ /**
474
+ * Whether the feature switch currently allows tool registration/execution.
475
+ * `acceptAttachments=false` keeps inbound attachment messages inert, so a
476
+ * tool that only downloads them is disabled as well.
477
+ */
478
+ enabled(): boolean {
479
+ const config = this.deps.config()
480
+ return config.feishuTools.attachmentFetch && config.acceptAttachments
481
+ }
482
+
483
+ /**
484
+ * Pre-authorize one message for one route. The admission layer calls this for
485
+ * the triggering message; the quote layer calls it for quoted messages; this
486
+ * tool itself records messages it resolved. A no-op while the feature is
487
+ * disabled keeps a disabled runtime from accumulating state.
488
+ */
489
+ noteKnownMessage(
490
+ route: AttachmentFetchRoute,
491
+ message: KnownAttachmentMessageInput,
492
+ source: KnownAttachmentSource = message.source ?? 'manual',
493
+ ): void {
494
+ if (!this.enabled()) return
495
+ this.whitelist.register(route, message, source, this.now())
496
+ }
497
+
498
+ /**
499
+ * Whether one message id is authorized for one route: the in-memory whitelist
500
+ * first, then the durable pending registry — the same sources
501
+ * {@link resolveKnownMessage} uses when a fetch is executed. The
502
+ * `feishu_topic_messages` tool calls this to decide whether a main-timeline
503
+ * message (no `thread_id`) may be detached into the current topic route.
504
+ *
505
+ * Never throws: an unavailable registry fails closed.
506
+ */
507
+ isKnownMessage(route: AttachmentFetchRoute, messageId: string): boolean {
508
+ if (this.whitelist.lookup(route, messageId) !== undefined) return true
509
+ const pending = this.deps.pending?.()
510
+ if (pending === undefined) return false
511
+ try {
512
+ return pending.get(pendingScopeOfRoute(route), messageId) !== undefined
513
+ } catch {
514
+ // Authorization lookups fail closed; a storage hiccup must not throw into
515
+ // the topic tool's read path.
516
+ return false
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Register the tool inside one Agent scope.
522
+ *
523
+ * Called from the agent-preset `setup` after the preset is mounted (the host
524
+ * composes tools inside presets). `inject(['tools', 'attachments'])` is the
525
+ * P0-proven registration path; the callback re-checks both services because
526
+ * a host may resolve the injection with a partially mounted scope. Missing
527
+ * services are a no-op with one warning; a mount failure never fails the
528
+ * session.
529
+ */
530
+ async mount(agentCtx: AgentContext, sessionId: string): Promise<void> {
531
+ if (!this.enabled()) return
532
+ // Cordis hands the inject callback a DIFFERENT context object than the
533
+ // agent ctx, so dedupe on the outer scope and reuse its single attempt.
534
+ const existing = this.mountAttempts.get(agentCtx)
535
+ if (existing !== undefined) return await existing
536
+ const attempt = this.mountAttempt(agentCtx, sessionId)
537
+ this.mountAttempts.set(agentCtx, attempt)
538
+ await attempt
539
+ }
540
+
541
+ private async mountAttempt(agentCtx: AgentContext, sessionId: string): Promise<void> {
542
+ let injected = false
543
+ const outcome = await waitForAvailability(
544
+ Promise.resolve(agentCtx.inject(['tools', 'attachments'], (ctx) => {
545
+ injected = true
546
+ this.registerInScope(ctx, sessionId)
547
+ })),
548
+ // A deployment that already timed out once is unlikely to gain the
549
+ // services a moment later: fail fast on later sessions while the pending
550
+ // injection can still backfill registration if they do appear.
551
+ this.warnedUnavailable ? MOUNT_INJECT_RETRY_WAIT_MS : MOUNT_INJECT_WAIT_MS,
552
+ )
553
+ if (outcome === 'failed') {
554
+ this.warnMountFailure(`dsh-channels: mounting ${ATTACHMENT_FETCH_TOOL_NAME} failed`)
555
+ return
556
+ }
557
+ // Real cordis settles an injection whose dependencies never appear WITHOUT
558
+ // calling the callback; treat "settled but never injected" as unavailable.
559
+ // The fiber stays pending so a late service still backfills registration.
560
+ if (!injected && !this.warnedUnavailable) {
561
+ this.warnedUnavailable = true
562
+ this.deps.warn(
563
+ 'dsh-channels: %s 未挂载:宿主 tools/attachments 服务不可用(会话继续)',
564
+ ATTACHMENT_FETCH_TOOL_NAME,
565
+ )
566
+ }
567
+ }
568
+
569
+ /** Drop the whitelist entries of one retired route (record GC / cleanup). */
570
+ forgetRoute(routeKey: string): void {
571
+ this.whitelist.forget(routeKey)
572
+ }
573
+
574
+ /**
575
+ * Drop whitelist routes that no longer resolve as active route records. The
576
+ * inbound TTL path retires a thread route without going through the service
577
+ * cleanup callback, so the periodic GC reconciles the whitelist afterwards.
578
+ * @returns number of route buckets dropped.
579
+ */
580
+ pruneRoutes(stillActive: (routeKey: string) => boolean): number {
581
+ let removed = 0
582
+ for (const key of this.whitelist.routeKeys()) {
583
+ if (stillActive(key)) continue
584
+ this.whitelist.forget(key)
585
+ removed += 1
586
+ }
587
+ return removed
588
+ }
589
+
590
+ /** Drop every whitelisted route (shutdown). */
591
+ clear(): void {
592
+ this.whitelist.clear()
593
+ }
594
+
595
+ private registerInScope(agentCtx: AgentContext, sessionId: string): void {
596
+ // No per-callback-context dedupe: when a host service unloads/reloads,
597
+ // cordis disposes the injection fiber's effects (including our
598
+ // `tools.register`) and invokes this callback again with the same ctx
599
+ // object. Skipping the re-registration would silently lose the tool; a
600
+ // genuinely duplicated registration is caught below as a warning.
601
+ const tools = agentCtx.tools
602
+ if (tools === undefined || typeof tools.register !== 'function') {
603
+ if (!this.warnedMissingTools) {
604
+ this.warnedMissingTools = true
605
+ this.deps.warn(
606
+ 'dsh-channels: %s 未注册:宿主 tools 服务不可用(附件工具已禁用)',
607
+ ATTACHMENT_FETCH_TOOL_NAME,
608
+ )
609
+ }
610
+ return
611
+ }
612
+ const service = resolveAttachmentService(agentCtx)
613
+ if (service === undefined) {
614
+ if (!this.warnedMissingAttachments) {
615
+ this.warnedMissingAttachments = true
616
+ this.deps.warn(
617
+ 'dsh-channels: %s 未注册:宿主 attachments 服务不可用(附件工具已禁用)',
618
+ ATTACHMENT_FETCH_TOOL_NAME,
619
+ )
620
+ }
621
+ return
622
+ }
623
+ try {
624
+ tools.register(this.definitionFor(agentCtx, sessionId))
625
+ } catch (error) {
626
+ this.warnMountFailure(
627
+ `dsh-channels: registering ${ATTACHMENT_FETCH_TOOL_NAME} failed: ${String(error)}`,
628
+ )
629
+ }
630
+ }
631
+
632
+ private warnMountFailure(message: string): void {
633
+ if (this.warnedMountFailure) return
634
+ this.warnedMountFailure = true
635
+ this.deps.warn(message)
636
+ }
637
+
638
+ private now(): number {
639
+ return this.deps.now?.() ?? Date.now()
640
+ }
641
+
642
+ // ---- Tool definition --------------------------------------------------
643
+
644
+ private definitionFor(agentCtx: AgentContext, sessionId: string): ToolDefinition {
645
+ return {
646
+ name: ATTACHMENT_FETCH_TOOL_NAME,
647
+ description:
648
+ 'Fetch images/files attached to a known Feishu message in this conversation. '
649
+ + 'Images come back as viewable image content; files are saved as immutable read-only copies and returned with a readable path. '
650
+ + 'Without file_key every attachment of the message is fetched. '
651
+ + 'Only messages already known to this conversation (the triggering message, a quoted message, or an attachment listed in context) are accessible; unknown message ids are refused. '
652
+ + 'Calls are bounded by a per-call attachment count, total byte budget and timeout; per-attachment failures are reported without aborting the whole call.',
653
+ parameters: ATTACHMENT_FETCH_PARAMETERS,
654
+ output: {
655
+ schema: ATTACHMENT_FETCH_OUTPUT_SCHEMA,
656
+ render: (_args, value) => renderAttachmentFetchResult(value),
657
+ },
658
+ execute: async (rawArgs, exec) => await this.execute(agentCtx, sessionId, rawArgs, exec),
659
+ presentCall: args => presentAttachmentFetchCall(args),
660
+ presentResult: (_args, value) => presentAttachmentFetchResult(value),
661
+ isConcurrencySafe: () => false,
662
+ }
663
+ }
664
+
665
+ // ---- Execution --------------------------------------------------------
666
+
667
+ private async execute(
668
+ agentCtx: AgentContext,
669
+ sessionId: string,
670
+ rawArgs: Record<string, unknown>,
671
+ exec: ToolExecution,
672
+ ): Promise<AttachmentFetchValue> {
673
+ const parsed = attachmentFetchArgsSchema.safeParse(rawArgs)
674
+ if (!parsed.success) {
675
+ // A thrown, readable error is the correct shape for invalid arguments:
676
+ // the host turns it into a failed tool call with a text message (never a
677
+ // raw object rendered as `[object Object]`).
678
+ const issue = parsed.error.issues[0]
679
+ const path = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
680
+ throw new Error(`${ATTACHMENT_FETCH_TOOL_NAME}: invalid ${path}: ${issue?.message ?? 'invalid arguments'}`)
681
+ }
682
+ const args = parsed.data
683
+ const messageId = args.message_id
684
+ if (!this.enabled()) return callFailure(messageId, failure('TOOL_DISABLED'))
685
+
686
+ const route = this.deps.routeOfSession(sessionId)
687
+ if (route === undefined) {
688
+ return callFailure(messageId, failure('ROUTE_NOT_FOUND'))
689
+ }
690
+ const config = this.deps.config().feishuTools
691
+ const scope = createAbortScope(exec.signal, config.attachmentFetchTimeoutMs)
692
+ const budget: DownloadBudget = { maxBytes: config.attachmentFetchMaxBytes, usedBytes: 0, exceeded: false }
693
+ const call: FetchCallContext = {
694
+ route,
695
+ args,
696
+ signal: scope.signal,
697
+ timeout: scope.timeout,
698
+ budget,
699
+ limits: {
700
+ maxResources: config.attachmentFetchMaxResources,
701
+ },
702
+ native: nativeAttachmentStoreOf(agentCtx),
703
+ imageCap: imageByteCapOf(agentCtx),
704
+ channel: this.deps.channel(),
705
+ pending: this.deps.pending?.(),
706
+ }
707
+ try {
708
+ return await this.fetch(call)
709
+ } catch (error) {
710
+ if (budget.exceeded) return callFailure(messageId, failure('BYTES_LIMIT'))
711
+ if (scope.signal.aborted) {
712
+ return callFailure(messageId, failure(scope.timeout.value ? 'TIMEOUT' : 'ABORTED'))
713
+ }
714
+ const reason = `${attachmentFetchFailureReason('INTERNAL_ERROR')}:${describeUnexpectedError(error)}`
715
+ this.deps.warn('dsh-channels: %s failed unexpectedly: %s', ATTACHMENT_FETCH_TOOL_NAME, reason)
716
+ return callFailure(messageId, { ok: false, code: 'INTERNAL_ERROR', reason })
717
+ } finally {
718
+ scope.dispose()
719
+ }
720
+ }
721
+
722
+ private async fetch(call: FetchCallContext): Promise<AttachmentFetchValue> {
723
+ const messageId = call.args.message_id
724
+ if (call.signal.aborted) {
725
+ return callFailure(messageId, failure(call.timeout.value ? 'TIMEOUT' : 'ABORTED'))
726
+ }
727
+
728
+ // 1. Authorize the message against this route's known resources.
729
+ const known = this.resolveKnownMessage(call, messageId)
730
+ if (known === undefined) {
731
+ return callFailure(messageId, failure('AUTHORIZATION_DENIED'))
732
+ }
733
+
734
+ // 2. Resolve every resource (explicit list, or message/container expansion).
735
+ let resources = [...known.resources]
736
+ if (resources.length === 0) {
737
+ const expanded = await this.expandMessageResources(call, known)
738
+ if (!expanded.ok) return callFailure(messageId, expanded)
739
+ resources = expanded.resources
740
+ }
741
+ const allResources = resources
742
+ if (call.args.file_key !== undefined) {
743
+ const key = call.args.file_key
744
+ resources = allResources.filter(resource => resource.fileKey === key)
745
+ if (resources.length === 0) {
746
+ // Bound the listing: a merged-forward container may hold hundreds of
747
+ // child keys and the error text is model-visible.
748
+ const knownKeys = allResources.map(resource => resource.fileKey)
749
+ const shown = knownKeys.slice(0, 10)
750
+ const hidden = knownKeys.length - shown.length
751
+ const available = knownKeys.length === 0
752
+ ? ''
753
+ : `(可用:${shown.join('、')}${hidden > 0 ? ` 等 ${knownKeys.length} 个` : ''})`
754
+ return callFailure(messageId, failure(
755
+ 'UNKNOWN_FILE_KEY',
756
+ `${attachmentFetchFailureReason('UNKNOWN_FILE_KEY')}:${displayText(key)}${available}`,
757
+ ))
758
+ }
759
+ }
760
+ if (resources.length === 0) {
761
+ return callFailure(messageId, failure('NO_RESOURCES'))
762
+ }
763
+ if (resources.length > call.limits.maxResources) {
764
+ return callFailure(messageId, failure(
765
+ 'RESOURCE_LIMIT',
766
+ `${attachmentFetchFailureReason('RESOURCE_LIMIT')}(本消息 ${resources.length} 个,单次上限 ${call.limits.maxResources} 个;请用 file_key 分批获取)`,
767
+ ))
768
+ }
769
+
770
+ // 3. The message is authorized and has a usable resource set: remember it
771
+ // (with the resolved resources) for later turns of this route. This is
772
+ // an authorization fact, so it is recorded even if the downloads below
773
+ // fail and the model retries. Bookkeeping never fails the fetch.
774
+ this.rememberKnownMessage(call, known, allResources)
775
+
776
+ // 4. Fetch each resource, keeping per-resource failures as values.
777
+ const results: AttachmentFetchResultItem[] = []
778
+ for (let index = 0; index < resources.length; index += 1) {
779
+ const resource = resources[index]
780
+ if (resource === undefined) continue
781
+ if (call.signal.aborted) {
782
+ results.push(cancelledItem(resource, call.timeout.value))
783
+ for (const rest of resources.slice(index + 1)) results.push(cancelledItem(rest, call.timeout.value))
784
+ break
785
+ }
786
+ if (call.budget.exceeded) {
787
+ results.push(failedItem(resource, failure('BYTES_LIMIT')))
788
+ for (const rest of resources.slice(index + 1)) results.push(failedItem(rest, failure('BYTES_LIMIT')))
789
+ break
790
+ }
791
+ if (call.channel === undefined) {
792
+ results.push(failedItem(resource, failure('CHANNEL_UNAVAILABLE')))
793
+ continue
794
+ }
795
+ results.push(await this.fetchResource(call, known, resource))
796
+ }
797
+
798
+ const fetched = results.filter(item => item.ok).length
799
+ // A pending manifest row is consumed only once an attachment was actually
800
+ // fetched: a run where every download failed keeps the row discoverable
801
+ // (retries are still authorized by the whitelist entry recorded above).
802
+ // The durable write is best-effort and raced against the call signal, so a
803
+ // stalled storage backend can never keep the tool call pending.
804
+ if (fetched > 0 && call.pending !== undefined) {
805
+ try {
806
+ await cancellable(
807
+ Promise.resolve(call.pending.consume(pendingScopeOfRoute(call.route), known.messageId)),
808
+ call.signal,
809
+ )
810
+ } catch {
811
+ // Bookkeeping only: the attachments were already delivered.
812
+ }
813
+ }
814
+ const shownMessageId = displayText(messageId)
815
+ const summary = fetched > 0
816
+ ? `已获取 ${fetched}/${results.length} 个附件(消息 ${shownMessageId})`
817
+ : `${attachmentFetchFailureReason(
818
+ results.length === 0 ? 'NO_RESOURCES'
819
+ : (results[0]?.code ?? 'DOWNLOAD_FAILED'),
820
+ )}(消息 ${shownMessageId})`
821
+ return {
822
+ message_id: messageId,
823
+ ok: fetched > 0 && fetched === results.length,
824
+ summary,
825
+ results,
826
+ }
827
+ }
828
+
829
+ /** Whitelist first, then the durable silent-registration registry. */
830
+ private resolveKnownMessage(
831
+ call: FetchCallContext,
832
+ messageId: string,
833
+ ): KnownAttachmentMessage | undefined {
834
+ const known = this.whitelist.lookup(call.route, messageId)
835
+ if (known !== undefined) return known
836
+ const pending = call.pending
837
+ if (pending === undefined) return undefined
838
+ const record = pending.get(pendingScopeOfRoute(call.route), messageId)
839
+ if (record === undefined) return undefined
840
+ return knownMessageOfPending(record)
841
+ }
842
+
843
+ /** Record the resolved message as known for later turns of this route. */
844
+ private rememberKnownMessage(
845
+ call: FetchCallContext,
846
+ message: KnownAttachmentMessage,
847
+ resources: readonly KnownAttachmentResource[],
848
+ ): void {
849
+ this.whitelist.register(
850
+ call.route,
851
+ { ...message, resources },
852
+ 'tool-result',
853
+ this.now(),
854
+ )
855
+ }
856
+
857
+ // ---- Resource expansion ----------------------------------------------
858
+
859
+ /**
860
+ * Read one known message through the channel's raw message API to discover
861
+ * its resources. This is the merged-forward path (P0: children are listed on
862
+ * the top-level container message, while their bytes download through the
863
+ * container id) and the fallback for trigger/quote registrations that carry
864
+ * only a message id.
865
+ */
866
+ private async expandMessageResources(
867
+ call: FetchCallContext,
868
+ message: KnownAttachmentMessage,
869
+ ): Promise<{ ok: true; resources: KnownAttachmentResource[] } | AttachmentFetchFailure> {
870
+ const channel = call.channel
871
+ if (channel === undefined) return failure('CHANNEL_UNAVAILABLE')
872
+ const messageGet = channel.messageGet
873
+ if (messageGet === undefined) {
874
+ return failure(
875
+ 'MESSAGE_READ_FAILED',
876
+ `${attachmentFetchFailureReason('MESSAGE_READ_FAILED')}:当前飞书通道不支持读取消息内容,无法展开附件`,
877
+ )
878
+ }
879
+ let response: unknown
880
+ try {
881
+ response = await cancellable(Promise.resolve(messageGet.call(channel, message.messageId)), call.signal)
882
+ } catch (error) {
883
+ if (isAbortError(error)) throw error
884
+ return failure(
885
+ 'MESSAGE_READ_FAILED',
886
+ `${attachmentFetchFailureReason('MESSAGE_READ_FAILED')}:${describeFetchError(error)}`,
887
+ )
888
+ }
889
+ const items = rawMessageItems(response)
890
+ if (items === undefined) {
891
+ return failure(
892
+ 'MESSAGE_READ_FAILED',
893
+ `${attachmentFetchFailureReason('MESSAGE_READ_FAILED')}:返回内容无法识别`,
894
+ )
895
+ }
896
+ const resources = message.kind === 'merge_forward'
897
+ ? mergedForwardResources(items)
898
+ : messageResourcesOfItems(items)
899
+ return { ok: true, resources }
900
+ }
901
+
902
+ // ---- Per-resource fetch ----------------------------------------------
903
+
904
+ private async fetchResource(
905
+ call: FetchCallContext,
906
+ message: KnownAttachmentMessage,
907
+ resource: KnownAttachmentResource,
908
+ ): Promise<AttachmentFetchResultItem> {
909
+ // `containerMessageId` is part of a trusted registration (pending record or
910
+ // admission-provided whitelist entry), never model input: the P0 rule makes
911
+ // merged-forward child bytes downloadable only through the top-level
912
+ // container id, so child ids are never used as download ids here.
913
+ const downloadId = resource.containerMessageId ?? message.messageId
914
+ if (resource.folder === true) {
915
+ return failedItem(resource, failure('FOLDER_UNSUPPORTED'))
916
+ }
917
+ if (resource.type === 'image') {
918
+ return await this.fetchImage(call, downloadId, resource)
919
+ }
920
+ return await this.fetchFile(call, downloadId, resource)
921
+ }
922
+
923
+ private async fetchImage(
924
+ call: FetchCallContext,
925
+ downloadId: string,
926
+ resource: KnownAttachmentResource,
927
+ ): Promise<AttachmentFetchResultItem> {
928
+ const channel = call.channel
929
+ if (channel === undefined) return failedItem(resource, failure('CHANNEL_UNAVAILABLE'))
930
+ const remaining = call.budget.maxBytes - call.budget.usedBytes
931
+ if (remaining <= 0) {
932
+ call.budget.exceeded = true
933
+ return failedItem(resource, failure('BYTES_LIMIT'))
934
+ }
935
+ const imageCap = call.imageCap
936
+ const limit = Math.min(imageCap, remaining)
937
+ let stream: ResourceChunkStream
938
+ try {
939
+ stream = await cancellable(Promise.resolve(channel.downloadResource(downloadId, resource.fileKey, 'image')), call.signal)
940
+ } catch (error) {
941
+ return downloadFailureItem(resource, error, call)
942
+ }
943
+ let bytes: Uint8Array
944
+ try {
945
+ bytes = await cancellable(
946
+ collectBoundedBytes(stream, call.signal, call.budget, limit, imageCap),
947
+ call.signal,
948
+ )
949
+ } catch (error) {
950
+ if (error instanceof BudgetExceededError) {
951
+ call.budget.exceeded = true
952
+ return failedItem(resource, failure('BYTES_LIMIT'))
953
+ }
954
+ if (error instanceof ImageTooLargeError) {
955
+ return failedItem(resource, failure('IMAGE_TOO_LARGE'))
956
+ }
957
+ return downloadFailureItem(resource, error, call)
958
+ }
959
+ let saved: AttachmentResult<StoredImage>
960
+ try {
961
+ saved = await cancellable(call.native.saveImage({
962
+ bytes,
963
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
964
+ }), call.signal)
965
+ } catch (error) {
966
+ return downloadFailureItem(resource, error, call)
967
+ }
968
+ if (!saved.ok) return failedItem(resource, saved)
969
+ const ref = saved.value.ref
970
+ const read = call.native.imageReadPath(ref)
971
+ return {
972
+ ok: true,
973
+ kind: 'image',
974
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
975
+ file_key: resource.fileKey,
976
+ bytes: ref.bytes,
977
+ media_type: ref.mediaType,
978
+ width: ref.width,
979
+ height: ref.height,
980
+ ...(read.ok ? { read_path: read.value } : {}),
981
+ attachment: ref,
982
+ }
983
+ }
984
+
985
+ private async fetchFile(
986
+ call: FetchCallContext,
987
+ downloadId: string,
988
+ resource: KnownAttachmentResource,
989
+ ): Promise<AttachmentFetchResultItem> {
990
+ const channel = call.channel
991
+ if (channel === undefined) return failedItem(resource, failure('CHANNEL_UNAVAILABLE'))
992
+ if (call.budget.maxBytes <= call.budget.usedBytes) {
993
+ call.budget.exceeded = true
994
+ return failedItem(resource, failure('BYTES_LIMIT'))
995
+ }
996
+ let stream: ResourceChunkStream
997
+ try {
998
+ stream = await cancellable(Promise.resolve(channel.downloadResource(downloadId, resource.fileKey, 'file')), call.signal)
999
+ } catch (error) {
1000
+ return downloadFailureItem(resource, error, call)
1001
+ }
1002
+ const gated = budgetedChunks(stream, call.signal, call.budget)
1003
+ let saved: AttachmentResult<StoredFile>
1004
+ try {
1005
+ saved = await cancellable(call.native.saveFile({
1006
+ source: gated,
1007
+ signal: call.signal,
1008
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
1009
+ }), call.signal)
1010
+ } catch (error) {
1011
+ if (call.budget.exceeded) return failedItem(resource, failure('BYTES_LIMIT'))
1012
+ return downloadFailureItem(resource, error, call)
1013
+ }
1014
+ if (!saved.ok) {
1015
+ if (call.budget.exceeded) return failedItem(resource, failure('BYTES_LIMIT'))
1016
+ if (call.signal.aborted) return cancelledItem(resource, call.timeout.value)
1017
+ return failedItem(resource, saved)
1018
+ }
1019
+ return {
1020
+ ok: true,
1021
+ kind: 'file',
1022
+ name: saved.value.ref.name,
1023
+ file_key: resource.fileKey,
1024
+ bytes: saved.value.ref.bytes,
1025
+ handle_text: saved.value.handleText,
1026
+ attachment: saved.value.ref,
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ /** Map one thrown download/store error into a structured item. */
1032
+ function downloadFailureItem(
1033
+ resource: KnownAttachmentResource,
1034
+ error: unknown,
1035
+ call: FetchCallContext,
1036
+ ): AttachmentFetchResultItem {
1037
+ if (call.budget.exceeded) return failedItem(resource, failure('BYTES_LIMIT'))
1038
+ if (call.signal.aborted) return cancelledItem(resource, call.timeout.value)
1039
+ return failedItem(resource, failure(
1040
+ 'DOWNLOAD_FAILED',
1041
+ `${attachmentFetchFailureReason('DOWNLOAD_FAILED')}:${describeFetchError(error)}`,
1042
+ ))
1043
+ }
1044
+
1045
+ // ---- Download plumbing --------------------------------------------------
1046
+
1047
+ /**
1048
+ * Run one promise with cooperative cancellation: an aborted signal rejects the
1049
+ * wrapper immediately (the underlying operation is signalled too where the
1050
+ * host contract supports it). Late rejections are swallowed so a timed-out
1051
+ * tool call never produces an unhandled rejection.
1052
+ */
1053
+ function cancellable<T>(task: Promise<T>, signal: AbortSignal): Promise<T> {
1054
+ if (signal.aborted) {
1055
+ task.catch(() => {})
1056
+ return Promise.reject(abortError())
1057
+ }
1058
+ return new Promise<T>((resolve, reject) => {
1059
+ let settled = false
1060
+ const onAbort = (): void => {
1061
+ if (settled) return
1062
+ settled = true
1063
+ signal.removeEventListener('abort', onAbort)
1064
+ task.catch(() => {})
1065
+ reject(abortError())
1066
+ }
1067
+ signal.addEventListener('abort', onAbort, { once: true })
1068
+ task.then(
1069
+ (value) => {
1070
+ if (settled) return
1071
+ settled = true
1072
+ signal.removeEventListener('abort', onAbort)
1073
+ resolve(value)
1074
+ },
1075
+ (error: unknown) => {
1076
+ if (settled) return
1077
+ settled = true
1078
+ signal.removeEventListener('abort', onAbort)
1079
+ reject(error)
1080
+ },
1081
+ )
1082
+ })
1083
+ }
1084
+
1085
+ function abortError(): Error {
1086
+ const error = new Error('The operation was aborted')
1087
+ error.name = 'AbortError'
1088
+ return error
1089
+ }
1090
+
1091
+ function isAbortError(error: unknown): boolean {
1092
+ return typeof error === 'object' && error !== null
1093
+ && (error as { name?: unknown }).name === 'AbortError'
1094
+ }
1095
+
1096
+ /** Total budget for one tool call; also forwards caller/timeout cancellation. */
1097
+ function createAbortScope(caller: AbortSignal, timeoutMs: number): {
1098
+ signal: AbortSignal
1099
+ timeout: { value: boolean }
1100
+ dispose(): void
1101
+ } {
1102
+ const controller = new AbortController()
1103
+ const timeout = { value: false }
1104
+ const forward = (): void => { controller.abort() }
1105
+ if (caller.aborted) controller.abort()
1106
+ else caller.addEventListener('abort', forward, { once: true })
1107
+ const timer = setTimeout(() => {
1108
+ timeout.value = true
1109
+ controller.abort()
1110
+ }, timeoutMs)
1111
+ timer.unref?.()
1112
+ return {
1113
+ signal: controller.signal,
1114
+ timeout,
1115
+ dispose(): void {
1116
+ clearTimeout(timer)
1117
+ caller.removeEventListener('abort', forward)
1118
+ controller.abort()
1119
+ },
1120
+ }
1121
+ }
1122
+
1123
+ class BudgetExceededError extends Error {
1124
+ override readonly name = 'AttachmentFetchBudgetExceeded'
1125
+ }
1126
+
1127
+ class ImageTooLargeError extends Error {
1128
+ override readonly name = 'AttachmentFetchImageTooLarge'
1129
+ }
1130
+
1131
+ /** Byte cap for one image: the host's own limit is authoritative. */
1132
+ function imageByteCap(service: AttachmentStore | undefined): number {
1133
+ const max = service?.imageLimits?.maxImageBytes
1134
+ if (typeof max === 'number' && Number.isFinite(max) && max > 0) return max
1135
+ return FALLBACK_MAX_IMAGE_BYTES
1136
+ }
1137
+
1138
+ /** Read one image into memory under both the image cap and the call budget. */
1139
+ async function collectBoundedBytes(
1140
+ source: AsyncIterable<Uint8Array>,
1141
+ signal: AbortSignal,
1142
+ budget: DownloadBudget,
1143
+ limit: number,
1144
+ imageCap: number,
1145
+ ): Promise<Uint8Array> {
1146
+ const chunks: Uint8Array[] = []
1147
+ let ownBytes = 0
1148
+ for await (const chunk of source) {
1149
+ if (signal.aborted) throw abortError()
1150
+ ownBytes += chunk.byteLength
1151
+ budget.usedBytes += chunk.byteLength
1152
+ if (ownBytes > imageCap) throw new ImageTooLargeError()
1153
+ if (budget.usedBytes > budget.maxBytes || ownBytes > limit) throw new BudgetExceededError()
1154
+ chunks.push(chunk)
1155
+ }
1156
+ const bytes = new Uint8Array(ownBytes)
1157
+ let offset = 0
1158
+ for (const chunk of chunks) {
1159
+ bytes.set(chunk, offset)
1160
+ offset += chunk.byteLength
1161
+ }
1162
+ return bytes
1163
+ }
1164
+
1165
+ /** Files stream through the native library; the generator enforces the budget. */
1166
+ async function* budgetedChunks(
1167
+ source: AsyncIterable<Uint8Array>,
1168
+ signal: AbortSignal,
1169
+ budget: DownloadBudget,
1170
+ ): AsyncGenerator<Uint8Array> {
1171
+ const iterator = source[Symbol.asyncIterator]()
1172
+ try {
1173
+ while (true) {
1174
+ const next = await cancellable(iterator.next(), signal)
1175
+ if (next.done === true) return
1176
+ budget.usedBytes += next.value.byteLength
1177
+ if (budget.usedBytes > budget.maxBytes) {
1178
+ budget.exceeded = true
1179
+ throw new BudgetExceededError()
1180
+ }
1181
+ yield next.value
1182
+ }
1183
+ } finally {
1184
+ // Best-effort cleanup: a cancelled stream must not keep the underlying
1185
+ // Node stream open. Never awaited so a stalled source cannot hang the tool
1186
+ // call after the abort was already reported.
1187
+ try {
1188
+ void Promise.resolve(iterator.return?.()).catch(() => {})
1189
+ } catch {
1190
+ // A synchronous throw from a broken iterator is already moot here.
1191
+ }
1192
+ }
1193
+ }
1194
+
1195
+ // ---- Item helpers -------------------------------------------------------
1196
+
1197
+ function failedItem(
1198
+ resource: KnownAttachmentResource,
1199
+ result: AttachmentFetchFailure,
1200
+ ): AttachmentFetchResultItem {
1201
+ return {
1202
+ ok: false,
1203
+ kind: resource.type,
1204
+ ...(resource.fileName === undefined ? {} : { name: resource.fileName }),
1205
+ file_key: resource.fileKey,
1206
+ code: result.code,
1207
+ reason: result.reason,
1208
+ }
1209
+ }
1210
+
1211
+ function cancelledItem(
1212
+ resource: KnownAttachmentResource,
1213
+ timedOut: boolean,
1214
+ ): AttachmentFetchResultItem {
1215
+ return failedItem(resource, failure(timedOut ? 'TIMEOUT' : 'ABORTED'))
1216
+ }
1217
+
1218
+ function callFailure(messageId: string, result: AttachmentFetchFailure): AttachmentFetchValue {
1219
+ return {
1220
+ message_id: messageId,
1221
+ ok: false,
1222
+ summary: `${result.reason}(消息 ${displayText(messageId)})`,
1223
+ results: [],
1224
+ }
1225
+ }
1226
+
1227
+ /** Cap for one untrusted error/file-name description placed into a result. */
1228
+ const MAX_DESCRIPTION_TEXT = 500
1229
+
1230
+ /**
1231
+ * Inline-safe, bounded text: control characters (newlines could forge extra
1232
+ * result lines) collapse to spaces and the length is bounded.
1233
+ */
1234
+ function sanitizeInlineText(value: string, max = MAX_DESCRIPTION_TEXT): string {
1235
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim()
1236
+ return cleaned.length <= max ? cleaned : `${cleaned.slice(0, max)}…(已截断)`
1237
+ }
1238
+
1239
+ function truncateDescription(text: string): string {
1240
+ return sanitizeInlineText(text)
1241
+ }
1242
+
1243
+ /** Readable, bounded description for an unexpected (non-abort) throw. */
1244
+ function describeUnexpectedError(error: unknown): string {
1245
+ return truncateDescription(describeThrownValue(error))
1246
+ }
1247
+
1248
+ /**
1249
+ * Readable text for an arbitrary thrown value (never `[object Object]`).
1250
+ *
1251
+ * Plain-object rejects are common in the Feishu SDK (`{ code, msg }`). Only a
1252
+ * whitelist of descriptive fields is surfaced: serializing the whole object
1253
+ * could leak request headers or large bodies into the model-visible result.
1254
+ */
1255
+ function describeThrownValue(error: unknown): string {
1256
+ if (error instanceof Error && error.message.length > 0) return error.message
1257
+ if (typeof error === 'string' && error.length > 0) return error
1258
+ if (error === null || error === undefined) return '未知错误'
1259
+ if (typeof error === 'object') {
1260
+ const record = error as Record<string, unknown>
1261
+ const parts: string[] = []
1262
+ for (const key of ['code', 'status', 'statusCode', 'name', 'msg', 'message']) {
1263
+ const value = record[key]
1264
+ if (typeof value === 'string' && value.length > 0) parts.push(`${key}=${value}`)
1265
+ else if (typeof value === 'number' && Number.isFinite(value)) parts.push(`${key}=${value}`)
1266
+ }
1267
+ if (parts.length > 0) return parts.join(' ')
1268
+ return '未知错误(不可序列化的对象)'
1269
+ }
1270
+ return String(error)
1271
+ }
1272
+
1273
+ /** Readable, bounded download/store error text (Feishu 400/403 mapped). */
1274
+ function describeFetchError(error: unknown): string {
1275
+ const raw = describeThrownValue(error)
1276
+ return truncateDescription(describeFailure(new Error(raw)))
1277
+ }
1278
+
1279
+ /**
1280
+ * One untrusted display name for the model-facing text: control characters
1281
+ * (newlines could forge extra result lines) collapse to spaces and the length
1282
+ * is bounded; the structured value keeps the original name.
1283
+ */
1284
+ function displayText(value: string): string {
1285
+ return sanitizeInlineText(value, 200)
1286
+ }
1287
+
1288
+ // ---- Raw message parsing (merged forward / message.get) -----------------
1289
+
1290
+ interface RawMessageItem {
1291
+ readonly messageId?: string
1292
+ readonly msgType?: string
1293
+ readonly content?: unknown
1294
+ }
1295
+
1296
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
1297
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
1298
+ ? value as Record<string, unknown>
1299
+ : undefined
1300
+ }
1301
+
1302
+ function stringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
1303
+ const value = record?.[key]
1304
+ return typeof value === 'string' && value.length > 0 ? value : undefined
1305
+ }
1306
+
1307
+ /**
1308
+ * `im.v1.message.get` response → message items. Tolerates the SDK envelope
1309
+ * (`{ data: { items } }`) and an unwrapped `{ items }` for stubs.
1310
+ */
1311
+ function rawMessageItems(response: unknown): RawMessageItem[] | undefined {
1312
+ const root = asRecord(response)
1313
+ const data = asRecord(root?.data) ?? root
1314
+ const items = data?.items
1315
+ if (!Array.isArray(items)) return undefined
1316
+ const parsed: RawMessageItem[] = []
1317
+ for (const item of items) {
1318
+ const record = asRecord(item)
1319
+ if (record === undefined) continue
1320
+ const body = asRecord(record.body)
1321
+ parsed.push({
1322
+ ...(stringField(record, 'message_id') === undefined ? {} : { messageId: stringField(record, 'message_id') }),
1323
+ ...(stringField(record, 'msg_type') === undefined ? {} : { msgType: stringField(record, 'msg_type') }),
1324
+ ...(body?.content === undefined ? {} : { content: body.content }),
1325
+ })
1326
+ }
1327
+ return parsed
1328
+ }
1329
+
1330
+ /** Parse Feishu's JSON-string message body content. */
1331
+ function parsedContent(content: unknown): Record<string, unknown> | undefined {
1332
+ if (typeof content === 'string') {
1333
+ try {
1334
+ return asRecord(JSON.parse(content))
1335
+ } catch {
1336
+ return undefined
1337
+ }
1338
+ }
1339
+ return asRecord(content)
1340
+ }
1341
+
1342
+ /** Resources of one regular (non merged-forward) message. */
1343
+ function messageResourcesOfItems(items: readonly RawMessageItem[]): KnownAttachmentResource[] {
1344
+ const resources: KnownAttachmentResource[] = []
1345
+ const seen = new Set<string>()
1346
+ for (const item of items) {
1347
+ for (const resource of resourcesOfItem(item)) {
1348
+ if (seen.has(resource.fileKey)) continue
1349
+ seen.add(resource.fileKey)
1350
+ resources.push(resource)
1351
+ }
1352
+ }
1353
+ return resources
1354
+ }
1355
+
1356
+ /**
1357
+ * Resources of a merged-forward container: every returned item is a child (the
1358
+ * top-level container itself carries no resource), and their bytes are fetched
1359
+ * through the container's own message id (P0 rule).
1360
+ */
1361
+ function mergedForwardResources(items: readonly RawMessageItem[]): KnownAttachmentResource[] {
1362
+ const resources: KnownAttachmentResource[] = []
1363
+ const seen = new Set<string>()
1364
+ for (const item of items) {
1365
+ for (const resource of resourcesOfItem(item)) {
1366
+ if (seen.has(resource.fileKey)) continue
1367
+ seen.add(resource.fileKey)
1368
+ resources.push(resource)
1369
+ }
1370
+ }
1371
+ return resources
1372
+ }
1373
+
1374
+ /** Extract downloadable resources from one raw message item. */
1375
+ function resourcesOfItem(item: RawMessageItem): KnownAttachmentResource[] {
1376
+ const content = parsedContent(item.content)
1377
+ if (content === undefined) return []
1378
+ switch (item.msgType) {
1379
+ case 'image': {
1380
+ const fileKey = stringField(content, 'image_key')
1381
+ return fileKey === undefined ? [] : [{ type: 'image', fileKey }]
1382
+ }
1383
+ case 'file': {
1384
+ const fileKey = stringField(content, 'file_key')
1385
+ if (fileKey === undefined) return []
1386
+ const fileName = stringField(content, 'file_name')
1387
+ return [{ type: 'file', fileKey, ...(fileName === undefined ? {} : { fileName }) }]
1388
+ }
1389
+ case 'post': {
1390
+ return postResources(content)
1391
+ }
1392
+ case 'folder': {
1393
+ const fileKey = stringField(content, 'file_key')
1394
+ if (fileKey === undefined) return []
1395
+ const fileName = stringField(content, 'file_name')
1396
+ return [{
1397
+ type: 'file',
1398
+ fileKey,
1399
+ folder: true,
1400
+ ...(fileName === undefined ? {} : { fileName }),
1401
+ }]
1402
+ }
1403
+ default:
1404
+ return []
1405
+ }
1406
+ }
1407
+
1408
+ /** Locale keys Feishu uses to wrap a localized post body. */
1409
+ const POST_LOCALES: ReadonlySet<string> = new Set([
1410
+ 'zh_cn', 'zh_hk', 'zh_tw', 'en_us', 'ja_jp', 'ko_kr', 'de_de', 'fr_fr',
1411
+ 'es_es', 'pt_br', 'ru_ru', 'th_th', 'vi_vn', 'id_id', 'it_it', 'ms_my',
1412
+ ])
1413
+
1414
+ /**
1415
+ * Resources embedded in a rich-text (`post`) body.
1416
+ *
1417
+ * Feishu serializes a post as `{ title, content, files }`, optionally wrapped
1418
+ * in one or more known locales (`{ zh_cn: { content, files }, ... }`). The
1419
+ * `content` rows carry inline `img`/`file`/`media` elements while attached
1420
+ * files live in the body's top-level `files[]` array that the SDK's post
1421
+ * converter drops — the same gap `postAttachedFiles` recovers on the inbound
1422
+ * path.
1423
+ *
1424
+ * Only the standard body shape is inspected: arbitrary nested `files`/`tag`
1425
+ * fields elsewhere in the body are NOT adopted, so the tool never treats
1426
+ * unrelated metadata as an attachment of an authorized message.
1427
+ */
1428
+ function postResources(content: Record<string, unknown>): KnownAttachmentResource[] {
1429
+ const resources: KnownAttachmentResource[] = []
1430
+ const byKey = new Map<string, KnownAttachmentResource>()
1431
+ const push = (resource: KnownAttachmentResource): void => {
1432
+ const existing = byKey.get(resource.fileKey)
1433
+ if (existing === undefined) {
1434
+ byKey.set(resource.fileKey, resource)
1435
+ resources.push(resource)
1436
+ return
1437
+ }
1438
+ // A folder marker from `files[]` must survive an inline duplicate.
1439
+ if (existing.folder !== true && resource.folder === true) {
1440
+ const index = resources.indexOf(existing)
1441
+ if (index !== -1) resources[index] = { ...existing, folder: true }
1442
+ }
1443
+ }
1444
+ for (const body of postBodies(content)) {
1445
+ const rows = body.content
1446
+ if (Array.isArray(rows)) {
1447
+ for (const row of rows) {
1448
+ if (!Array.isArray(row)) continue
1449
+ for (const rawElement of row) {
1450
+ const element = asRecord(rawElement)
1451
+ if (element === undefined) continue
1452
+ const tag = stringField(element, 'tag')
1453
+ if (tag === 'img') {
1454
+ const fileKey = stringField(element, 'image_key')
1455
+ if (fileKey !== undefined) push({ type: 'image', fileKey })
1456
+ continue
1457
+ }
1458
+ if (tag === 'file' || tag === 'media') {
1459
+ const fileKey = stringField(element, 'file_key')
1460
+ if (fileKey === undefined) continue
1461
+ const fileName = stringField(element, 'file_name')
1462
+ push({
1463
+ type: 'file',
1464
+ fileKey,
1465
+ ...(fileName === undefined ? {} : { fileName }),
1466
+ ...(element.is_folder === true ? { folder: true } : {}),
1467
+ })
1468
+ }
1469
+ }
1470
+ }
1471
+ }
1472
+ const files = body.files
1473
+ if (Array.isArray(files)) {
1474
+ for (const rawFile of files) {
1475
+ const file = asRecord(rawFile)
1476
+ if (file === undefined) continue
1477
+ const fileKey = stringField(file, 'file_key')
1478
+ if (fileKey === undefined) continue
1479
+ const fileName = stringField(file, 'file_name')
1480
+ push({
1481
+ type: 'file',
1482
+ fileKey,
1483
+ ...(fileName === undefined ? {} : { fileName }),
1484
+ ...(file.is_folder === true ? { folder: true } : {}),
1485
+ })
1486
+ }
1487
+ }
1488
+ }
1489
+ return resources
1490
+ }
1491
+
1492
+ /** The standard post body record(s): the direct shape plus known locale wrappers. */
1493
+ function postBodies(content: Record<string, unknown>): Array<Record<string, unknown>> {
1494
+ const bodies: Array<Record<string, unknown>> = []
1495
+ if (isPostBody(content)) bodies.push(content)
1496
+ for (const [key, value] of Object.entries(content)) {
1497
+ if (!POST_LOCALES.has(key)) continue
1498
+ const body = asRecord(value)
1499
+ if (body !== undefined && isPostBody(body)) bodies.push(body)
1500
+ }
1501
+ return bodies
1502
+ }
1503
+
1504
+ function isPostBody(record: Record<string, unknown>): boolean {
1505
+ return Array.isArray(record.content) || Array.isArray(record.files)
1506
+ }
1507
+
1508
+ /** Pending registry record → known message (authorization snapshot). */
1509
+ function knownMessageOfPending(record: PendingAttachmentRecord): KnownAttachmentMessage {
1510
+ return {
1511
+ messageId: record.messageId,
1512
+ kind: record.kind === 'merge_forward' ? 'merge_forward' : 'message',
1513
+ senderOpenId: record.senderOpenId,
1514
+ resources: record.resources.map((resource: PendingAttachmentResource): KnownAttachmentResource => ({
1515
+ type: resource.type,
1516
+ fileKey: resource.fileKey,
1517
+ ...(resource.fileName === undefined ? {} : { fileName: resource.fileName }),
1518
+ ...(resource.folder === true ? { folder: true } : {}),
1519
+ ...(resource.containerMessageId === undefined ? {} : { containerMessageId: resource.containerMessageId }),
1520
+ })),
1521
+ }
1522
+ }
1523
+
1524
+ /**
1525
+ * Wait for one cordis injection to settle, but never longer than `timeoutMs`:
1526
+ * a hard dependency on a service a deployment does not mount would otherwise
1527
+ * block agent creation forever.
1528
+ */
1529
+ function waitForAvailability(
1530
+ task: PromiseLike<unknown>,
1531
+ timeoutMs: number,
1532
+ ): Promise<'ready' | 'failed' | 'timeout'> {
1533
+ return new Promise<'ready' | 'failed' | 'timeout'>((resolve) => {
1534
+ let settled = false
1535
+ const finish = (outcome: 'ready' | 'failed' | 'timeout'): void => {
1536
+ if (settled) return
1537
+ settled = true
1538
+ clearTimeout(timer)
1539
+ resolve(outcome)
1540
+ }
1541
+ const timer = setTimeout(() => finish('timeout'), timeoutMs)
1542
+ timer.unref?.()
1543
+ Promise.resolve(task).then(
1544
+ () => finish('ready'),
1545
+ () => finish('failed'),
1546
+ )
1547
+ })
1548
+ }
1549
+
1550
+ // ---- Rendering / presentation -------------------------------------------
1551
+
1552
+ function resourceLabel(item: AttachmentFetchResultItem): string {
1553
+ if (item.name !== undefined && item.name !== '') return displayText(item.name)
1554
+ if (item.file_key !== undefined) return displayText(item.file_key)
1555
+ return item.kind === 'image' ? '图片' : '文件'
1556
+ }
1557
+
1558
+ function imageMetadataLine(item: AttachmentFetchResultItem): string {
1559
+ const parts: string[] = []
1560
+ if (item.bytes !== undefined) parts.push(`${item.bytes} 字节`)
1561
+ if (item.width !== undefined && item.height !== undefined) parts.push(`${item.width}×${item.height}`)
1562
+ if (item.media_type !== undefined) parts.push(item.media_type)
1563
+ const metadata = parts.length === 0 ? '' : `(${parts.join(',')})`
1564
+ const path = item.read_path === undefined ? '' : `;只读路径:${item.read_path}`
1565
+ return `- 图片 ${resourceLabel(item)} 已获取${metadata}${path}`
1566
+ }
1567
+
1568
+ function resultTextLine(item: AttachmentFetchResultItem): string {
1569
+ if (item.ok) {
1570
+ if (item.kind === 'image') return imageMetadataLine(item)
1571
+ const handle = item.handle_text === undefined ? '' : ` ${item.handle_text}`
1572
+ return `- 文件 ${resourceLabel(item)}(${item.bytes ?? 0} 字节)已保存为只读副本。${handle}`.trimEnd()
1573
+ }
1574
+ return `- ${resourceLabel(item)} 获取失败(${item.reason ?? attachmentFetchFailureReason(item.code ?? 'DOWNLOAD_FAILED')})`
1575
+ }
1576
+
1577
+ /**
1578
+ * Turn one tool result into model content: a text block (summary, per-resource
1579
+ * metadata, file handle fallback) plus one image/file block per fetched
1580
+ * resource. The text path is always present so a host that cannot deliver file
1581
+ * blocks (or a text-only model) still gets a usable answer.
1582
+ */
1583
+ export function renderAttachmentFetchResult(value: unknown): readonly ToolContentBlock[] {
1584
+ const result = (typeof value === 'object' && value !== null ? value : {}) as Partial<AttachmentFetchValue>
1585
+ const items = (Array.isArray(result.results) ? result.results : []).filter(
1586
+ (item): item is AttachmentFetchResultItem => typeof item === 'object' && item !== null,
1587
+ )
1588
+ const summary = typeof result.summary === 'string' ? result.summary : ATTACHMENT_FETCH_TOOL_NAME
1589
+ const lines = [summary]
1590
+ for (const item of items) lines.push(resultTextLine(item))
1591
+ if (items.some(item => item.ok && item.kind === 'image')) {
1592
+ lines.push('若当前模型无法查看图片,请依据上面的元数据向用户说明,并请用户描述图片内容。')
1593
+ }
1594
+ const blocks: ToolContentBlock[] = [{ type: 'text', text: lines.join('\n') }]
1595
+ for (const item of items) {
1596
+ if (!item.ok || item.attachment === undefined) continue
1597
+ if (item.kind === 'image') blocks.push({ type: 'image', attachment: item.attachment })
1598
+ else blocks.push({ type: 'file', attachment: item.attachment })
1599
+ }
1600
+ return blocks
1601
+ }
1602
+
1603
+ /** Host-local card call projection: "fetch attachment of message X". */
1604
+ export function presentAttachmentFetchCall(args: Record<string, unknown>): string {
1605
+ const messageId = typeof args.message_id === 'string' ? args.message_id : ''
1606
+ const fileKey = typeof args.file_key === 'string' ? args.file_key : undefined
1607
+ return fileKey === undefined
1608
+ ? `获取飞书附件 ${messageId}`
1609
+ : `获取飞书附件 ${messageId}(${fileKey})`
1610
+ }
1611
+
1612
+ /** Host-local card result projection: the one-line outcome. */
1613
+ export function presentAttachmentFetchResult(value: unknown): string {
1614
+ const result = (typeof value === 'object' && value !== null ? value : {}) as Partial<AttachmentFetchValue>
1615
+ return typeof result.summary === 'string' ? result.summary : ATTACHMENT_FETCH_TOOL_NAME
1616
+ }
1617
+
1618
+ // ---- Exposed helper for the image-cap resolution ------------------------
1619
+
1620
+ /**
1621
+ * Resolve the live image byte cap of one Agent scope (the service may unmount
1622
+ * between registration and execution; a missing service keeps the fallback cap
1623
+ * so the caller can still report a structured "unmounted" failure).
1624
+ */
1625
+ function imageByteCapOf(agentCtx: AgentContext): number {
1626
+ return imageByteCap(resolveAttachmentService(agentCtx))
1627
+ }
1628
+
1629
+ // ---- Topic messages tool ------------------------------------------------
1630
+
1631
+ /** Model-facing tool name; stable across releases (session history references it). */
1632
+ export const TOPIC_MESSAGES_TOOL_NAME = 'feishu_topic_messages'
1633
+
1634
+ /**
1635
+ * Model-facing description of {@link TOPIC_MESSAGES_TOOL_NAME}: the vocabulary
1636
+ * the model needs to choose between one quoted-message read and paging through
1637
+ * the discussion that precedes the @.
1638
+ */
1639
+ const TOPIC_MESSAGES_DESCRIPTION =
1640
+ 'Read messages of the CURRENT Feishu conversation: the topic/thread history, or the chat main timeline on '
1641
+ + 'a degraded (non-topic) route. Pass message_id to read one message (a quoted message, or a merged-forward '
1642
+ + 'message which is expanded into its child messages); omit message_id to list the most recent messages '
1643
+ + 'newest-first and page with limit/cursor. Every message carries its message_id (usable with '
1644
+ + 'feishu_attachment_fetch), text, time, sender and attachment metadata. Only messages of this conversation '
1645
+ + 'are readable: a message id from another chat/topic, or one this session never admitted, is refused. '
1646
+ + 'Reading a message also authorizes its images/files for feishu_attachment_fetch. Output is bounded; use '
1647
+ + 'limit (default 10, maximum 50) and cursor to continue. Merged-forward child message ids are NOT '
1648
+ + 'downloadable; fetch their attachments with the top-level container message_id shown for the container row.'
1649
+
1650
+ /** Extra model guidance appended when this deployment cannot fetch attachments (archive backend). */
1651
+ const TOPIC_MESSAGES_FETCH_UNAVAILABLE_DESCRIPTION =
1652
+ ' In this deployment feishu_attachment_fetch is unavailable; report attachment metadata to the user '
1653
+ + 'instead of fetching.'
1654
+
1655
+ /**
1656
+ * Runtime argument validation of `feishu_topic_messages`.
1657
+ *
1658
+ * The host projects {@link TOPIC_MESSAGES_PARAMETERS} to the model but does not
1659
+ * re-validate arguments (the same finding as the attachment tool), so this zod
1660
+ * schema is the authoritative argument gate. `message_id` is shape-checked
1661
+ * (Feishu ids are `om_` + URL-safe characters; no path/query injection reaches
1662
+ * the read API), and `message_id` (single read) / `cursor` (list paging) are
1663
+ * mutually exclusive; the execute path rejects that combination with a readable
1664
+ * Chinese error rather than a schema message.
1665
+ */
1666
+ export const topicMessagesArgsSchema = z.object({
1667
+ message_id: z.string().min(1).max(160).regex(/^om_[A-Za-z0-9_-]{1,128}$/).optional(),
1668
+ limit: z.number().int().min(1).max(TOPIC_MESSAGES_MAX_LIMIT).optional(),
1669
+ cursor: z.string().min(1).max(2048).optional(),
1670
+ }).strict()
1671
+
1672
+ export type TopicMessagesArgs = z.infer<typeof topicMessagesArgsSchema>
1673
+
1674
+ /** Model-facing parameter schema (hand-written; no DSH runtime import). */
1675
+ export const TOPIC_MESSAGES_PARAMETERS: Record<string, unknown> = {
1676
+ type: 'object',
1677
+ additionalProperties: false,
1678
+ properties: {
1679
+ message_id: {
1680
+ type: 'string',
1681
+ minLength: 1,
1682
+ maxLength: 160,
1683
+ pattern: '^om_[A-Za-z0-9_-]{1,128}$',
1684
+ description:
1685
+ 'Feishu message id (om_...) of one message to read. Omit to list the current topic/chat history instead.',
1686
+ },
1687
+ limit: {
1688
+ type: 'integer',
1689
+ minimum: 1,
1690
+ maximum: TOPIC_MESSAGES_MAX_LIMIT,
1691
+ default: TOPIC_MESSAGES_DEFAULT_LIMIT,
1692
+ description:
1693
+ 'Page size when listing history, default 10 and maximum 50. Ignored in message_id mode.',
1694
+ },
1695
+ cursor: {
1696
+ type: 'string',
1697
+ minLength: 1,
1698
+ maxLength: 2048,
1699
+ description:
1700
+ 'Opaque page token from a previous result (its cursor field) used to continue an older page. '
1701
+ + 'Only for list mode; never combine it with message_id.',
1702
+ },
1703
+ },
1704
+ }
1705
+
1706
+ // ---- Topic messages results ---------------------------------------------
1707
+
1708
+ /** Stable failure codes of `feishu_topic_messages` (one readable reason each). */
1709
+ export type TopicMessagesFailureCode =
1710
+ | 'ROUTE_NOT_FOUND'
1711
+ | 'TOOL_DISABLED'
1712
+ | 'INVALID_ARGUMENT'
1713
+ | 'MESSAGE_OUT_OF_ROUTE'
1714
+ | 'MESSAGE_READ_FAILED'
1715
+ | 'PERMISSION_DENIED'
1716
+ | 'ABORTED'
1717
+ | 'TIMEOUT'
1718
+ | 'INTERNAL_ERROR'
1719
+
1720
+ const TOPIC_MESSAGES_FAILURE_REASONS: Readonly<Record<TopicMessagesFailureCode, string>> = {
1721
+ ROUTE_NOT_FOUND: '当前会话尚未绑定飞书话题或聊天,无法读取消息',
1722
+ TOOL_DISABLED: '话题消息读取工具已禁用',
1723
+ INVALID_ARGUMENT: '读取消息的参数不合法',
1724
+ MESSAGE_OUT_OF_ROUTE:
1725
+ '该消息不在当前会话的飞书话题/聊天范围内(仅可读取当前话题的消息或本会话已登记的消息)',
1726
+ MESSAGE_READ_FAILED: '读取飞书消息失败',
1727
+ PERMISSION_DENIED: '飞书应用缺少读取消息的权限(如 im:message:readonly),无法读取引用或话题消息',
1728
+ ABORTED: '读取飞书消息已取消',
1729
+ TIMEOUT: '读取飞书消息超时',
1730
+ INTERNAL_ERROR: '读取话题消息失败',
1731
+ }
1732
+
1733
+ /** `single` for one message_id read, `newest_first` for a history page. */
1734
+ export type TopicMessagesOrder = 'newest_first' | 'single'
1735
+
1736
+ /** The container the result belongs to (the current route's read scope). */
1737
+ export interface TopicMessagesContainer {
1738
+ readonly type: 'thread' | 'chat'
1739
+ readonly id: string
1740
+ }
1741
+
1742
+ /**
1743
+ * Result value of `feishu_topic_messages`. Lossless JSON by construction:
1744
+ * optional keys are omitted instead of set to `undefined`.
1745
+ */
1746
+ export interface TopicMessagesValue {
1747
+ readonly ok: boolean
1748
+ readonly summary: string
1749
+ readonly container: TopicMessagesContainer
1750
+ readonly order: TopicMessagesOrder
1751
+ readonly message_count: number
1752
+ readonly has_more: boolean
1753
+ /** Opaque page token of the next older page (`has_more` pages only). */
1754
+ readonly cursor?: string
1755
+ readonly messages: readonly TopicMessageView[]
1756
+ readonly code?: TopicMessagesFailureCode
1757
+ readonly reason?: string
1758
+ }
1759
+
1760
+ /** Model-facing output schema; mirrors {@link TopicMessagesValue}. */
1761
+ export const TOPIC_MESSAGES_OUTPUT_SCHEMA: Record<string, unknown> = {
1762
+ type: 'object',
1763
+ additionalProperties: false,
1764
+ required: ['ok', 'summary', 'container', 'order', 'message_count', 'has_more', 'messages'],
1765
+ properties: {
1766
+ ok: { type: 'boolean' },
1767
+ summary: { type: 'string' },
1768
+ container: {
1769
+ type: 'object',
1770
+ additionalProperties: false,
1771
+ required: ['type', 'id'],
1772
+ properties: {
1773
+ type: { type: 'string', enum: ['thread', 'chat'] },
1774
+ id: { type: 'string' },
1775
+ },
1776
+ },
1777
+ order: { type: 'string', enum: ['newest_first', 'single'] },
1778
+ message_count: { type: 'number' },
1779
+ has_more: { type: 'boolean' },
1780
+ cursor: { type: 'string' },
1781
+ messages: {
1782
+ type: 'array',
1783
+ items: {
1784
+ type: 'object',
1785
+ additionalProperties: false,
1786
+ required: ['message_id', 'type', 'sender', 'text'],
1787
+ properties: {
1788
+ message_id: { type: 'string' },
1789
+ type: { type: 'string' },
1790
+ sender: { type: 'string', enum: ['user', 'bot', 'system'] },
1791
+ time: { type: 'string' },
1792
+ deleted: { type: 'boolean' },
1793
+ text: { type: 'string' },
1794
+ parent_message_id: { type: 'string' },
1795
+ resources: {
1796
+ type: 'array',
1797
+ items: {
1798
+ type: 'object',
1799
+ additionalProperties: false,
1800
+ required: ['type', 'file_key'],
1801
+ properties: {
1802
+ type: { type: 'string', enum: ['image', 'file'] },
1803
+ file_key: { type: 'string' },
1804
+ name: { type: 'string' },
1805
+ folder: { type: 'boolean' },
1806
+ },
1807
+ },
1808
+ },
1809
+ },
1810
+ },
1811
+ },
1812
+ code: { type: 'string' },
1813
+ reason: { type: 'string' },
1814
+ },
1815
+ }
1816
+
1817
+ // ---- Topic messages runtime deps ----------------------------------------
1818
+
1819
+ /**
1820
+ * Everything the topic-messages tool runtime needs, injected by the service.
1821
+ *
1822
+ * `noteKnownMessage` / `isKnownMessage` MUST be the attachment-fetch runtime's
1823
+ * methods: both tools share one per-route whitelist, so a message discovered
1824
+ * here can immediately be fetched by `feishu_attachment_fetch` (AC5), and a
1825
+ * main-timeline message the fetch/quote layers already admitted stays readable
1826
+ * from a topic route.
1827
+ */
1828
+ export interface TopicMessagesRuntimeDeps {
1829
+ /** Live plugin configuration (switch, timeout). */
1830
+ config(): FeishuConfig
1831
+ /** Live Feishu channel; undefined while disconnected/shut down. */
1832
+ channel(): FeishuChannel | undefined
1833
+ /** Resolve the route currently bound to one session. */
1834
+ routeOfSession(sessionId: string): AttachmentFetchRoute | undefined
1835
+ /** Register one discovered message's resources for the shared fetch whitelist. */
1836
+ noteKnownMessage(
1837
+ route: AttachmentFetchRoute,
1838
+ message: KnownAttachmentMessageInput,
1839
+ source: KnownAttachmentSource,
1840
+ ): void
1841
+ /** Whether one message id is already authorized for the route (whitelist/pending). */
1842
+ isKnownMessage(route: AttachmentFetchRoute, messageId: string): boolean
1843
+ /**
1844
+ * Whether `feishu_attachment_fetch` is usable in this deployment (native
1845
+ * backend + tool enabled). `false` makes the tool description tell the model
1846
+ * to report attachment metadata instead of fetching. Absent = available.
1847
+ */
1848
+ fetchAvailable?(): boolean
1849
+ /** Structured warning sink (mount degradation, unexpected failures). */
1850
+ warn(message: string, ...args: unknown[]): void
1851
+ }
1852
+
1853
+ type TopicReadOutcome =
1854
+ | { readonly ok: true; readonly value: TopicMessagesValue }
1855
+ | { readonly ok: false; readonly code: TopicMessagesFailureCode; readonly reason: string }
1856
+
1857
+ /**
1858
+ * The `feishu_topic_messages` runtime: mounts the tool into Agent scopes and
1859
+ * executes message reads against the session's current route.
1860
+ *
1861
+ * It deliberately owns no per-route state: the authorization whitelist lives in
1862
+ * the attachment-fetch runtime (shared through {@link TopicMessagesRuntimeDeps}),
1863
+ * so this runtime only needs its mount bookkeeping.
1864
+ */
1865
+ export class TopicMessagesToolRuntime {
1866
+ /** Agent scope → its one mount attempt (cordis callbacks get distinct ctx objects). */
1867
+ private readonly mountAttempts = new WeakMap<AgentContext, Promise<void>>()
1868
+ private warnedMissingTools = false
1869
+ private warnedMountFailure = false
1870
+ private warnedUnavailable = false
1871
+
1872
+ constructor(private readonly deps: TopicMessagesRuntimeDeps) {}
1873
+
1874
+ /** Whether the feature switch currently allows tool registration/execution. */
1875
+ enabled(): boolean {
1876
+ return this.deps.config().feishuTools.topicMessages
1877
+ }
1878
+
1879
+ /**
1880
+ * Register the tool inside one Agent scope. Mirrors the attachment tool's
1881
+ * mount: `inject(['tools'])` is the P0-proven path, missing services are a
1882
+ * no-op with one readable warning, and a slow host never blocks session
1883
+ * creation (bounded 2 s wait, the pending injection can still backfill).
1884
+ */
1885
+ async mount(agentCtx: AgentContext, sessionId: string): Promise<void> {
1886
+ if (!this.enabled()) return
1887
+ // Cordis hands the inject callback a DIFFERENT context object than the
1888
+ // agent ctx, so dedupe on the outer scope and reuse its single attempt.
1889
+ const existing = this.mountAttempts.get(agentCtx)
1890
+ if (existing !== undefined) return await existing
1891
+ const attempt = this.mountAttempt(agentCtx, sessionId)
1892
+ this.mountAttempts.set(agentCtx, attempt)
1893
+ await attempt
1894
+ }
1895
+
1896
+ /** No process-local state to drop; kept for symmetry with the fetch runtime. */
1897
+ clear(): void {
1898
+ // Intentionally empty: this runtime registers a tool per Agent scope only.
1899
+ }
1900
+
1901
+ private async mountAttempt(agentCtx: AgentContext, sessionId: string): Promise<void> {
1902
+ let injected = false
1903
+ const outcome = await waitForAvailability(
1904
+ Promise.resolve(agentCtx.inject(['tools'], (ctx) => {
1905
+ injected = true
1906
+ this.registerInScope(ctx, sessionId)
1907
+ })),
1908
+ // A deployment that already timed out once is unlikely to gain the
1909
+ // services a moment later: fail fast on later sessions while the pending
1910
+ // injection can still backfill registration if they do appear.
1911
+ this.warnedUnavailable ? MOUNT_INJECT_RETRY_WAIT_MS : MOUNT_INJECT_WAIT_MS,
1912
+ )
1913
+ if (outcome === 'failed') {
1914
+ this.warnMountFailure(`dsh-channels: mounting ${TOPIC_MESSAGES_TOOL_NAME} failed`)
1915
+ return
1916
+ }
1917
+ // Real cordis settles an injection whose dependencies never appear WITHOUT
1918
+ // calling the callback; treat "settled but never injected" as unavailable.
1919
+ // The fiber stays pending so a late service still backfills registration.
1920
+ if (!injected && !this.warnedUnavailable) {
1921
+ this.warnedUnavailable = true
1922
+ this.deps.warn(
1923
+ 'dsh-channels: %s 未挂载:宿主 tools 服务不可用(会话继续)',
1924
+ TOPIC_MESSAGES_TOOL_NAME,
1925
+ )
1926
+ }
1927
+ }
1928
+
1929
+ private registerInScope(agentCtx: AgentContext, sessionId: string): void {
1930
+ // No per-callback-context dedupe: when a host service unloads/reloads,
1931
+ // cordis disposes the injection fiber's effects (including our
1932
+ // `tools.register`) and invokes this callback again with the same ctx
1933
+ // object. Skipping the re-registration would silently lose the tool.
1934
+ const tools = agentCtx.tools
1935
+ if (tools === undefined || typeof tools.register !== 'function') {
1936
+ if (!this.warnedMissingTools) {
1937
+ this.warnedMissingTools = true
1938
+ this.deps.warn(
1939
+ 'dsh-channels: %s 未注册:宿主 tools 服务不可用(话题消息工具已禁用)',
1940
+ TOPIC_MESSAGES_TOOL_NAME,
1941
+ )
1942
+ }
1943
+ return
1944
+ }
1945
+ try {
1946
+ tools.register(this.definitionFor(sessionId))
1947
+ } catch (error) {
1948
+ this.warnMountFailure(
1949
+ `dsh-channels: registering ${TOPIC_MESSAGES_TOOL_NAME} failed: ${describeFetchError(error)}`,
1950
+ )
1951
+ }
1952
+ }
1953
+
1954
+ private warnMountFailure(message: string): void {
1955
+ if (this.warnedMountFailure) return
1956
+ this.warnedMountFailure = true
1957
+ this.deps.warn(message)
1958
+ }
1959
+
1960
+ private definitionFor(sessionId: string): ToolDefinition {
1961
+ return {
1962
+ name: TOPIC_MESSAGES_TOOL_NAME,
1963
+ description: TOPIC_MESSAGES_DESCRIPTION
1964
+ + (this.isFetchUnavailable() ? TOPIC_MESSAGES_FETCH_UNAVAILABLE_DESCRIPTION : ''),
1965
+ parameters: TOPIC_MESSAGES_PARAMETERS,
1966
+ output: {
1967
+ schema: TOPIC_MESSAGES_OUTPUT_SCHEMA,
1968
+ render: (_args, value) => renderTopicMessagesResult(value),
1969
+ },
1970
+ execute: async (rawArgs, exec) => await this.execute(sessionId, rawArgs, exec),
1971
+ presentCall: args => presentTopicMessagesCall(args),
1972
+ presentResult: (_args, value) => presentTopicMessagesResult(value),
1973
+ isConcurrencySafe: () => true,
1974
+ }
1975
+ }
1976
+
1977
+ /**
1978
+ * Whether the deployment cannot fetch attachments (archive backend / fetch
1979
+ * tool disabled). A probe failure keeps the model guidance unchanged (the
1980
+ * tool still works; a fetch attempt returns its own readable failure).
1981
+ */
1982
+ private isFetchUnavailable(): boolean {
1983
+ try {
1984
+ return this.deps.fetchAvailable?.() === false
1985
+ } catch {
1986
+ return false
1987
+ }
1988
+ }
1989
+
1990
+ // ---- Execution --------------------------------------------------------
1991
+
1992
+ private async execute(
1993
+ sessionId: string,
1994
+ rawArgs: Record<string, unknown>,
1995
+ exec: ToolExecution,
1996
+ ): Promise<TopicMessagesValue> {
1997
+ const parsed = topicMessagesArgsSchema.safeParse(rawArgs)
1998
+ if (!parsed.success) {
1999
+ // A thrown, readable error is the correct shape for invalid arguments:
2000
+ // the host turns it into a failed tool call with a text message (never a
2001
+ // raw object rendered as `[object Object]`).
2002
+ const issue = parsed.error.issues[0]
2003
+ const path = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
2004
+ throw new Error(
2005
+ `${TOPIC_MESSAGES_TOOL_NAME}: invalid ${path}: ${issue?.message ?? 'invalid arguments'}`,
2006
+ )
2007
+ }
2008
+ const args = parsed.data
2009
+ if (args.message_id !== undefined && args.cursor !== undefined) {
2010
+ throw new Error(
2011
+ `${TOPIC_MESSAGES_TOOL_NAME}: invalid arguments: message_id 与 cursor 不能同时使用`
2012
+ + '(message_id 是单条读取,cursor 只用于列表翻页)',
2013
+ )
2014
+ }
2015
+ if (!this.enabled()) return topicFailureValue(args, undefined, 'TOOL_DISABLED')
2016
+
2017
+ // The route is re-read on every execution: a session can be re-bound (chat
2018
+ // route replaced by a topic route) between the mount and a later call.
2019
+ const route = this.deps.routeOfSession(sessionId)
2020
+ if (route === undefined) return topicFailureValue(args, undefined, 'ROUTE_NOT_FOUND')
2021
+
2022
+ const config = this.deps.config().feishuTools
2023
+ const reader = new MessageReader({ channel: () => this.deps.channel() })
2024
+ try {
2025
+ const outcome = args.message_id === undefined
2026
+ ? await this.listMessages(reader, route, args, exec, config.topicMessagesTimeoutMs)
2027
+ : await this.readMessageById(reader, route, args.message_id, exec, config.topicMessagesTimeoutMs)
2028
+ if (outcome.ok) return outcome.value
2029
+ return topicFailureValue(args, route, outcome.code, outcome.reason)
2030
+ } catch (error) {
2031
+ if (exec.signal.aborted) return topicFailureValue(args, route, 'ABORTED')
2032
+ const detail = describeReadError(error)
2033
+ this.deps.warn(
2034
+ 'dsh-channels: %s failed unexpectedly: %s',
2035
+ TOPIC_MESSAGES_TOOL_NAME,
2036
+ detail,
2037
+ )
2038
+ return topicFailureValue(
2039
+ args,
2040
+ route,
2041
+ 'INTERNAL_ERROR',
2042
+ `${TOPIC_MESSAGES_FAILURE_REASONS.INTERNAL_ERROR}:${detail}`,
2043
+ )
2044
+ }
2045
+ }
2046
+
2047
+ /** `message.list` mode: newest-first page of the current route's container. */
2048
+ private async listMessages(
2049
+ reader: MessageReader,
2050
+ route: AttachmentFetchRoute,
2051
+ args: TopicMessagesArgs,
2052
+ exec: ToolExecution,
2053
+ timeoutMs: number,
2054
+ ): Promise<TopicReadOutcome> {
2055
+ const limit = args.limit ?? TOPIC_MESSAGES_DEFAULT_LIMIT
2056
+ const listed = await reader.listRoute(route, {
2057
+ pageSize: limit,
2058
+ ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
2059
+ order: 'newest-first',
2060
+ signal: exec.signal,
2061
+ timeoutMs,
2062
+ })
2063
+ if (!listed.ok) {
2064
+ return { ok: false, code: topicFailureCodeOf(listed.code), reason: listed.reason }
2065
+ }
2066
+ const page = listed.value
2067
+ const messages: TopicMessageView[] = []
2068
+ for (const item of page.items) {
2069
+ messages.push(topicMessageViewOf(item))
2070
+ // Registering every row of a long history would churn the shared LRU
2071
+ // whitelist (a couple of 50-row pages evict the trigger/quote attachments
2072
+ // that ARE fetchable). Plain-text rows carry no downloadable resource, so
2073
+ // only attachment rows and merged-forward containers are noted.
2074
+ if (item.resources.length === 0 && item.msgType !== 'merge_forward') continue
2075
+ const known = knownMessageOfItems([item])
2076
+ if (known !== undefined) this.noteKnown(route, known)
2077
+ }
2078
+ const container = topicContainerOf(route)
2079
+ const label = topicContainerLabel(container.type)
2080
+ const more = page.hasMore ? ',还有更多(用 cursor 翻页)' : ''
2081
+ const summary = messages.length === 0
2082
+ ? `没有可读取的消息(${label} ${displayText(container.id)})`
2083
+ : `已读取 ${messages.length} 条消息${more}(${label} ${displayText(container.id)},按时间倒序)`
2084
+ return {
2085
+ ok: true,
2086
+ value: {
2087
+ ok: true,
2088
+ summary,
2089
+ container,
2090
+ order: 'newest_first',
2091
+ message_count: messages.length,
2092
+ has_more: page.hasMore,
2093
+ ...(page.pageToken === undefined ? {} : { cursor: page.pageToken }),
2094
+ messages,
2095
+ },
2096
+ }
2097
+ }
2098
+
2099
+ /** `message.get` mode: one message, a merged-forward container expanded. */
2100
+ private async readMessageById(
2101
+ reader: MessageReader,
2102
+ route: AttachmentFetchRoute,
2103
+ messageId: string,
2104
+ exec: ToolExecution,
2105
+ timeoutMs: number,
2106
+ ): Promise<TopicReadOutcome> {
2107
+ const read = await reader.readMessage(messageId, { signal: exec.signal, timeoutMs })
2108
+ if (!read.ok) {
2109
+ return { ok: false, code: topicFailureCodeOf(read.code), reason: read.reason }
2110
+ }
2111
+ const items = read.value
2112
+ // The container is NOT necessarily the first item: a merged-forward
2113
+ // `message.get` returns a flat list whose children carry `upper_message_id`,
2114
+ // so the shared resolver picks the top-level item independent of order.
2115
+ const container = quoteItemsOf(items)
2116
+ if (container === undefined) {
2117
+ return {
2118
+ ok: false,
2119
+ code: 'MESSAGE_READ_FAILED',
2120
+ reason: `飞书未返回该消息(${displayText(messageId)}),可能已被撤回或无权访问`,
2121
+ }
2122
+ }
2123
+ // Route membership is enforced on the container: another chat, another
2124
+ // topic of the same group, or an unregistered main-timeline message is
2125
+ // refused. A message already admitted to this route (trigger/quote/pending)
2126
+ // may be read even without a topic id (quotes happen before the topic).
2127
+ const known = this.deps.isKnownMessage(route, messageId)
2128
+ if (!messageBelongsToRoute(route, container, known ? [messageId] : [])) {
2129
+ return {
2130
+ ok: false,
2131
+ code: 'MESSAGE_OUT_OF_ROUTE',
2132
+ reason: TOPIC_MESSAGES_FAILURE_REASONS.MESSAGE_OUT_OF_ROUTE,
2133
+ }
2134
+ }
2135
+ // The read succeeded: register the container (merged-forward resources are
2136
+ // downloadable only through the top-level container id) so the fetch tool
2137
+ // can serve them without a second discovery round-trip.
2138
+ const knownMessage = knownMessageOfItems(items)
2139
+ if (knownMessage !== undefined) this.noteKnown(route, knownMessage)
2140
+
2141
+ // Container first, then its children in the order Feishu returned them.
2142
+ // The value is bounded by the same cap as rendering: one huge merged
2143
+ // forward must not flood the tool result/session history.
2144
+ const children = items.filter(item => item !== container)
2145
+ const ordered = [container, ...children]
2146
+ const shown = ordered.slice(0, TOPIC_MESSAGES_RENDER_MAX_MESSAGES)
2147
+ const hidden = ordered.length - shown.length
2148
+ const messages = shown.map(item => topicMessageViewOf(item))
2149
+ const resourceCount = ordered.reduce((total, item) => total + item.resources.length, 0)
2150
+ const containerInfo = topicContainerOf(route)
2151
+ const label = topicContainerLabel(containerInfo.type)
2152
+ const childCount = container.msgType === 'merge_forward' ? children.length : 0
2153
+ const mergeNote = container.msgType === 'merge_forward'
2154
+ ? `(合并转发,已展开 ${childCount} 条子消息)`
2155
+ : ''
2156
+ const resourceNote = resourceCount > 0 ? `,含 ${resourceCount} 个附件` : ''
2157
+ const hiddenNote = hidden > 0 ? `(另有 ${hidden} 条消息未展示)` : ''
2158
+ return {
2159
+ ok: true,
2160
+ value: {
2161
+ ok: true,
2162
+ summary: `已读取消息 ${displayText(container.messageId)}${mergeNote}${resourceNote}${hiddenNote}`
2163
+ + `(${label} ${displayText(containerInfo.id)})`,
2164
+ container: containerInfo,
2165
+ order: 'single',
2166
+ message_count: messages.length,
2167
+ has_more: false,
2168
+ messages,
2169
+ },
2170
+ }
2171
+ }
2172
+
2173
+ /**
2174
+ * Record one discovered message in the shared fetch whitelist. Bookkeeping
2175
+ * failures are warnings only: the model already has the read result and the
2176
+ * turn must not fail because the whitelist is momentarily unavailable.
2177
+ */
2178
+ private noteKnown(route: AttachmentFetchRoute, message: KnownAttachmentMessageInput): void {
2179
+ try {
2180
+ this.deps.noteKnownMessage(route, message, 'tool-result')
2181
+ } catch (error) {
2182
+ this.deps.warn(
2183
+ 'dsh-channels: %s failed to register a known message: %s',
2184
+ TOPIC_MESSAGES_TOOL_NAME,
2185
+ describeReadError(error),
2186
+ )
2187
+ }
2188
+ }
2189
+ }
2190
+
2191
+ /** The route's read container: the thread id for a topic, the chat id otherwise. */
2192
+ function topicContainerOf(route: AttachmentFetchRoute | undefined): TopicMessagesContainer {
2193
+ if (route === undefined) {
2194
+ // No route means no read scope; the empty id keeps the value shape uniform
2195
+ // for replay/rendering without inventing an authorization fact.
2196
+ return { type: 'chat', id: '' }
2197
+ }
2198
+ return route.kind === 'thread'
2199
+ ? { type: 'thread', id: route.key }
2200
+ : { type: 'chat', id: route.chatId }
2201
+ }
2202
+
2203
+ function topicContainerLabel(type: TopicMessagesContainer['type']): string {
2204
+ return type === 'thread' ? '话题' : '聊天'
2205
+ }
2206
+
2207
+ /** Map one `message-read` failure code into the tool's stable code set. */
2208
+ function topicFailureCodeOf(code: MessageReadFailureCode): TopicMessagesFailureCode {
2209
+ switch (code) {
2210
+ case 'PERMISSION_DENIED':
2211
+ return 'PERMISSION_DENIED'
2212
+ case 'INVALID_ARGUMENT':
2213
+ return 'INVALID_ARGUMENT'
2214
+ case 'ABORTED':
2215
+ return 'ABORTED'
2216
+ case 'TIMEOUT':
2217
+ return 'TIMEOUT'
2218
+ case 'CHANNEL_UNAVAILABLE':
2219
+ case 'MESSAGE_READ_FAILED':
2220
+ default:
2221
+ return 'MESSAGE_READ_FAILED'
2222
+ }
2223
+ }
2224
+
2225
+ /** One structured failure value (never thrown as a business error). */
2226
+ function topicFailureValue(
2227
+ args: TopicMessagesArgs | undefined,
2228
+ route: AttachmentFetchRoute | undefined,
2229
+ code: TopicMessagesFailureCode,
2230
+ reasonOverride?: string,
2231
+ ): TopicMessagesValue {
2232
+ const reason = reasonOverride ?? TOPIC_MESSAGES_FAILURE_REASONS[code]
2233
+ const container = topicContainerOf(route)
2234
+ const where = container.id === ''
2235
+ ? ''
2236
+ : `(${topicContainerLabel(container.type)} ${displayText(container.id)})`
2237
+ return {
2238
+ ok: false,
2239
+ summary: `${reason}${where}`,
2240
+ container,
2241
+ order: args?.message_id === undefined ? 'newest_first' : 'single',
2242
+ message_count: 0,
2243
+ has_more: false,
2244
+ messages: [],
2245
+ code,
2246
+ reason,
2247
+ }
2248
+ }
2249
+
2250
+ // ---- Topic messages rendering -------------------------------------------
2251
+
2252
+ /** Rendered message lines are bounded (whole-block character guard below). */
2253
+ const TOPIC_MESSAGES_RENDER_MAX_MESSAGES = TOPIC_MESSAGES_MAX_LIMIT
2254
+ const TOPIC_MESSAGES_RENDER_MAX_CHARS = 12_000
2255
+
2256
+ /** One model-facing line for a message view (id first so it is fetchable). */
2257
+ function topicMessageLine(view: TopicMessageView): string {
2258
+ const messageId = typeof view.message_id === 'string' ? displayText(view.message_id) : ''
2259
+ const sender = view.sender === 'bot' ? '机器人' : view.sender === 'system' ? '系统' : '用户'
2260
+ const time = typeof view.time === 'string' && view.time !== '' ? `[${view.time}] ` : ''
2261
+ const text = typeof view.text === 'string' ? view.text : ''
2262
+ const resources = Array.isArray(view.resources) ? view.resources : []
2263
+ const idPart = messageId === '' ? '' : `${messageId} `
2264
+ return `- ${idPart}${time}${sender}: ${text}${topicResourcesSuffix(resources)}`
2265
+ }
2266
+
2267
+ /** Compact attachment suffix of one rendered line (mirrors the prompt blocks). */
2268
+ function topicResourcesSuffix(resources: readonly TopicMessageResourceView[]): string {
2269
+ if (resources.length === 0) return ''
2270
+ const images = resources.filter(resource => resource.type === 'image').length
2271
+ const files = resources.filter(resource => resource.type === 'file')
2272
+ const parts: string[] = []
2273
+ if (images > 0) parts.push(`图片×${images}`)
2274
+ if (files.length > 0) {
2275
+ const named = files
2276
+ .filter(resource => typeof resource.name === 'string' && resource.name !== '')
2277
+ .slice(0, 2)
2278
+ .map(resource => `"${displayText(resource.name ?? '')}"`)
2279
+ parts.push(named.length === 0 ? `文件×${files.length}` : `文件 ${named.join('、')}`)
2280
+ }
2281
+ return `(附件:${parts.join(';')})`
2282
+ }
2283
+
2284
+ /**
2285
+ * Turn one tool result into model content: the summary plus one bounded line
2286
+ * per message. Rendering never throws on a replayed/malformed value (session
2287
+ * history projection) and the whole text block is length-bounded.
2288
+ */
2289
+ export function renderTopicMessagesResult(value: unknown): readonly ToolContentBlock[] {
2290
+ const result = (typeof value === 'object' && value !== null ? value : {}) as Partial<TopicMessagesValue>
2291
+ const summary = typeof result.summary === 'string' && result.summary !== ''
2292
+ ? result.summary
2293
+ : TOPIC_MESSAGES_TOOL_NAME
2294
+ const lines: string[] = [summary]
2295
+ if (typeof result.code === 'string') lines.push(`错误码:${result.code}`)
2296
+ if (typeof result.reason === 'string' && result.reason !== '' && result.reason !== summary) {
2297
+ lines.push(`原因:${result.reason}`)
2298
+ }
2299
+ // The model needs the opaque token verbatim to continue paging; it is only
2300
+ // visible through this rendered text (the host shows render output, not the
2301
+ // structured value). Omitting it made the model guess message ids in the
2302
+ // 2026-10-21 real-device smoke test.
2303
+ if (typeof result.cursor === 'string' && result.cursor !== '') {
2304
+ lines.push(`- 下一页 cursor: ${sanitizeInlineText(result.cursor, 512)}`)
2305
+ }
2306
+ const messages = (Array.isArray(result.messages) ? result.messages : [])
2307
+ .filter((message): message is TopicMessageView => typeof message === 'object' && message !== null)
2308
+ let used = lines.join('\n').length
2309
+ let shown = 0
2310
+ for (const view of messages) {
2311
+ if (shown >= TOPIC_MESSAGES_RENDER_MAX_MESSAGES) break
2312
+ const line = topicMessageLine(view)
2313
+ // Keep room for the truncation marker so the whole block stays bounded.
2314
+ if (used + line.length + 1 > TOPIC_MESSAGES_RENDER_MAX_CHARS - 120) break
2315
+ lines.push(line)
2316
+ used += line.length + 1
2317
+ shown += 1
2318
+ }
2319
+ const hidden = messages.length - shown
2320
+ if (hidden > 0) {
2321
+ lines.push(`- …(其余 ${hidden} 条消息未展示;可用 message_id 或 limit/cursor 继续读取)`)
2322
+ }
2323
+ return [{ type: 'text', text: lines.join('\n') }]
2324
+ }
2325
+
2326
+ /** Host-local card call projection: "read one message" / "list recent messages". */
2327
+ export function presentTopicMessagesCall(args: Record<string, unknown>): string {
2328
+ const messageId = typeof args.message_id === 'string' && args.message_id !== ''
2329
+ ? args.message_id
2330
+ : undefined
2331
+ if (messageId !== undefined) return `读取飞书消息 ${displayText(messageId)}`
2332
+ const rawLimit = args.limit
2333
+ const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit)
2334
+ ? clampInt(rawLimit, 1, TOPIC_MESSAGES_MAX_LIMIT)
2335
+ : TOPIC_MESSAGES_DEFAULT_LIMIT
2336
+ return typeof args.cursor === 'string' && args.cursor !== ''
2337
+ ? `读取飞书话题消息(最近 ${limit} 条,翻页)`
2338
+ : `读取飞书话题消息(最近 ${limit} 条)`
2339
+ }
2340
+
2341
+ /** Host-local card result projection: the one-line outcome. */
2342
+ export function presentTopicMessagesResult(value: unknown): string {
2343
+ const result = (typeof value === 'object' && value !== null ? value : {}) as Partial<TopicMessagesValue>
2344
+ return typeof result.summary === 'string' ? result.summary : TOPIC_MESSAGES_TOOL_NAME
2345
+ }