@zooid/transport-matrix 0.7.4 → 0.8.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.
- package/dist/index.d.ts +109 -1
- package/dist/index.js +271 -9
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/attachments.test.ts +58 -0
- package/src/attachments.ts +30 -0
- package/src/context-provider.test.ts +33 -0
- package/src/context-provider.ts +21 -3
- package/src/index.ts +8 -2
- package/src/media-client.test.ts +102 -0
- package/src/media-client.ts +69 -0
- package/src/pending-media.test.ts +51 -0
- package/src/pending-media.ts +37 -0
- package/src/router.test.ts +22 -1
- package/src/router.ts +11 -0
- package/src/space-provisioner.test.ts +26 -1
- package/src/space-provisioner.ts +15 -4
- package/src/transport.test.ts +242 -0
- package/src/transport.ts +227 -4
package/src/transport.test.ts
CHANGED
|
@@ -1312,3 +1312,245 @@ describe('full loop integration', () => {
|
|
|
1312
1312
|
)
|
|
1313
1313
|
})
|
|
1314
1314
|
})
|
|
1315
|
+
|
|
1316
|
+
// ─── Media pipeline tests ────────────────────────────────────────────────────
|
|
1317
|
+
|
|
1318
|
+
const TINY_PNG_B64 =
|
|
1319
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
|
|
1320
|
+
const TINY_PNG = Buffer.from(TINY_PNG_B64, 'base64')
|
|
1321
|
+
|
|
1322
|
+
function fakeMedia() {
|
|
1323
|
+
return {
|
|
1324
|
+
download: vi.fn(async () => ({ data: new Uint8Array(TINY_PNG), contentType: 'image/png' })),
|
|
1325
|
+
upload: vi.fn(async () => ({ content_uri: 'mxc://localhost/up1' })),
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const workspaceBinding = {
|
|
1330
|
+
...baseAgents[0],
|
|
1331
|
+
workspaceDir: '/tmp/ws',
|
|
1332
|
+
agentWorkspacePath: '/workspace',
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function makeMediaTransport(opts: {
|
|
1336
|
+
media?: ReturnType<typeof fakeMedia>
|
|
1337
|
+
writeAttachmentFn?: unknown
|
|
1338
|
+
} = {}) {
|
|
1339
|
+
const { reg, finishPrompt } = fakeRegistry()
|
|
1340
|
+
const approvals = fakeApprovals()
|
|
1341
|
+
const client = fakeClient()
|
|
1342
|
+
const transport = createMatrixTransport({
|
|
1343
|
+
agents: reg as never,
|
|
1344
|
+
approvals: approvals as never,
|
|
1345
|
+
client: client as never,
|
|
1346
|
+
bindings: [workspaceBinding],
|
|
1347
|
+
hsToken: 'hs-secret',
|
|
1348
|
+
drainQuietMs: 0,
|
|
1349
|
+
media: opts.media as never,
|
|
1350
|
+
writeAttachmentFn: opts.writeAttachmentFn as never,
|
|
1351
|
+
})
|
|
1352
|
+
return { transport, agents: reg, client, finishPrompt }
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function imageEvent(over: {
|
|
1356
|
+
size?: number
|
|
1357
|
+
mimetype?: string
|
|
1358
|
+
msgtype?: string
|
|
1359
|
+
body?: string
|
|
1360
|
+
eventId?: string
|
|
1361
|
+
} = {}) {
|
|
1362
|
+
return {
|
|
1363
|
+
type: 'm.room.message',
|
|
1364
|
+
event_id: over.eventId ?? '$media1',
|
|
1365
|
+
room_id: '!r:example.com',
|
|
1366
|
+
sender: '@alice:example.com',
|
|
1367
|
+
content: {
|
|
1368
|
+
msgtype: over.msgtype ?? 'm.image',
|
|
1369
|
+
body: over.body ?? 'dog.png',
|
|
1370
|
+
url: 'mxc://localhost/abc',
|
|
1371
|
+
info: { mimetype: over.mimetype ?? 'image/png', size: over.size ?? 67 },
|
|
1372
|
+
},
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function mentionMsg(body: string, eventId = '$text1') {
|
|
1377
|
+
return {
|
|
1378
|
+
type: 'm.room.message',
|
|
1379
|
+
event_id: eventId,
|
|
1380
|
+
room_id: '!r:example.com',
|
|
1381
|
+
sender: '@alice:example.com',
|
|
1382
|
+
content: {
|
|
1383
|
+
msgtype: 'm.text',
|
|
1384
|
+
body: `@architect ${body}`,
|
|
1385
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
1386
|
+
},
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
describe('inbound media', () => {
|
|
1391
|
+
it('media events do not trigger a turn; m.text from the same sender drains them inline', async () => {
|
|
1392
|
+
const media = fakeMedia()
|
|
1393
|
+
const { transport, agents } = makeMediaTransport({ media })
|
|
1394
|
+
|
|
1395
|
+
// Image event: no turn fired
|
|
1396
|
+
await postTxn(transport.app, { events: [imageEvent()] })
|
|
1397
|
+
await settleTurn()
|
|
1398
|
+
expect(agents.prompt).not.toHaveBeenCalled()
|
|
1399
|
+
|
|
1400
|
+
// m.text mention from same sender: turn fires, image block prepended
|
|
1401
|
+
agents.prompt.mockImplementation(async (_name: string, p: { content: unknown[] }) => {
|
|
1402
|
+
agents.onEvent('architect', {
|
|
1403
|
+
type: 'agent_message_chunk',
|
|
1404
|
+
sessionId: 'sess-$text1',
|
|
1405
|
+
content: { type: 'text', text: 'got it' },
|
|
1406
|
+
})
|
|
1407
|
+
return { stopReason: 'end_turn' as const }
|
|
1408
|
+
})
|
|
1409
|
+
await postTxn(transport.app, { events: [mentionMsg('look at this')] })
|
|
1410
|
+
await settleTurn()
|
|
1411
|
+
|
|
1412
|
+
expect(agents.prompt).toHaveBeenCalledOnce()
|
|
1413
|
+
const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
|
|
1414
|
+
expect(content[0]).toMatchObject({ type: 'image', data: TINY_PNG_B64, mimeType: 'image/png' })
|
|
1415
|
+
expect((content[1] as { type: string; text: string }).type).toBe('text')
|
|
1416
|
+
expect(media.download).toHaveBeenCalledOnce()
|
|
1417
|
+
})
|
|
1418
|
+
|
|
1419
|
+
it('routes an oversized image to the file path with a resource_link block and prose line', async () => {
|
|
1420
|
+
const media = fakeMedia()
|
|
1421
|
+
const writeAttachmentFn = vi.fn(() => ({
|
|
1422
|
+
hostPath: '/tmp/ws/.zooid/attachments/media1/dog.png',
|
|
1423
|
+
agentPath: '/workspace/.zooid/attachments/media1/dog.png',
|
|
1424
|
+
}))
|
|
1425
|
+
const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
|
|
1426
|
+
|
|
1427
|
+
agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
|
|
1428
|
+
await postTxn(transport.app, { events: [imageEvent({ size: 600_000 })] }) // > MAX_INLINE_IMAGE_BYTES
|
|
1429
|
+
await postTxn(transport.app, { events: [mentionMsg('summarize')] })
|
|
1430
|
+
await settleTurn()
|
|
1431
|
+
|
|
1432
|
+
const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
|
|
1433
|
+
expect(content[0]).toMatchObject({
|
|
1434
|
+
type: 'resource_link',
|
|
1435
|
+
uri: 'file:///workspace/.zooid/attachments/media1/dog.png',
|
|
1436
|
+
name: 'dog.png',
|
|
1437
|
+
})
|
|
1438
|
+
expect((content[1] as { text: string }).text).toContain(
|
|
1439
|
+
'/workspace/.zooid/attachments/media1/dog.png',
|
|
1440
|
+
)
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
it('routes m.file to the workspace regardless of size', async () => {
|
|
1444
|
+
const media = fakeMedia()
|
|
1445
|
+
const writeAttachmentFn = vi.fn(() => ({
|
|
1446
|
+
hostPath: '/tmp/ws/.zooid/attachments/media1/report.pdf',
|
|
1447
|
+
agentPath: '/workspace/.zooid/attachments/media1/report.pdf',
|
|
1448
|
+
}))
|
|
1449
|
+
const { transport, agents } = makeMediaTransport({ media, writeAttachmentFn })
|
|
1450
|
+
|
|
1451
|
+
agents.prompt.mockResolvedValue({ stopReason: 'end_turn' as const })
|
|
1452
|
+
await postTxn(transport.app, {
|
|
1453
|
+
events: [imageEvent({ msgtype: 'm.file', body: 'report.pdf', mimetype: 'application/pdf' })],
|
|
1454
|
+
})
|
|
1455
|
+
await postTxn(transport.app, { events: [mentionMsg('read it')] })
|
|
1456
|
+
await settleTurn()
|
|
1457
|
+
|
|
1458
|
+
expect(writeAttachmentFn).toHaveBeenCalledOnce()
|
|
1459
|
+
const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
|
|
1460
|
+
expect((content[0] as { type: string }).type).toBe('resource_link')
|
|
1461
|
+
})
|
|
1462
|
+
|
|
1463
|
+
it('emits eco.zoon.error (code media_failed) when download fails, still runs the turn text-only', async () => {
|
|
1464
|
+
const media = fakeMedia()
|
|
1465
|
+
media.download.mockRejectedValueOnce(new Error('download boom'))
|
|
1466
|
+
const { transport, agents, client } = makeMediaTransport({ media })
|
|
1467
|
+
|
|
1468
|
+
agents.prompt.mockImplementation(async () => {
|
|
1469
|
+
agents.onEvent('architect', {
|
|
1470
|
+
type: 'agent_message_chunk',
|
|
1471
|
+
sessionId: 'sess-$text1',
|
|
1472
|
+
content: { type: 'text', text: 'ok' },
|
|
1473
|
+
})
|
|
1474
|
+
return { stopReason: 'end_turn' as const }
|
|
1475
|
+
})
|
|
1476
|
+
await postTxn(transport.app, { events: [imageEvent()] })
|
|
1477
|
+
await postTxn(transport.app, { events: [mentionMsg('look')] })
|
|
1478
|
+
await settleTurn()
|
|
1479
|
+
|
|
1480
|
+
expect(client.sendCustomEvent).toHaveBeenCalledWith(
|
|
1481
|
+
expect.objectContaining({
|
|
1482
|
+
eventType: 'eco.zoon.error',
|
|
1483
|
+
content: expect.objectContaining({ code: 'media_failed' }),
|
|
1484
|
+
}),
|
|
1485
|
+
)
|
|
1486
|
+
const content = (agents.prompt.mock.calls[0][1] as { content: unknown[] }).content
|
|
1487
|
+
expect(content).toHaveLength(1)
|
|
1488
|
+
expect((content[0] as { type: string }).type).toBe('text')
|
|
1489
|
+
})
|
|
1490
|
+
})
|
|
1491
|
+
|
|
1492
|
+
describe('outbound agent images', () => {
|
|
1493
|
+
it('uploads an image chunk and sends a threaded m.image as the agent user', async () => {
|
|
1494
|
+
const media = fakeMedia()
|
|
1495
|
+
const { transport, agents, client } = makeMediaTransport({ media })
|
|
1496
|
+
|
|
1497
|
+
agents.prompt.mockImplementation(async () => {
|
|
1498
|
+
// Emit image block during prompt
|
|
1499
|
+
agents.onEvent('architect', {
|
|
1500
|
+
type: 'agent_message_chunk',
|
|
1501
|
+
sessionId: 'sess-$text1',
|
|
1502
|
+
content: { type: 'image', data: TINY_PNG_B64, mimeType: 'image/png' },
|
|
1503
|
+
})
|
|
1504
|
+
// Also emit text block so the turn isn't empty
|
|
1505
|
+
agents.onEvent('architect', {
|
|
1506
|
+
type: 'agent_message_chunk',
|
|
1507
|
+
sessionId: 'sess-$text1',
|
|
1508
|
+
content: { type: 'text', text: 'here is the image' },
|
|
1509
|
+
})
|
|
1510
|
+
return { stopReason: 'end_turn' as const }
|
|
1511
|
+
})
|
|
1512
|
+
|
|
1513
|
+
await postTxn(transport.app, { events: [mentionMsg('show me an image')] })
|
|
1514
|
+
await settleTurn()
|
|
1515
|
+
// Give async upload/send a moment to settle
|
|
1516
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
1517
|
+
|
|
1518
|
+
expect(media.upload).toHaveBeenCalledWith(
|
|
1519
|
+
expect.objectContaining({ contentType: 'image/png', asUserId: '@architect:example.com' }),
|
|
1520
|
+
)
|
|
1521
|
+
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
1522
|
+
expect.objectContaining({
|
|
1523
|
+
asUserId: '@architect:example.com',
|
|
1524
|
+
content: expect.objectContaining({
|
|
1525
|
+
msgtype: 'm.image',
|
|
1526
|
+
url: 'mxc://localhost/up1',
|
|
1527
|
+
info: expect.objectContaining({ mimetype: 'image/png', size: TINY_PNG.length }),
|
|
1528
|
+
}),
|
|
1529
|
+
}),
|
|
1530
|
+
)
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
it('does not throw when an audio block arrives (non-goal — warn and drop)', async () => {
|
|
1534
|
+
const media = fakeMedia()
|
|
1535
|
+
const { transport, agents } = makeMediaTransport({ media })
|
|
1536
|
+
|
|
1537
|
+
agents.prompt.mockImplementation(async () => {
|
|
1538
|
+
agents.onEvent('architect', {
|
|
1539
|
+
type: 'agent_message_chunk',
|
|
1540
|
+
sessionId: 'sess-$text1',
|
|
1541
|
+
content: { type: 'audio', data: 'AAAA', mimeType: 'audio/wav' },
|
|
1542
|
+
})
|
|
1543
|
+
agents.onEvent('architect', {
|
|
1544
|
+
type: 'agent_message_chunk',
|
|
1545
|
+
sessionId: 'sess-$text1',
|
|
1546
|
+
content: { type: 'text', text: 'ok' },
|
|
1547
|
+
})
|
|
1548
|
+
return { stopReason: 'end_turn' as const }
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
await expect(
|
|
1552
|
+
postTxn(transport.app, { events: [mentionMsg('test audio')] }),
|
|
1553
|
+
).resolves.not.toThrow()
|
|
1554
|
+
await settleTurn()
|
|
1555
|
+
})
|
|
1556
|
+
})
|
package/src/transport.ts
CHANGED
|
@@ -1,14 +1,38 @@
|
|
|
1
1
|
import { Hono } from 'hono'
|
|
2
2
|
import { timingSafeEqual } from 'node:crypto'
|
|
3
3
|
import type { AcpRegistry, ApprovalCorrelator, RegisteredApproval } from '@zooid/core'
|
|
4
|
-
import type { AgentEvent } from '@zooid/acp-client'
|
|
4
|
+
import type { AgentEvent, ContentBlock } from '@zooid/acp-client'
|
|
5
5
|
import { MatrixClient } from './matrix-client.js'
|
|
6
6
|
import { BotPool } from './bot-pool.js'
|
|
7
|
-
import { route, type AgentBinding, type ThreadState } from './router.js'
|
|
7
|
+
import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
|
|
8
8
|
import { stripMention, extractMentions } from './mentions.js'
|
|
9
9
|
import { toToolCallBody, toUpdateBody, toPlanBody, toErrorBody } from './event-encoders.js'
|
|
10
10
|
import { classify } from '@zooid/acp-client'
|
|
11
11
|
import { toMatrixHtml } from './markdown-to-matrix-html.js'
|
|
12
|
+
import {
|
|
13
|
+
PendingMediaStore,
|
|
14
|
+
type PendingMediaItem,
|
|
15
|
+
} from './pending-media.js'
|
|
16
|
+
import {
|
|
17
|
+
MediaClient,
|
|
18
|
+
MAX_INLINE_IMAGE_BYTES,
|
|
19
|
+
INLINE_IMAGE_MIMES,
|
|
20
|
+
} from './media-client.js'
|
|
21
|
+
import { writeAttachment } from './attachments.js'
|
|
22
|
+
|
|
23
|
+
export interface MediaClientLike {
|
|
24
|
+
download(input: {
|
|
25
|
+
mxcUri: string
|
|
26
|
+
asUserId: string
|
|
27
|
+
maxBytes?: number
|
|
28
|
+
}): Promise<{ data: Uint8Array; contentType: string }>
|
|
29
|
+
upload(input: {
|
|
30
|
+
data: Uint8Array
|
|
31
|
+
contentType: string
|
|
32
|
+
filename?: string
|
|
33
|
+
asUserId: string
|
|
34
|
+
}): Promise<{ content_uri: string }>
|
|
35
|
+
}
|
|
12
36
|
|
|
13
37
|
export interface CreateMatrixTransportOptions {
|
|
14
38
|
agents: AcpRegistry
|
|
@@ -24,6 +48,10 @@ export interface CreateMatrixTransportOptions {
|
|
|
24
48
|
drainQuietMs?: number
|
|
25
49
|
/** Hard cap on the post-turn drain. Defaults to `DRAIN_MAX_MS`. */
|
|
26
50
|
drainMaxMs?: number
|
|
51
|
+
/** Injected media client for downloading/uploading Matrix media. */
|
|
52
|
+
media?: MediaClientLike
|
|
53
|
+
/** Injected attachment writer (defaults to the real writeAttachment). */
|
|
54
|
+
writeAttachmentFn?: typeof writeAttachment
|
|
27
55
|
}
|
|
28
56
|
|
|
29
57
|
interface SessionContext {
|
|
@@ -47,6 +75,120 @@ interface MatrixEvent {
|
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
const STARTUP_GRACE_MS = 5_000
|
|
78
|
+
|
|
79
|
+
interface MediaBlocksResult {
|
|
80
|
+
blocks: ContentBlock[]
|
|
81
|
+
pathLines: string[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function buildMediaBlocks(
|
|
85
|
+
items: PendingMediaItem[],
|
|
86
|
+
opts: {
|
|
87
|
+
agent: AgentBinding
|
|
88
|
+
media: MediaClientLike | undefined
|
|
89
|
+
writeAttachmentFn: typeof writeAttachment
|
|
90
|
+
onError: (item: PendingMediaItem, err: unknown) => void
|
|
91
|
+
},
|
|
92
|
+
): Promise<MediaBlocksResult> {
|
|
93
|
+
const blocks: ContentBlock[] = []
|
|
94
|
+
const pathLines: string[] = []
|
|
95
|
+
|
|
96
|
+
if (!opts.media || items.length === 0) return { blocks, pathLines }
|
|
97
|
+
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
try {
|
|
100
|
+
const isInlineCandidate =
|
|
101
|
+
item.msgtype === 'm.image' &&
|
|
102
|
+
INLINE_IMAGE_MIMES.includes(item.info?.mimetype ?? '') &&
|
|
103
|
+
(item.info?.size === undefined || item.info.size <= MAX_INLINE_IMAGE_BYTES)
|
|
104
|
+
|
|
105
|
+
if (isInlineCandidate) {
|
|
106
|
+
const { data, contentType } = await opts.media.download({
|
|
107
|
+
mxcUri: item.url,
|
|
108
|
+
asUserId: opts.agent.userId,
|
|
109
|
+
})
|
|
110
|
+
// Double-check actual size (info can lie)
|
|
111
|
+
if (data.byteLength <= MAX_INLINE_IMAGE_BYTES) {
|
|
112
|
+
blocks.push({
|
|
113
|
+
type: 'image',
|
|
114
|
+
data: Buffer.from(data).toString('base64'),
|
|
115
|
+
mimeType: contentType,
|
|
116
|
+
})
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
// Actual size exceeded cap — fall through to file route with the already-downloaded bytes
|
|
120
|
+
if (opts.agent.workspaceDir) {
|
|
121
|
+
const paths = opts.writeAttachmentFn({
|
|
122
|
+
workspaceDir: opts.agent.workspaceDir,
|
|
123
|
+
agentWorkspacePath: opts.agent.agentWorkspacePath ?? opts.agent.workspaceDir,
|
|
124
|
+
eventId: item.eventId,
|
|
125
|
+
filename: item.filename ?? item.body,
|
|
126
|
+
data,
|
|
127
|
+
})
|
|
128
|
+
blocks.push({
|
|
129
|
+
type: 'resource_link',
|
|
130
|
+
uri: `file://${paths.agentPath}`,
|
|
131
|
+
name: item.filename ?? item.body,
|
|
132
|
+
})
|
|
133
|
+
pathLines.push(`Attached file: ${paths.agentPath}`)
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
// File route (m.file, m.video, m.audio, or oversized image)
|
|
137
|
+
if (!opts.agent.workspaceDir) continue
|
|
138
|
+
const { data } = await opts.media.download({
|
|
139
|
+
mxcUri: item.url,
|
|
140
|
+
asUserId: opts.agent.userId,
|
|
141
|
+
})
|
|
142
|
+
const paths = opts.writeAttachmentFn({
|
|
143
|
+
workspaceDir: opts.agent.workspaceDir,
|
|
144
|
+
agentWorkspacePath: opts.agent.agentWorkspacePath ?? opts.agent.workspaceDir,
|
|
145
|
+
eventId: item.eventId,
|
|
146
|
+
filename: item.filename ?? item.body,
|
|
147
|
+
data,
|
|
148
|
+
})
|
|
149
|
+
blocks.push({
|
|
150
|
+
type: 'resource_link',
|
|
151
|
+
uri: `file://${paths.agentPath}`,
|
|
152
|
+
name: item.filename ?? item.body,
|
|
153
|
+
mimeType: item.info?.mimetype,
|
|
154
|
+
size: item.info?.size,
|
|
155
|
+
})
|
|
156
|
+
pathLines.push(`Attached file: ${paths.agentPath}`)
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
opts.onError(item, err)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { blocks, pathLines }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function sendMediaError(
|
|
167
|
+
ctx: { agent: AgentBinding; roomId: string; threadRoot: string },
|
|
168
|
+
_err: unknown,
|
|
169
|
+
message: string,
|
|
170
|
+
client: MatrixClient,
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
await client
|
|
173
|
+
.sendCustomEvent({
|
|
174
|
+
roomId: ctx.roomId,
|
|
175
|
+
asUserId: ctx.agent.userId,
|
|
176
|
+
eventType: 'eco.zoon.error',
|
|
177
|
+
content: toErrorBody(
|
|
178
|
+
{
|
|
179
|
+
kind: 'error' as const,
|
|
180
|
+
agentId: ctx.agent.name,
|
|
181
|
+
sessionId: null,
|
|
182
|
+
turnId: null,
|
|
183
|
+
code: 'media_failed',
|
|
184
|
+
message: message.slice(0, 250),
|
|
185
|
+
transient: false,
|
|
186
|
+
},
|
|
187
|
+
ctx.threadRoot,
|
|
188
|
+
),
|
|
189
|
+
})
|
|
190
|
+
.catch((e) => console.warn(`[matrix:${ctx.agent.name}] eco.zoon.error send failed:`, e))
|
|
191
|
+
}
|
|
50
192
|
const SEEN_EVENT_CAP = 5_000
|
|
51
193
|
|
|
52
194
|
// ACP only guarantees that an agent flushes pending `session/update`
|
|
@@ -77,6 +219,9 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
77
219
|
const { agents, approvals, client, bindings, hsToken, adminUserId } = opts
|
|
78
220
|
const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
|
|
79
221
|
const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
|
|
222
|
+
const mediaClient = opts.media
|
|
223
|
+
const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment
|
|
224
|
+
const pendingMedia = new PendingMediaStore()
|
|
80
225
|
const pool = new BotPool(client, bindings)
|
|
81
226
|
const sessions = new Map<string, SessionContext>()
|
|
82
227
|
const buffers = new Map<string, string>()
|
|
@@ -104,7 +249,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
104
249
|
}
|
|
105
250
|
|
|
106
251
|
if (event.type === 'agent_message_chunk') {
|
|
107
|
-
const block = event.content as { type?: string; text?: string }
|
|
252
|
+
const block = event.content as { type?: string; text?: string; data?: string; mimeType?: string }
|
|
108
253
|
if (block.type === 'text' && typeof block.text === 'string') {
|
|
109
254
|
const current = buffers.get(event.sessionId) ?? ''
|
|
110
255
|
// Within a message, tokens carry their own leading spaces, so we
|
|
@@ -127,6 +272,38 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
127
272
|
buffers.set(event.sessionId, current + prefix + block.text)
|
|
128
273
|
if (event.messageId !== undefined)
|
|
129
274
|
bufferMessageIds.set(event.sessionId, event.messageId)
|
|
275
|
+
} else if (
|
|
276
|
+
block.type === 'image' &&
|
|
277
|
+
typeof block.data === 'string' &&
|
|
278
|
+
typeof block.mimeType === 'string' &&
|
|
279
|
+
mediaClient
|
|
280
|
+
) {
|
|
281
|
+
// Outbound agent image: upload immediately and send as a threaded m.image.
|
|
282
|
+
const ctx = sessions.get(event.sessionId)
|
|
283
|
+
if (ctx) {
|
|
284
|
+
const bytes = Buffer.from(block.data, 'base64')
|
|
285
|
+
const ext = (block.mimeType.split('/')[1] ?? 'png').replace(/[^a-z0-9]/gi, '')
|
|
286
|
+
const filename = `image.${ext}`
|
|
287
|
+
void mediaClient
|
|
288
|
+
.upload({ data: bytes, contentType: block.mimeType, filename, asUserId: ctx.agent.userId })
|
|
289
|
+
.then(({ content_uri }) =>
|
|
290
|
+
client.sendMessage({
|
|
291
|
+
roomId: ctx.roomId,
|
|
292
|
+
asUserId: ctx.agent.userId,
|
|
293
|
+
threadRoot: ctx.threadRoot,
|
|
294
|
+
content: {
|
|
295
|
+
msgtype: 'm.image',
|
|
296
|
+
body: filename,
|
|
297
|
+
url: content_uri,
|
|
298
|
+
info: { mimetype: block.mimeType, size: bytes.length },
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
)
|
|
302
|
+
.catch((err) => {
|
|
303
|
+
console.warn(`[matrix:${name}] outbound image upload failed:`, err)
|
|
304
|
+
void sendMediaError(ctx, err, 'agent image upload failed', client)
|
|
305
|
+
})
|
|
306
|
+
}
|
|
130
307
|
} else {
|
|
131
308
|
console.warn(`[matrix:${name}] dropped chunk block type=${block.type}`, block)
|
|
132
309
|
}
|
|
@@ -315,6 +492,29 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
315
492
|
continue
|
|
316
493
|
}
|
|
317
494
|
logInbound(evt)
|
|
495
|
+
|
|
496
|
+
// Capture media events in the pending store; never route them to agents.
|
|
497
|
+
if (
|
|
498
|
+
evt.type === 'm.room.message' &&
|
|
499
|
+
isMediaMsgtype(evt.content?.msgtype) &&
|
|
500
|
+
evt.room_id &&
|
|
501
|
+
evt.event_id &&
|
|
502
|
+
evt.sender &&
|
|
503
|
+
evt.content?.url &&
|
|
504
|
+
!bindings.some((b) => b.userId === evt.sender)
|
|
505
|
+
) {
|
|
506
|
+
pendingMedia.add(evt.room_id, inboundThreadRoot(evt), {
|
|
507
|
+
eventId: evt.event_id,
|
|
508
|
+
sender: evt.sender,
|
|
509
|
+
msgtype: evt.content.msgtype as string,
|
|
510
|
+
body: (evt.content.body as string | undefined) ?? '',
|
|
511
|
+
filename: evt.content.filename as string | undefined,
|
|
512
|
+
url: evt.content.url as string,
|
|
513
|
+
info: evt.content.info as PendingMediaItem['info'],
|
|
514
|
+
})
|
|
515
|
+
continue
|
|
516
|
+
}
|
|
517
|
+
|
|
318
518
|
// Agent-promotion: top-level inbound event becomes the thread root.
|
|
319
519
|
// For in-thread messages the existing root is preserved.
|
|
320
520
|
const promotedRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
@@ -461,10 +661,33 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
461
661
|
try {
|
|
462
662
|
const rawBody = evt.content?.body ?? ''
|
|
463
663
|
const promptText = stripMention(rawBody, agent.userId)
|
|
664
|
+
|
|
665
|
+
// Drain pending media for this sender+thread and prepend as ACP content blocks.
|
|
666
|
+
const pendingItems = pendingMedia.drain(
|
|
667
|
+
evt.room_id,
|
|
668
|
+
inboundThreadRoot(evt),
|
|
669
|
+
evt.sender ?? '',
|
|
670
|
+
)
|
|
671
|
+
const { blocks, pathLines } = await buildMediaBlocks(pendingItems, {
|
|
672
|
+
agent,
|
|
673
|
+
media: mediaClient,
|
|
674
|
+
writeAttachmentFn,
|
|
675
|
+
onError: (item, err) => {
|
|
676
|
+
console.warn(`[matrix:${agent.name}] media_failed for ${item.body}:`, err)
|
|
677
|
+
void sendMediaError(
|
|
678
|
+
{ agent, roomId: evt.room_id!, threadRoot },
|
|
679
|
+
err,
|
|
680
|
+
`Could not process attachment: ${item.body}`,
|
|
681
|
+
client,
|
|
682
|
+
)
|
|
683
|
+
},
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
const fullPromptText = [promptText, ...pathLines].filter(Boolean).join('\n')
|
|
464
687
|
await agents.prompt(agent.name, {
|
|
465
688
|
threadId: sessionKey,
|
|
466
689
|
channelId: evt.room_id,
|
|
467
|
-
content: [{ type: 'text', text:
|
|
690
|
+
content: [...blocks, { type: 'text', text: fullPromptText }],
|
|
468
691
|
})
|
|
469
692
|
// Drain: the prompt promise resolves on the stopReason response, but
|
|
470
693
|
// trailing chunks may still arrive (see DRAIN_* above). Wait until the
|