Files
Abdul Rahman ef6afd5a20 Let runAgent messages carry file attachments (#25835)
Follow-up to #25695, and the platform half of
https://github.com/twentyhq/core-team-issues/issues/2895. Not Slack
specific: it affects every app that calls `runAgent`.

## Why

The in-app chat already gets files to a model.
`buildFilePartsFromAttachments` validates ids against
`FileFolder.AgentChat`, `loadMessagesFromDB` swaps in a signed URL, and
`convertToModelMessages` emits a file part.

An app cannot reach that path for one reason: `RunAgentMessage` is
`{role, content: string}`, and `agent-async-executor.service.ts` mapped
it straight through. So an app could tell an agent a screenshot exists
and nothing more.

Apps can already write the bytes. `createFileUpload` /
`completeFileUpload` are `@MetadataResolver()` mutations, which is where
the SDK already sends everything, and `permissions.service.ts` has an
application-token branch that resolves the app's default role, so an app
declaring `UPLOAD_FILE` can upload into `agent-chat` today. Only the
message contract was missing.

## What

- `RunAgentMessage` gains an optional `attachments` list of `{fileId,
filename?}`, with a matching GraphQL input.
- `RunAgentAttachmentService` resolves each fileId against uploaded
files in the caller's workspace under `agent-chat`, signs each distinct
file once, and emits `{type: 'file', data, mediaType, filename}` parts
alongside the text. The shape mirrors what `convertToModelMessages`
produces for chat, including for images.
- The executor builds its messages through that service.

No SDK change was needed: `runAgent` forwards the input object and the
mutation does not enumerate input fields.

## Behaviour worth reviewing

- **A message with no attachments still reaches the model as a bare
string**, not a one-element parts array. This path runs for every app
and workflow agent, and widening it must not perturb prompt caching or
model behaviour for callers who never asked for files. There is a test
pinning this.
- **An unresolvable fileId throws** rather than quietly answering
without the file. Chat filters silently, but for an API an app calls, a
dropped attachment that nobody reports is much harder to debug, and a
caller that wants to degrade can catch and retry without it.
- **Attachments on an assistant message throw.** They cannot be
represented in an assistant model message, so accepting and dropping
them would be the same trap.
- Capped at 10 attachments per message and 255 characters of filename,
enforced in the resolution path.

Resolution stays workspace-scoped and folder-scoped, and only `UPLOADED`
files resolve, so a fileId referenced before `completeFileUpload` fails
here rather than producing a URL that 404s inside the provider.

## A pre-existing gap this surfaced

`AgentRunResolver` installs no `ResolverValidationPipe`, so **nothing**
on `RunAgentInput` is validated today, including the existing
`@ArrayMaxSize(100)` on `messages` and `@IsNotEmpty` on `prompt`. That
is why the new limits are enforced in `RunAgentAttachmentService` rather
than by decorators alone.

Adding the pipe here would switch on every dormant decorator at once,
and one of them breaks a shipped caller:
`build-slack-conversation-messages.ts` replays thread history, and an
assistant turn whose text strips to nothing with no files attached
reaches `runAgent` with `content: ''`. That passes today and would start
failing `@IsNotEmpty`. Since the Slack app ships separately from the
server, an old app build against a new server would break. Worth fixing,
with its callers first, but not as a side effect of this PR.

## Testing

- 9 unit tests on the resolver service; full `ai-agent-execution` and
`ai` module suites pass.
- `tsgo --noEmit` clean on twenty-server, twenty-shared and twenty-sdk;
oxlint and oxfmt clean.
- Metadata GraphQL artifacts regenerated against a locally running
server. The schema diff is the new input type; the churn in the client
SDK `types.ts` is index renumbering from inserting it.

Not verified end to end: that an image actually lands in front of a
model. That needs a live run against a real provider. The message shape
matches what chat already sends successfully, but that is inference
rather than observation.

## Next

The Slack consumer is a separate app-package change: declare
`UPLOAD_FILE`, add the `files:read` bot scope (which forces existing
installations to reauthorise), download from `url_private` server-side,
upload, and pass the fileIds here. The names-only path from #25695 stays
as the fallback.
2026-09-15 15:36:27 +00:00
..