mirror of
https://github.com/twentyhq/twenty.git
synced 2026-09-16 07:56:04 -04:00
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.