fix(agent-route): forward file attachments between agents

Before: `send_file(to='parent')` from a sub-agent wrote the bytes to
the sub-agent's own session outbox, but agent-to-agent routing copied
only the content JSON — the target's inbound message referenced
`files: ['x.png']` but the bytes lived in a session directory the
target couldn't mount. Parent agents orchestrating sub-agents (e.g.
Design Team delegating illustration work to an Illustrator sub-agent
on Codex) received file-reference messages with nothing to forward.

Fix: on route, if the source's content has `files`, copy each referenced
file from `<source>/outbox/<src-msg-id>/` to
`<target>/inbox/<a2a-msg-id>/`, and emit `attachments` (the existing
formatter convention — see formatter.ts:223) with `localPath` relative
to `/workspace/`. The target formatter already renders these as
`[file: <name> — saved to /workspace/inbox/<a2a-id>/<name>]`, so the
target agent sees the path and can call `send_file(path=…, to=…)` to
forward onward.

Convention matches what session-manager.ts:256 already does for
base64-encoded channel-inbound attachments — same inbox layout, same
content shape. Nothing on the formatter/agent side needed to change.

## Scope

- `forwardAttachedFiles(source, target)` — pure-ish helper that copies
  files and returns the attachments array.
- `forwardFileAttachments(msg, …)` — wraps the helper for the route
  path: parses content, copies files if present, merges into any
  existing `attachments`, re-serialises.
- `routeAgentMessage` — uses the rewritten content when writing the
  target's inbound row.
- Log line now includes `forwardedFileCount` for observability.

Missing source files are skipped with a warning rather than killing
the route — a bad filename in a batch shouldn't drop the
accompanying text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adam
2026-04-24 15:15:33 +10:00
parent 81ef193e69
commit 672e228876

View File

@@ -3,9 +3,13 @@
*
* Outbound messages with `channel_type === 'agent'` target another agent
* group rather than a channel. Permission is enforced via `agent_destinations` —
* the source agent must have a row for the target. Content is copied verbatim;
* the target's formatter looks up the source agent in its own local map to
* display a name.
* the source agent must have a row for the target. Content is copied into the
* target's inbound DB; if the source message had `files` (from `send_file`),
* the actual bytes are copied from the source's outbox into the target's
* `inbox/<a2a-msg-id>/` directory and surfaced to the target agent as
* `attachments` (existing formatter convention — see formatter.ts:230).
* The target agent can then forward the file onward via its own `send_file`
* call using the absolute `/workspace/inbox/<a2a-msg-id>/<filename>` path.
*
* Self-messages are always allowed (used for system notes injected back into
* an agent's own session, e.g. post-approval follow-up prompts).
@@ -14,14 +18,75 @@
* `channel_type === 'agent'` check. When the module is absent the check in
* core throws with a "module not installed" message so retry → mark failed.
*/
import fs from 'fs';
import path from 'path';
import { getAgentGroup } from '../../db/agent-groups.js';
import { getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.js';
import { log } from '../../log.js';
import { resolveSession, writeSessionMessage } from '../../session-manager.js';
import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
import type { Session } from '../../types.js';
import { hasDestination } from './db/agent-destinations.js';
export interface ForwardedAttachment {
name: string;
filename: string;
type: 'file';
localPath: string;
}
/**
* Copy file attachments from the source agent's outbox into the target
* agent's inbox. Returns attachments using the formatter's existing
* `{name, type, localPath}` convention — target agent reads `localPath`
* as relative to `/workspace/`, matching how channel-inbound attachments
* are surfaced today.
*
* Missing source files are skipped with a warning rather than failing
* the whole route — a bad filename reference shouldn't kill the
* accompanying text.
*/
export function forwardAttachedFiles(
source: { agentGroupId: string; sessionId: string; messageId: string; filenames: string[] },
target: { agentGroupId: string; sessionId: string; messageId: string },
): ForwardedAttachment[] {
if (source.filenames.length === 0) return [];
const sourceDir = path.join(sessionDir(source.agentGroupId, source.sessionId), 'outbox', source.messageId);
if (!fs.existsSync(sourceDir)) {
log.warn('agent-route: source outbox dir missing, no files forwarded', {
sourceMsgId: source.messageId,
sourceDir,
});
return [];
}
const targetInboxDir = path.join(sessionDir(target.agentGroupId, target.sessionId), 'inbox', target.messageId);
fs.mkdirSync(targetInboxDir, { recursive: true });
const attachments: ForwardedAttachment[] = [];
for (const filename of source.filenames) {
const src = path.join(sourceDir, filename);
if (!fs.existsSync(src)) {
log.warn('agent-route: referenced file missing in source outbox, skipped', {
sourceMsgId: source.messageId,
filename,
});
continue;
}
const dst = path.join(targetInboxDir, filename);
fs.copyFileSync(src, dst);
attachments.push({
name: filename,
filename,
type: 'file',
localPath: `inbox/${target.messageId}/${filename}`,
});
}
return attachments;
}
export interface RoutableAgentMessage {
id: string;
platform_id: string | null;
@@ -45,20 +110,87 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
}
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// If the source message references files (via `send_file`), forward the
// bytes from the source's outbox into the target's inbox so the target
// agent can actually see and re-send them. Without this, agent-to-agent
// file attachments look like they arrive but the target has no way to
// read the bytes — they live in a session dir it doesn't mount.
const forwardedContent = forwardFileAttachments(msg, a2aMsgId, session, targetAgentGroupId, targetSession.id);
writeSessionMessage(targetAgentGroupId, targetSession.id, {
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
id: a2aMsgId,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: session.agent_group_id,
channelType: 'agent',
threadId: null,
content: msg.content,
content: forwardedContent,
});
log.info('Agent message routed', {
from: session.agent_group_id,
to: targetAgentGroupId,
targetSession: targetSession.id,
a2aMsgId,
forwardedFileCount: countForwardedFiles(forwardedContent),
});
const fresh = getSession(targetSession.id);
if (fresh) await wakeContainer(fresh);
}
/**
* Parse source content, copy any referenced `files` from source outbox to
* target inbox, and return a JSON string with an `attachments` array added
* (formatter.ts:223 already knows how to render this shape).
*
* If the source content isn't JSON or has no files, returns the original
* content string unchanged — this is safe to call on every route.
*/
function forwardFileAttachments(
msg: RoutableAgentMessage,
a2aMsgId: string,
sourceSession: Session,
targetAgentGroupId: string,
targetSessionId: string,
): string {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(msg.content);
} catch {
return msg.content;
}
const files = parsed.files as unknown;
if (!Array.isArray(files) || files.length === 0) return msg.content;
const filenames = files.filter((f): f is string => typeof f === 'string');
if (filenames.length === 0) return msg.content;
const attachments = forwardAttachedFiles(
{
agentGroupId: sourceSession.agent_group_id,
sessionId: sourceSession.id,
messageId: msg.id,
filenames,
},
{
agentGroupId: targetAgentGroupId,
sessionId: targetSessionId,
messageId: a2aMsgId,
},
);
// Merge into any existing `attachments` (unlikely in a2a context but safe).
const existing = Array.isArray(parsed.attachments) ? (parsed.attachments as Record<string, unknown>[]) : [];
parsed.attachments = [...existing, ...attachments];
return JSON.stringify(parsed);
}
function countForwardedFiles(contentStr: string): number {
try {
const parsed = JSON.parse(contentStr);
return Array.isArray(parsed.attachments) ? parsed.attachments.length : 0;
} catch {
return 0;
}
}