Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
97f36b9
fix(attachments): @JsonIgnore base64 payload so it is never persisted
ginccc Jul 2, 2026
17d4ca8
fix(attachments): scrub inline base64 from persisted context copies
ginccc Jul 2, 2026
961eb33
refactor(attachments): extract shared AttachmentTextExtractor from Pd…
ginccc Jul 2, 2026
21ae5f3
feat(attachments): add ModelCapabilityService for multimodal gating
ginccc Jul 2, 2026
5a6e540
chore(attachments): align max-body-size with attachment cap + Phase 0…
ginccc Jul 2, 2026
7541849
refactor(attachments): unify on IAttachmentStore with grants + quotas
ginccc Jul 2, 2026
136e6cd
feat(attachments): storageRef extraction branch + secure REST surface
ginccc Jul 2, 2026
b6c90f7
feat(attachments): unified AttachmentForwarder (hybrid PDF, text inli…
ginccc Jul 2, 2026
c72b7f0
test(attachments): raise coverage above 90% instr / 80% branch gate
ginccc Jul 2, 2026
fe7601f
feat(attachments): per-task multimodal overrides + history extract st…
ginccc Jul 3, 2026
3ea2007
feat(attachments): readAttachment tool for multi-turn recall (Phase 4)
ginccc Jul 3, 2026
2ce8e94
feat(attachments): group parity β€” fan-out grants + member injection (…
ginccc Jul 3, 2026
8349459
test(attachments): branch coverage for history-stitching generator
ginccc Jul 3, 2026
282e899
feat(attachments): forwarder metrics + GDPR portability metadata (Pha…
ginccc Jul 3, 2026
0d8c083
test(attachments): UserDataExport record coverage to 100%
ginccc Jul 3, 2026
b7cfffb
fix(attachments): correct two high-severity bugs found in adversarial…
ginccc Jul 3, 2026
1a40952
fix(attachments): harden ContentTypeMatcher against the attachments p…
ginccc Jul 3, 2026
1359273
fix(attachments): address PR #588 automated review findings
ginccc Jul 3, 2026
c767607
refactor(attachments): replace fully-qualified names with imports
ginccc Jul 13, 2026
972c22c
Merge origin/main into feat/multimodal-attachments-completion
ginccc Jul 13, 2026
bdcad29
docs(agents): require simple-name imports over inline FQNs
ginccc Jul 13, 2026
0160f6e
fix(attachments): re-hydrate group attachments on HITL resume
ginccc Jul 13, 2026
2d2e1ee
fix(attachments): address PR #588 review comments
ginccc Jul 13, 2026
c387b9d
chore(config): remove no-op reattachTurns knob
ginccc Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,12 @@ When implementing a new feature, provide:
- Include conversation context in logs
- Use appropriate levels: DEBUG (verbose), INFO (important events), ERROR (failures)

#### Imports

- **Always reference types and annotations by their simple name with a top-level `import`** β€” never inline a fully-qualified name (e.g. write `@Inject IAttachmentStore store;` with the imports, not `@jakarta.inject.Inject ai.labs.eddi.engine.attachments.IAttachmentStore store;`). FQNs in field declarations, method signatures, annotations, and generics hurt readability and are a common review comment.
- The **only** acceptable inline FQN is disambiguating two classes that share a simple name and are both used in the same file β€” and even then, prefer restructuring so only one is imported.
- Don't leave unused imports behind after a refactor; run `./mvnw formatter:format` and `./mvnw validate` (Checkstyle) before committing.

#### Production-Scale Thinking

When designing any new feature, always consider these before finalizing the design:
Expand Down
254 changes: 254 additions & 0 deletions docs/changelog.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package ai.labs.eddi.configs.groups.model;

import ai.labs.eddi.configs.hitl.HitlTimeoutPolicy;
import ai.labs.eddi.engine.memory.model.Attachment;
import com.fasterxml.jackson.annotation.JsonIgnore;

import java.time.Instant;
Expand Down Expand Up @@ -74,6 +75,16 @@ public class GroupConversation {
@JsonIgnore
private transient AgentGroupConfiguration.DynamicAgentConfig dynamicAgentConfig;

/**
* Transient attachments for this discussion. Set at fan-out by
* {@code GroupConversationService} β€” inline files are materialized into the
* blob store (owned by this group conversation) and each member conversation is
* granted access. Not persisted to the transcript document; the blobs live in
* {@code IAttachmentStore} bound to this conversation's id.
*/
@JsonIgnore
private transient List<Attachment> attachments;

Comment thread
ginccc marked this conversation as resolved.
/**
* A single entry in the discussion transcript. Each entry records one agent's
* contribution during a specific phase.
Expand Down Expand Up @@ -326,6 +337,15 @@ public void setDynamicAgentConfig(AgentGroupConfiguration.DynamicAgentConfig dyn
this.dynamicAgentConfig = dynamicAgentConfig;
}

@JsonIgnore
public List<Attachment> getAttachments() {
return attachments;
}

public void setAttachments(List<Attachment> attachments) {
this.attachments = attachments;
}

@JsonIgnore
public boolean isPaused() {
return pausedAt != null;
Expand Down
196 changes: 160 additions & 36 deletions src/main/java/ai/labs/eddi/datastore/mongo/GridFsAttachmentStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@

import ai.labs.eddi.engine.attachments.IAttachmentStore;
import ai.labs.eddi.engine.attachments.MimeValidator;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
import com.mongodb.client.gridfs.GridFSBucket;
import com.mongodb.client.gridfs.GridFSBuckets;
import com.mongodb.client.gridfs.model.GridFSFile;
import com.mongodb.client.gridfs.model.GridFSUploadOptions;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import io.quarkus.arc.DefaultBean;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.bson.types.ObjectId;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
Expand All @@ -24,14 +27,18 @@
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import static ai.labs.eddi.utils.LogSanitizer.sanitize;

/**
* MongoDB GridFS implementation of {@link IAttachmentStore}.
* <p>
* Uses GridFS for storing binary attachments with metadata. Files are stored in
* the {@code attachments} GridFS bucket.
* Files are stored in the {@code attachments} GridFS bucket. The public
* {@code storageRef} is a random UUID kept in the file metadata (not the raw
* ObjectId), so references are unguessable; legacy blobs referenced by their
* plain ObjectId hex still resolve. Access grants are stored as a
* {@code metadata.grants} array and die with the blob.
*
* @since 6.0.0
*/
Expand All @@ -41,15 +48,27 @@ public class GridFsAttachmentStore implements IAttachmentStore {

private static final Logger LOGGER = Logger.getLogger(GridFsAttachmentStore.class);
private static final String BUCKET_NAME = "attachments";
private static final String META_CONVERSATION_ID = "conversationId";
private static final String META_STORAGE_REF = "storageRef";
private static final String META_MIME_TYPE = "mimeType";
private static final String META_GRANTS = "grants";

private final GridFSBucket gridFSBucket;
private final MongoCollection<Document> filesCollection;

@ConfigProperty(name = "eddi.attachments.max-size-bytes", defaultValue = "20971520") // 20 MB
long maxSizeBytes;

@ConfigProperty(name = "eddi.attachments.max-per-conversation", defaultValue = "50")
long maxPerConversation;

@ConfigProperty(name = "eddi.attachments.max-total-bytes-per-conversation", defaultValue = "104857600") // 100 MB
long maxTotalBytesPerConversation;

@Inject
public GridFsAttachmentStore(MongoDatabase database) {
this.gridFSBucket = GridFSBuckets.create(database, BUCKET_NAME);
this.filesCollection = database.getCollection(BUCKET_NAME + ".files");
}

@Override
Expand All @@ -72,23 +91,26 @@ public Attachment store(byte[] bytes, String declaredMime, String filename,
"MIME type mismatch: declared='%s', detected='%s'".formatted(declaredMime, detectedMime));
}

enforceQuota(conversationId, bytes.length);

String resolvedMime = MimeValidator.normalize(declaredMime != null ? declaredMime : detectedMime);
String storageRef = UUID.randomUUID().toString();

Document metadata = new Document()
.append("conversationId", conversationId)
.append(META_CONVERSATION_ID, conversationId)
.append("tenantId", tenantId)
.append("mimeType", resolvedMime)
.append("sizeBytes", (long) bytes.length);
.append(META_MIME_TYPE, resolvedMime)
.append("sizeBytes", (long) bytes.length)
.append(META_STORAGE_REF, storageRef)
.append(META_GRANTS, new ArrayList<String>());

GridFSUploadOptions options = new GridFSUploadOptions()
.metadata(metadata);
GridFSUploadOptions options = new GridFSUploadOptions().metadata(metadata);

ObjectId fileId = gridFSBucket.uploadFromStream(
gridFSBucket.uploadFromStream(
filename != null ? filename : "unnamed",
new ByteArrayInputStream(bytes),
options);

String storageRef = fileId.toHexString();
LOGGER.debugf("Stored attachment '%s' (%s, %d bytes) for conversation '%s' β†’ GridFS %s",
sanitize(filename), resolvedMime, bytes.length, sanitize(conversationId), storageRef);

Expand All @@ -97,37 +119,67 @@ public Attachment store(byte[] bytes, String declaredMime, String filename,

@Override
public byte[] load(String storageRef, String requestingConversationId) throws AttachmentStoreException {
try {
ObjectId fileId = new ObjectId(storageRef);
GridFSFile file = gridFSBucket.find(Filters.eq("_id", fileId)).first();
GridFSFile file = findFileByRef(storageRef);
if (file == null) {
throw new AttachmentNotFoundException("Attachment not found: " + storageRef);
}
authorize(file, requestingConversationId);

if (file == null) {
throw new AttachmentStoreException("Attachment not found: " + storageRef);
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
gridFSBucket.downloadToStream(file.getObjectId(), out);
return out.toByteArray();
}

// Check conversation ownership
Document metadata = file.getMetadata();
if (metadata != null) {
String ownerConv = metadata.getString("conversationId");
if (ownerConv != null && !ownerConv.equals(requestingConversationId)) {
throw new AttachmentStoreException(
"Cross-conversation access denied: attachment belongs to '%s', requested from '%s'"
.formatted(ownerConv, requestingConversationId));
}
}

ByteArrayOutputStream out = new ByteArrayOutputStream();
gridFSBucket.downloadToStream(fileId, out);
return out.toByteArray();
} catch (IllegalArgumentException e) {
throw new AttachmentStoreException("Invalid storage reference: " + storageRef, e);
@Override
public Attachment getMetadata(String storageRef, String requestingConversationId) throws AttachmentStoreException {
GridFSFile file = findFileByRef(storageRef);
if (file == null) {
throw new AttachmentNotFoundException("Attachment not found: " + storageRef);
}
authorize(file, requestingConversationId);

Document metadata = file.getMetadata();
String mime = metadata != null ? metadata.getString(META_MIME_TYPE) : null;
String owner = metadata != null ? metadata.getString(META_CONVERSATION_ID) : null;
String ref = metadata != null && metadata.getString(META_STORAGE_REF) != null
? metadata.getString(META_STORAGE_REF)
: storageRef;
return new Attachment(ref, file.getFilename(),
mime != null ? mime : "application/octet-stream", file.getLength(), owner);
}

@Override
public void grantAccess(String storageRef, String conversationId) throws AttachmentStoreException {
var result = filesCollection.updateOne(refFilter(storageRef),
Updates.addToSet("metadata." + META_GRANTS, conversationId));
if (result.getMatchedCount() == 0) {
throw new AttachmentNotFoundException("Attachment not found: " + storageRef);
}
LOGGER.debugf("Granted conversation '%s' access to attachment %s",
sanitize(conversationId), storageRef);
}

@Override
public boolean delete(String storageRef, String requestingConversationId) throws AttachmentStoreException {
GridFSFile file = findFileByRef(storageRef);
if (file == null) {
return false;
}
Document metadata = file.getMetadata();
String owner = metadata != null ? metadata.getString(META_CONVERSATION_ID) : null;
if (owner != null && !owner.equals(requestingConversationId)) {
throw new AttachmentAccessDeniedException(
"Delete denied: attachment belongs to '%s', requested from '%s'"
.formatted(owner, requestingConversationId));
}
gridFSBucket.delete(file.getObjectId());
return true;
}

@Override
public long deleteByConversation(String conversationId) {
long count = 0;
for (GridFSFile file : gridFSBucket.find(Filters.eq("metadata.conversationId", conversationId))) {
for (GridFSFile file : gridFSBucket.find(Filters.eq("metadata." + META_CONVERSATION_ID, conversationId))) {
gridFSBucket.delete(file.getObjectId());
count++;
}
Expand All @@ -139,16 +191,88 @@ public long deleteByConversation(String conversationId) {

@Override
public List<Attachment> listByConversation(String conversationId) {
return listMatching(Filters.eq("metadata." + META_CONVERSATION_ID, conversationId));
}

@Override
public List<Attachment> listAccessible(String conversationId) {
// Owned by the conversation OR granted to it. Filters.eq on the array field
// matches documents whose grants array contains the value (Mongo semantics).
return listMatching(Filters.or(
Filters.eq("metadata." + META_CONVERSATION_ID, conversationId),
Filters.eq("metadata." + META_GRANTS, conversationId)));
}

private List<Attachment> listMatching(Bson filter) {
List<Attachment> results = new ArrayList<>();
for (GridFSFile file : gridFSBucket.find(Filters.eq("metadata.conversationId", conversationId))) {
for (GridFSFile file : gridFSBucket.find(filter)) {
Document metadata = file.getMetadata();
String ref = metadata != null && metadata.getString(META_STORAGE_REF) != null
? metadata.getString(META_STORAGE_REF)
: file.getObjectId().toHexString();
results.add(new Attachment(
file.getObjectId().toHexString(),
ref,
file.getFilename(),
metadata != null ? metadata.getString("mimeType") : "application/octet-stream",
metadata != null && metadata.getString(META_MIME_TYPE) != null
? metadata.getString(META_MIME_TYPE)
: "application/octet-stream",
file.getLength(),
conversationId));
metadata != null ? metadata.getString(META_CONVERSATION_ID) : null));
}
return results;
}

private void enforceQuota(String conversationId, long incomingBytes) throws AttachmentStoreException {
if (maxPerConversation <= 0 && maxTotalBytesPerConversation <= 0) {
return;
}
long count = 0;
long totalBytes = 0;
for (GridFSFile file : gridFSBucket.find(Filters.eq("metadata." + META_CONVERSATION_ID, conversationId))) {
count++;
totalBytes += file.getLength();
}
if (maxPerConversation > 0 && count >= maxPerConversation) {
throw new AttachmentStoreException(
"Attachment quota exceeded for conversation: %d/%d files. Delete some attachments first."
.formatted(count, maxPerConversation));
}
if (maxTotalBytesPerConversation > 0 && totalBytes + incomingBytes > maxTotalBytesPerConversation) {
throw new AttachmentStoreException(
"Attachment storage quota exceeded for conversation: %d + %d bytes exceeds limit of %d. Delete some attachments first."
.formatted(totalBytes, incomingBytes, maxTotalBytesPerConversation));
}
}

private GridFSFile findFileByRef(String storageRef) {
return gridFSBucket.find(refFilter(storageRef)).first();
}

private Bson refFilter(String storageRef) {
if (ObjectId.isValid(storageRef)) {
// Match the modern UUID metadata ref or a legacy plain-ObjectId ref.
return Filters.or(
Filters.eq("metadata." + META_STORAGE_REF, storageRef),
Filters.eq("_id", new ObjectId(storageRef)));
}
return Filters.eq("metadata." + META_STORAGE_REF, storageRef);
}

private void authorize(GridFSFile file, String requester) throws AttachmentStoreException {
Document metadata = file.getMetadata();
if (metadata == null) {
return; // legacy blobs without metadata remain accessible
}
String owner = metadata.getString(META_CONVERSATION_ID);
if (owner == null || owner.equals(requester)) {
return;
}
List<String> grants = metadata.getList(META_GRANTS, String.class);
if (grants != null && grants.contains(requester)) {
return;
}
throw new AttachmentAccessDeniedException(
"Cross-conversation access denied: attachment belongs to '%s', requested from '%s'"
.formatted(owner, requester));
}
}
Loading
Loading