ios: add Agent attachments and shared transcript fade
TestFlight / Build and upload (push) Successful in 1m48s

This commit is contained in:
2026-10-14 19:51:27 -07:00
parent 2e62bfa736
commit 80748f764f
17 changed files with 2114 additions and 60 deletions
@@ -1,4 +1,5 @@
import Foundation
import CryptoKit
struct APIConfiguration: Sendable {
var baseURL: URL
@@ -41,10 +42,11 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
}
func agentDescriptor() async throws -> AgentDescriptor {
let value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
var value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
throw AgentFailure(message: "Unsupported Agent room protocol.")
}
if configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { value.capabilities?.attachments = nil }
return value
}
@@ -72,6 +74,72 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
return receipt
}
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
if !submission.attachmentIDs.isEmpty {
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
guard submission.attachmentIDs.count <= limits.countLimit else { throw AgentAttachmentIssue.local("Too many attachments for this Agent.", code: "attachment_limit_exceeded") }
}
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(submission), responseType: AgentReceipt.self)
try receipt.validate(requestID: submission.requestId, attachmentIDs: submission.attachmentIDs)
return receipt
}
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
let prepared = try AgentAttachmentPreparation.prepare(file.data, filename: file.filename, mimeType: file.mimeType, limits: limits)
let multipart = try AgentMultipart(file: prepared, uploadID: uploadID)
var request = try makeRequest(path: "/v1/agent/attachments", method: "POST", body: nil, acceptsSSE: false)
request.timeoutInterval = 120
request.setValue("multipart/form-data; boundary=\(multipart.boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = multipart.body
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: 64 * 1024)
guard status == 200 || status == 201 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
struct UploadResponse: Decodable { var attachment: AgentAttachment }
guard let response = try? JSONDecoder().decode(UploadResponse.self, from: data) else {
throw AgentAttachmentIssue.local("Invalid attachment upload receipt. Retry to confirm.", code: "invalid_upstream_response")
}
let attachment = response.attachment
try attachment.validate()
guard attachment.filename == prepared.filename, attachment.mimeType == prepared.mimeType,
attachment.sizeBytes == prepared.data.count, attachment.sha256 == prepared.sha256 else {
throw AgentAttachmentIssue.local("The upload receipt does not match the selected file.", code: "invalid_upstream_response")
}
return attachment
}
func agentDownload(_ attachment: AgentAttachment) async throws -> Data {
try attachment.validate()
guard configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw AgentAttachmentIssue.unavailable }
var request = try makeRequest(path: "/v1/agent/attachments", method: "GET", body: nil, acceptsSSE: false)
request.url = try configuration.agentAttachmentContentURL(id: attachment.id)
request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: attachment.sizeBytes)
guard status == 200 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
guard data.count == attachment.sizeBytes,
SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() == attachment.sha256 else {
throw AgentAttachmentIssue.local("The downloaded original failed its size or checksum check. Retry the download.", code: "invalid_upstream_response")
}
return data
}
private func attachmentBytes(_ request: URLRequest, maximum: Int) async throws -> (Data, Int, String?) {
let (stream, response) = try await session.bytes(for: request, delegate: AgentAttachmentRedirectPolicy())
defer { stream.task.cancel() }
guard let response = response as? HTTPURLResponse else { throw APIError.invalidResponse }
let success = (200...299).contains(response.statusCode)
let limit = success ? maximum : 64 * 1024
guard response.expectedContentLength <= Int64(limit) else {
throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response")
}
var data = Data()
for try await byte in stream {
try Task.checkCancellation()
guard data.count < limit else { throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response") }
data.append(byte)
}
return (data, response.statusCode, response.value(forHTTPHeaderField: "Retry-After"))
}
func agentSocket(after: String) async throws -> any AgentSocket {
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
task.maximumMessageSize = 8 * 1024 * 1024
@@ -355,7 +423,7 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
(data, response) = try await session.data(for: request, delegate: path.hasPrefix("/v1/agent") ? AgentAttachmentRedirectPolicy() : nil)
} catch {
if let urlError = error as? URLError, urlError.code == .cancelled {
SybilLog.debug(
@@ -369,6 +437,10 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
throw wrapped
}
if path.hasPrefix("/v1/agent"), let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode),
(try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue?["code"]?.stringValue != nil {
throw AgentAttachmentIssue.response(data, status: http.statusCode, retryAfter: http.value(forHTTPHeaderField: "Retry-After"))
}
try validate(response: response, data: data)
do {
@@ -0,0 +1,62 @@
import Foundation
// At most two authenticated downloads per room. Cache only verified bytes, bounded to 16 MiB,
// and replace the entire actor when API configuration changes. No credential-bearing disk cache.
actor AgentAttachmentDownloads {
private var active = 0
private var invalidated = false
private var transfers: [UUID: Task<Data, any Error>] = [:]
private var waiters: [(UUID, CheckedContinuation<Void, any Error>)] = []
private var cache: [AgentAttachment: Data] = [:]
private var order: [AgentAttachment] = []
private var byteCount = 0
func data(for attachment: AgentAttachment, client: any AgentAPIClienting) async throws -> Data {
try Task.checkCancellation()
guard !invalidated else { throw CancellationError() }
if let value = cache[attachment] { return value }
try await acquire()
defer { release() }
try Task.checkCancellation()
if let value = cache[attachment] { return value }
guard !invalidated else { throw CancellationError() }
let id = UUID()
let transfer = Task { try await client.agentDownload(attachment) }
transfers[id] = transfer
defer { transfers[id] = nil }
let value = try await withTaskCancellationHandler { try await transfer.value } onCancel: { transfer.cancel() }
try Task.checkCancellation()
guard !invalidated else { throw CancellationError() }
while byteCount + value.count > 16 * 1024 * 1024, !order.isEmpty {
let oldest = order.removeFirst()
byteCount -= cache.removeValue(forKey: oldest)?.count ?? 0
}
if cache[attachment] == nil { cache[attachment] = value; order.append(attachment); byteCount += value.count }
return value
}
func invalidate() {
invalidated = true
transfers.values.forEach { $0.cancel() }
for waiter in waiters { waiter.1.resume(throwing: CancellationError()) }
waiters = []; cache = [:]; order = []; byteCount = 0
}
private func acquire() async throws {
guard !invalidated else { throw CancellationError() }
if active < 2 { active += 1; return }
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
if Task.isCancelled { continuation.resume(throwing: CancellationError()) }
else { waiters.append((id, continuation)) }
}
} onCancel: { Task { await self.cancel(id) } }
}
private func cancel(_ id: UUID) {
guard let index = waiters.firstIndex(where: { $0.0 == id }) else { return }
waiters.remove(at: index).1.resume(throwing: CancellationError())
}
private func release() {
if waiters.isEmpty { active -= 1 }
else { waiters.removeFirst().1.resume() }
}
}
@@ -0,0 +1,219 @@
import CryptoKit
import Foundation
import Observation
struct AgentDraftItem: Codable, Equatable, Identifiable, Sendable {
enum State: String, Codable, Sendable { case loading, uploading, ready, failed }
var id: String = UUID().uuidString // Stable multipart uploadId, including across relaunch.
var filename: String
var mimeType: String?
var sizeBytes: Int?
var state: State = .loading
var attachment: AgentAttachment?
var issue: AgentAttachmentIssue?
}
struct AgentDraftSnapshot: Codable, Sendable {
var caption = ""
var items: [AgentDraftItem] = []
var id: String? = nil // Optional for drafts saved before durable outbox handoff tracking.
}
struct AgentDraftStorage: Sendable {
var read: @Sendable () throws -> AgentDraftSnapshot
var write: @Sendable (AgentDraftSnapshot) throws -> Void
var stage: @Sendable (String, Data) throws -> Void
var bytes: @Sendable (String) throws -> Data
var remove: @Sendable (String) -> Void
static func file(baseURL: URL, root: URL = .applicationSupportDirectory) -> Self {
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
let directory = root.appendingPathComponent("AgentAttachmentDrafts/\(key)", isDirectory: true)
let manifest = directory.appendingPathComponent("draft.json")
let file: @Sendable (String) throws -> URL = { id in
guard UUID(uuidString: id) != nil else { throw AgentAttachmentIssue.local("Invalid staged upload identity.") }
return directory.appendingPathComponent(id + ".bytes")
}
let write: @Sendable (Data, URL) throws -> Void = { data, destination in
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
}
return Self(read: {
guard FileManager.default.fileExists(atPath: manifest.path) else { return AgentDraftSnapshot() }
let data = try Data(contentsOf: manifest)
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
let snapshot = try JSONDecoder().decode(AgentDraftSnapshot.self, from: data)
guard snapshot.items.count <= 8, Set(snapshot.items.map(\.id)).count == snapshot.items.count,
snapshot.items.allSatisfy({ UUID(uuidString: $0.id) != nil }) else { throw AgentAttachmentIssue.local("The saved attachment draft is invalid.") }
return snapshot
}, write: { snapshot in
let data = try JSONEncoder().encode(snapshot)
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
try write(data, manifest)
}, stage: { id, data in
guard data.count <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
try write(data, file(id))
}, bytes: { id in
let url = try file(id)
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
guard size <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
return try Data(contentsOf: url)
}, remove: { id in if let url = try? file(id) { try? FileManager.default.removeItem(at: url) } })
}
}
@MainActor @Observable
final class AgentAttachmentDraft {
private(set) var id = UUID().uuidString
var caption = "" { didSet { save() } }
private(set) var items: [AgentDraftItem] = []
private(set) var capability: AgentAttachmentCapabilities?
private(set) var error: String?
private(set) var persistenceAvailable = true
@ObservationIgnored private var client: (any AgentAPIClienting)?
@ObservationIgnored private var storage: AgentDraftStorage?
@ObservationIgnored private var prepared: [String: AgentPreparedAttachment] = [:]
@ObservationIgnored private var loaders: [String: @Sendable () async throws -> AgentPreparedAttachment] = [:]
@ObservationIgnored private var worker: Task<Void, Never>?
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var restoring = false
@ObservationIgnored private var active = true
var supportsAttachments: Bool { capability?.supported == true && persistenceAvailable }
var ready: [AgentAttachment] { items.compactMap(\.attachment) }
var canSend: Bool {
caption.utf16.count <= 40_000 && (!caption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !items.isEmpty)
&& (items.isEmpty || (supportsAttachments && items.allSatisfy { $0.state == .ready && $0.attachment != nil }))
&& persistenceAvailable
}
func configure(client: any AgentAPIClienting, storage: AgentDraftStorage?) {
suspend()
self.client = client; self.storage = storage
prepared = [:]; loaders = [:]; capability = nil; error = nil; persistenceAvailable = true
restoring = true
defer { restoring = false; active = true }
do {
let saved = try storage?.read() ?? AgentDraftSnapshot()
id = saved.id ?? UUID().uuidString
caption = saved.caption
items = saved.items.map { item in
var item = item
if item.state != .ready {
item.state = .failed
item.issue = .local("Upload interrupted. Retry with the same upload ID. If selection did not finish, remove and select the file again.", code: "upload_interrupted")
}
return item
}
} catch {
caption = ""; items = []; persistenceAvailable = false
self.error = "Could not restore attachment draft: \(error.localizedDescription)"
}
}
func updateCapability(_ value: AgentAttachmentCapabilities?) {
capability = value?.supported == true ? value : nil
if capability == nil { suspend(); active = true }
}
func resume() { active = true; pump() }
func suspend() {
active = false; generation = UUID(); worker?.cancel(); worker = nil
for index in items.indices where items[index].state == .loading || items[index].state == .uploading {
items[index].state = .failed
items[index].issue = .local("Transfer interrupted. Retry to continue.", code: "upload_interrupted")
}
save()
}
@discardableResult
func add(filename: String, load: @escaping @Sendable () async throws -> AgentPreparedAttachment) -> String? {
guard supportsAttachments, let capability else { error = AgentAttachmentIssue.unavailable.localizedDescription; return nil }
guard items.count < capability.countLimit else { error = "You can attach up to \(capability.countLimit) files."; return nil }
let item = AgentDraftItem(filename: filename)
items.append(item); loaders[item.id] = load; error = nil
save(); pump()
return item.id
}
func remove(_ id: String) {
// Restart the serial worker; completion from the old generation can never reinsert a removed item.
let interrupted = items.filter { $0.state == .loading || $0.state == .uploading }.map(\.id)
generation = UUID(); worker?.cancel(); worker = nil
items.removeAll { $0.id == id }; loaders[id] = nil; prepared[id] = nil
save()
if persistenceAvailable { storage?.remove(id) }
for other in interrupted where other != id {
if let index = items.firstIndex(where: { $0.id == other }) { items[index].state = .loading }
}
pump()
}
func retry(_ id: String) {
guard supportsAttachments else { error = AgentAttachmentIssue.unavailable.localizedDescription; return }
guard let index = items.firstIndex(where: { $0.id == id }), items[index].state == .failed else { return }
items[index].state = .loading; items[index].issue = nil; error = nil
save(); pump()
}
func validateReady() throws -> [AgentAttachment] {
guard items.isEmpty || supportsAttachments else { throw AgentAttachmentIssue.unavailable }
guard items.allSatisfy({ $0.state == .ready && $0.attachment != nil }) else { throw AgentAttachmentIssue.local("Wait for every attachment to finish, or retry/remove failed files.") }
if let capability { try capability.validate(ready) }
return ready
}
@discardableResult func clearAfterSubmission() -> Bool {
guard persistenceAvailable else { return false }
let nextID = UUID().uuidString
do { try storage?.write(AgentDraftSnapshot(id: nextID)) }
catch {
persistenceAvailable = false
self.error = "Message is saved in the outbox, but its draft could not be cleared. Reconnect before retrying: \(error.localizedDescription)"
return false
}
let ids = items.map(\.id)
generation = UUID(); worker?.cancel(); worker = nil
restoring = true; caption = ""; items = []; id = nextID; restoring = false
prepared = [:]; loaders = [:]
ids.forEach { storage?.remove($0) }
return true
}
func save() {
guard !restoring, persistenceAvailable else { return }
do { try storage?.write(AgentDraftSnapshot(caption: caption, items: items, id: id)) }
catch { persistenceAvailable = false; self.error = "Could not save attachment draft: \(error.localizedDescription)" }
}
private func pump() {
guard active, supportsAttachments, let client, let limits = capability, worker == nil,
let item = items.first(where: { $0.state == .loading }) else { return }
let run = generation
worker = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.worker = nil; self.pump() } }
do {
let file: AgentPreparedAttachment
if let existing = self.prepared[item.id] { file = existing }
else if let mime = item.mimeType, let storage = self.storage {
file = try AgentAttachmentPreparation.prepare(storage.bytes(item.id), filename: item.filename, mimeType: mime, limits: limits)
} else if let loader = self.loaders[item.id] { file = try await loader() }
else { throw AgentAttachmentIssue.local("Selection did not finish before the app closed. Remove this item and select it again.", code: "selection_unavailable") }
try Task.checkCancellation()
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
let otherBytes = self.items.filter { $0.id != item.id }.reduce(0) { $0 + ($1.sizeBytes ?? 0) }
guard otherBytes + file.data.count <= limits.totalLimit else { throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded") }
self.prepared[item.id] = file
try self.storage?.stage(item.id, file.data)
self.items[index].filename = file.filename; self.items[index].mimeType = file.mimeType
self.items[index].sizeBytes = file.data.count; self.items[index].state = .uploading
self.save()
guard self.persistenceAvailable else { throw AgentAttachmentIssue.local("Save failed. Reconnect before uploading.") }
let attachment = try await client.agentUpload(file: file, uploadID: item.id)
try attachment.validate()
guard attachment.filename == file.filename, attachment.mimeType == file.mimeType,
attachment.sizeBytes == file.data.count, attachment.sha256 == file.sha256 else {
throw AgentAttachmentIssue.local("Upload receipt does not match the original file. Retry to confirm.", code: "invalid_upstream_response")
}
try Task.checkCancellation()
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
self.items[index].attachment = attachment; self.items[index].state = .ready; self.items[index].issue = nil
self.save()
self.loaders[item.id] = nil; self.prepared[item.id] = nil
// Keep staged bytes until the message is durably handed to the outbox.
} catch {
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
self.items[index].state = .failed; self.items[index].issue = .wrapping(error); self.save()
}
}
}
}
@@ -0,0 +1,239 @@
import Foundation
import ImageIO
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
import UIKit
// FileTransferRepresentation keeps Photo Library imports file-backed and bounded while reading.
// PhotosPicker does not expose the original PHAsset filename without broader library access.
struct AgentPickedPhoto: Transferable, Sendable {
var data: Data
var filename: String?
var mimeType: String?
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(importedContentType: .image) { received in
let url = received.file
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
guard size <= 32 * 1024 * 1024 else { throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded") }
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
try Task.checkCancellation()
guard data.count + chunk.count <= 32 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded")
}
data.append(chunk)
}
let type = CGImageSourceCreateWithData(data as CFData, nil).flatMap { CGImageSourceGetType($0) }.flatMap { UTType($0 as String) }
return Self(data: data, filename: url.lastPathComponent.isEmpty ? nil : url.lastPathComponent, mimeType: type?.preferredMIMEType)
}
}
}
struct AgentAttachmentComposer: View {
@Bindable var room: SybilAgentRoom
@State private var modalID = UUID()
@State private var pickerSession: UUID?
@State private var showChoices = false
@State private var showFiles = false
@State private var showPhotos = false
@State private var photos: [PhotosPickerItem] = []
private var modal: Bool { showChoices || showFiles || showPhotos }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if !room.attachmentDraft.items.isEmpty {
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 8) {
ForEach(room.attachmentDraft.items) { item in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.filename).lineLimit(1)
Button { room.attachmentDraft.remove(item.id) } label: { Image(systemName: "xmark.circle") }
.accessibilityLabel("Remove \(item.filename)")
}
if let attachment = item.attachment {
AgentAttachmentDetails(attachment: attachment)
} else {
Text(item.state == .uploading ? "Uploading…" : item.state == .loading ? "Loading…" : "Upload failed")
.foregroundStyle(SybilTheme.textMuted)
if item.state == .uploading || item.state == .loading { ProgressView().controlSize(.small) }
}
if let issue = item.issue {
Text(issue.localizedDescription).foregroundStyle(SybilTheme.danger).fixedSize(horizontal: false, vertical: true)
Button("Retry upload") { room.attachmentDraft.retry(item.id) }
.accessibilityIdentifier("agent-upload-retry")
}
}
.font(.sybil(.caption)).padding(10).frame(width: 260, alignment: .leading)
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
}
}
}.frame(maxHeight: 190)
}
if let error = room.attachmentDraft.error {
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
}
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
}
if room.attachmentDraft.supportsAttachments {
Button { pickerSession = room.attachmentSessionID; showChoices = true } label: { Label("Attach", systemImage: "paperclip") }
.font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-attach")
.disabled(room.attachmentDraft.items.count >= (room.attachmentDraft.capability?.countLimit ?? 0))
}
}
.confirmationDialog("Add attachment", isPresented: $showChoices) {
Button("Photo Library") { showPhotos = true }
Button("Files") { showFiles = true }
Button("Cancel", role: .cancel) {}
}
.photosPicker(isPresented: $showPhotos, selection: $photos,
maxSelectionCount: max(1, (room.attachmentDraft.capability?.countLimit ?? 1) - room.attachmentDraft.items.count),
selectionBehavior: .ordered, matching: .images)
.fileImporter(isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { return }
switch result {
case let .success(urls):
for url in urls {
room.attachmentDraft.add(filename: url.lastPathComponent) {
try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.file(url, limits: limits) }
}
}
case let .failure(error):
if (error as NSError).code != NSUserCancelledError {
room.attachmentDraft.add(filename: "File selection") { throw AgentAttachmentIssue.wrapping(error) }
}
}
}
.onChange(of: photos) { _, selected in
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { photos = []; return }
for item in selected {
room.attachmentDraft.add(filename: "Photo") {
guard let photo = try await item.loadTransferable(type: AgentPickedPhoto.self) else {
throw AgentAttachmentIssue.local("The selected photo could not be loaded.")
}
return try await AgentAttachmentPreparation.offMain {
try AgentAttachmentPreparation.photo(photo.data, filename: photo.filename, mimeType: photo.mimeType, limits: limits)
}
}
}
photos = []
}
.onChange(of: room.attachmentSessionID) { _, _ in showChoices = false; showFiles = false; showPhotos = false; photos = []; pickerSession = nil }
.onChange(of: modal) { _, value in room.setAttachmentModal(modalID, presented: value) }
.onDisappear { room.setAttachmentModal(modalID, presented: false) }
}
}
struct AgentAttachmentDetails: View {
var attachment: AgentAttachment
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("\(attachment.mimeType) · \(ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file))")
.foregroundStyle(SybilTheme.textMuted)
if let path = attachment.workspacePath {
Button {
UIPasteboard.general.string = path
} label: {
Label(path, systemImage: "doc.on.doc").lineLimit(2).multilineTextAlignment(.leading)
}
.buttonStyle(.plain).accessibilityLabel("Copy workspace path: \(path)")
}
}.font(.sybil(.caption2))
}
}
struct AgentAttachmentCard: View {
var attachment: AgentAttachment
var room: SybilAgentRoom
@Environment(\.scenePhase) private var scenePhase
@State private var image: UIImage?
@State private var text: String?
@State private var issue: String?
@State private var loading = false
@State private var wantsDownload = false
@State private var revision = 0
@State private var exportURL: URL?
@State private var showShare = false
@State private var modalID = UUID()
private var loadKey: String { "\(room.attachmentSessionID):\(attachment.id):\(attachment.sha256):\(revision):\(scenePhase == .active)" }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(attachment.filename).font(.sybil(.footnote, weight: .medium)).lineLimit(2)
AgentAttachmentDetails(attachment: attachment)
if attachment.kind == .image {
ZStack {
SybilTheme.surface
if let image { Image(uiImage: image).resizable().scaledToFit() }
else { Image(systemName: "photo").foregroundStyle(SybilTheme.textMuted) }
}.frame(height: 180).clipped()
}
if let text {
Text(text).font(.system(.caption, design: .monospaced)).lineLimit(6)
.frame(height: 100, alignment: .topLeading).textSelection(.enabled)
}
if loading { ProgressView("Loading original…").font(.sybil(.caption)) }
if let issue { Text(issue).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger) }
HStack {
if exportURL != nil {
Button { showShare = true } label: { Label("Save or share original", systemImage: "square.and.arrow.up") }
} else if !loading {
Button(issue == nil ? "Download original" : "Retry download") { wantsDownload = true; revision += 1 }
.accessibilityIdentifier("agent-download")
}
if loading { Button("Cancel") { wantsDownload = false; revision += 1 } }
}.font(.sybil(.caption))
}
.padding(12).frame(maxWidth: 340, alignment: .leading)
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
.task(id: loadKey) {
// Each view owns its transfer; configuration and foreground changes cancel it.
guard scenePhase == .active, wantsDownload || (attachment.kind == .image && revision == 0) else { loading = false; return }
loading = true; issue = nil
let session = room.attachmentSessionID
do {
let data = try await room.downloadAttachment(attachment)
try Task.checkCancellation()
guard session == room.attachmentSessionID else { return }
if attachment.kind == .image {
let bitmap = try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.raster(data, mime: attachment.mimeType) }
try Task.checkCancellation()
image = UIImage(cgImage: bitmap)
} else if attachment.kind == .text, let decoded = String(data: data, encoding: .utf8), !decoded.contains("\u{0000}") {
text = String(decoded.prefix(1000)) // Plain text only, including HTML/SVG.
}
if wantsDownload {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AgentOriginal-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent(try AgentAttachmentPreparation.filename(attachment.filename))
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
exportURL = destination
}
loading = false
} catch {
if !Task.isCancelled { issue = error.localizedDescription; loading = false }
}
}
.sheet(isPresented: $showShare) {
if let exportURL { AgentOriginalShare(url: exportURL) }
}
.onChange(of: showShare) { _, value in room.setAttachmentModal(modalID, presented: value) }
.onChange(of: room.attachmentSessionID) { _, _ in showShare = false; reset() }
.onDisappear { room.setAttachmentModal(modalID, presented: false); reset() }
}
private func reset() {
image = nil; text = nil
if let exportURL { try? FileManager.default.removeItem(at: exportURL.deletingLastPathComponent()) }
exportURL = nil
}
}
private struct AgentOriginalShare: UIViewControllerRepresentable {
var url: URL
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: [url], applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
@@ -0,0 +1,269 @@
import CryptoKit
import Foundation
import ImageIO
import UniformTypeIdentifiers
import UIKit
struct AgentAttachmentCapabilities: Codable, Equatable, Sendable {
var version: Int
var maxCount: Int
var maxTotalBytes: Int
var maxImageBytes: Int
var maxFileBytes: Int
var imageMimeTypes: [String]
var maxImagePixels: Int
var maxImageDimension: Int
var quotaBytes: Int64? = nil
var usedBytes: Int64? = nil
var remainingBytes: Int64? = nil
var maxConcurrentUploads: Int? = nil
var maxConcurrentDecodes: Int? = nil
var supported: Bool {
version == 1 && maxCount > 0 && maxTotalBytes > 0 && maxImageBytes > 0 && maxFileBytes > 0
&& maxImagePixels > 0 && maxImageDimension > 0
}
var countLimit: Int { min(maxCount, 8) }
var totalLimit: Int { min(maxTotalBytes, 32 * 1024 * 1024) }
var imageLimit: Int { min(maxImageBytes, 6 * 1024 * 1024) }
var fileLimit: Int { min(maxFileBytes, 8 * 1024 * 1024) }
var pixelLimit: Int { min(maxImagePixels, 50_000_000) }
var dimensionLimit: Int { min(maxImageDimension, 16_384) }
func validate(_ attachments: [AgentAttachment]) throws {
guard supported else { throw AgentAttachmentIssue.unavailable }
for attachment in attachments { try attachment.validate() }
guard attachments.count <= countLimit, attachments.reduce(0, { $0 + $1.sizeBytes }) <= totalLimit else {
throw AgentAttachmentIssue.local("Attachments exceed the advertised message limit.", code: "attachment_limit_exceeded")
}
for attachment in attachments {
guard attachment.sizeBytes <= (attachment.kind == .image ? imageLimit : fileLimit),
attachment.kind != .image || imageMimeTypes.contains(attachment.mimeType) else {
throw AgentAttachmentIssue.local("An attachment exceeds the current Agent limits or uses an unavailable image format.", code: "attachment_limit_exceeded")
}
}
}
}
struct AgentCapabilities: Decodable, Sendable {
var attachments: AgentAttachmentCapabilities?
init(attachments: AgentAttachmentCapabilities? = nil) { self.attachments = attachments }
private enum CodingKeys: String, CodingKey { case attachments }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
// Unknown versions or malformed future capabilities must not break text chat.
attachments = try? values.decode(AgentAttachmentCapabilities.self, forKey: .attachments)
}
}
struct AgentAttachment: Codable, Hashable, Identifiable, Sendable {
enum Kind: String, Codable, Sendable { case image, text, file }
var id: String
var kind: Kind
var filename: String
var mimeType: String
var sizeBytes: Int
var sha256: String
var workspacePath: String? = nil
func validate() throws {
guard !id.isEmpty, id.utf8.count <= 1024, !id.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
try AgentAttachmentPreparation.filename(filename) == filename,
sizeBytes >= 0, sizeBytes <= 8 * 1024 * 1024,
sha256.count == 64, sha256.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }),
try AgentAttachmentPreparation.mime(mimeType) == mimeType else {
throw AgentAttachmentIssue.local("The Agent returned an invalid attachment descriptor.", code: "invalid_upstream_response")
}
guard (kind == .image) == AgentAttachmentPreparation.nativeMIMEs.contains(mimeType),
kind != .image || sizeBytes <= 6 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("Invalid image attachment descriptor.", code: "invalid_upstream_response")
}
if let workspacePath {
guard workspacePath.hasPrefix("/"), !workspacePath.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
throw AgentAttachmentIssue.local("The Agent returned an invalid workspace path.", code: "invalid_upstream_response")
}
}
}
var metadataValue: JSONValue {
// All stored fields are JSON-compatible; this also keeps optional future fields separate from ChatAttachment.
(try? JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(self))) ?? .null
}
}
extension Message {
var agentAttachments: [AgentAttachment] {
guard let entries = metadata?.objectValue?["agentAttachments"]?.arrayValue else { return [] }
return entries.compactMap {
guard let data = try? JSONEncoder().encode($0),
let attachment = try? JSONDecoder().decode(AgentAttachment.self, from: data),
(try? attachment.validate()) != nil else { return nil }
return attachment
}
}
}
struct AgentSubmission: Encodable, Sendable {
struct Reference: Encodable, Sendable { let id: String }
let requestId: String
let content: String
let attachments: [Reference]?
init(content: String, requestID: String, attachments: [AgentAttachment] = []) throws {
guard !requestID.isEmpty, requestID.utf16.count <= 256, content.utf16.count <= 40_000,
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty,
attachments.count <= 8, Set(attachments.map(\.id)).count == attachments.count else {
throw AgentAttachmentIssue.local("Enter a message of at most 40,000 characters or attach files.", code: "invalid_attachment")
}
for attachment in attachments { try attachment.validate() }
guard attachments.reduce(0, { $0 + $1.sizeBytes }) <= 32 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded")
}
self.content = content; requestId = requestID
self.attachments = attachments.isEmpty ? nil : attachments.map { Reference(id: $0.id) }
}
var attachmentIDs: [String] { attachments?.map(\.id) ?? [] }
}
struct AgentAttachmentIssue: Error, LocalizedError, Codable, Equatable, Sendable {
var message: String
var code: String
var statusCode: Int?
// Preserve additive quota information without inventing field names.
var details: [String: JSONValue] = [:]
var retryAfter: String? = nil
var errorDescription: String? {
var result = "\(message) [\(code)]"
if code == "attachment_quota_exceeded" {
let numbers = ["requiredBytes", "remainingBytes", "quotaBytes"].compactMap { key -> String? in
guard let number = details[key]?.numberValue, number.isFinite, number >= 0, number < Double(Int64.max) else { return nil }
return "\(key): \(ByteCountFormatter.string(fromByteCount: Int64(number), countStyle: .file))"
}
if !numbers.isEmpty { result += " " + numbers.joined(separator: ", ") }
}
if let retryAfter { result += " Retry-After: \(retryAfter)." }
return result
}
static func local(_ message: String, code: String = "invalid_attachment") -> Self { Self(message: message, code: code) }
static let unavailable = Self.local("Attachments are unavailable on this Agent. Reconnect to check support, or remove the files to send text.", code: "attachments_unavailable")
static func wrapping(_ error: any Error) -> Self {
error as? Self ?? .local(error.localizedDescription, code: "attachment_transfer_failed")
}
static func response(_ data: Data, status: Int, retryAfter: String? = nil) -> Self {
let fields = (try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue ?? [:]
return Self(message: fields["message"]?.stringValue ?? fields["error"]?.stringValue ?? "Attachment request failed (HTTP \(status)).",
code: fields["code"]?.stringValue ?? "attachment_request_failed", statusCode: status, details: fields, retryAfter: retryAfter)
}
}
struct AgentPreparedAttachment: Sendable {
var filename: String
var mimeType: String
var data: Data
var sha256: String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
}
enum AgentAttachmentPreparation {
static func offMain<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
let task = Task.detached(operation: operation)
return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() }
}
static let nativeMIMEs: Set<String> = ["image/png", "image/jpeg", "image/gif", "image/webp"]
static func filename(_ value: String) throws -> String {
let value = value.precomposedStringWithCanonicalMapping
guard !value.isEmpty, value.utf8.count <= 255, value != ".", value != "..",
!value.contains("/"), !value.contains("\\"),
!value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
throw AgentAttachmentIssue.local("The filename must be a safe basename of 1–255 UTF-8 bytes.")
}
return value
}
static func mime(_ value: String) throws -> String {
let value = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
let parts = value.split(separator: "/", omittingEmptySubsequences: false)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~")
guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty && $0.unicodeScalars.allSatisfy(allowed.contains) }), value.utf8.count <= 255 else {
throw AgentAttachmentIssue.local("The selected file has an invalid media type.")
}
return value
}
static func file(_ url: URL, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
let accessed = url.startAccessingSecurityScopedResource()
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
let name = try filename(url.lastPathComponent)
let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType ?? UTType(filenameExtension: url.pathExtension)
let media = try mime(type?.preferredMIMEType ?? "application/octet-stream")
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
try Task.checkCancellation()
guard data.count + chunk.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else {
throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded")
}
data.append(chunk)
}
return try prepare(data, filename: name, mimeType: media, limits: limits)
}
static func prepare(_ data: Data, filename name: String, mimeType: String, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
guard limits.supported else { throw AgentAttachmentIssue.unavailable }
let name = try filename(name), media = try mime(mimeType)
guard data.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else { throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded") }
if nativeMIMEs.contains(media) {
guard limits.imageMimeTypes.contains(media), data.count <= limits.imageLimit else {
throw AgentAttachmentIssue.local("\(name) exceeds the image limit or uses an unavailable image format.", code: "attachment_limit_exceeded")
}
_ = try raster(data, mime: media, pixels: limits.pixelLimit, dimension: limits.dimensionLimit)
}
return AgentPreparedAttachment(filename: name, mimeType: media, data: data)
}
static func raster(_ data: Data, mime: String, pixels: Int = 50_000_000, dimension: Int = 16_384) throws -> CGImage {
guard nativeMIMEs.contains(mime), let source = CGImageSourceCreateWithData(data as CFData, nil),
let type = CGImageSourceGetType(source), UTType(type as String)?.preferredMIMEType == mime,
CGImageSourceGetStatus(source) == .statusComplete,
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
width > 0, height > 0, width <= dimension, height <= dimension, width <= pixels / height,
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 600, kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary) else {
throw AgentAttachmentIssue.local("The image is invalid, has a mismatched media type, or exceeds the dimension/pixel limit.", code: "invalid_image")
}
return image
}
static func photo(_ data: Data, filename originalName: String?, mimeType: String?, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
let media = mimeType ?? "application/octet-stream"
if nativeMIMEs.contains(media) {
return try prepare(data, filename: originalName ?? "photo.\(UTType(mimeType: media)?.preferredFilenameExtension ?? "jpg")", mimeType: media, limits: limits)
}
// Photo Library is an explicit visual-input selection. Convert HEIC/HEIF with bounded decoding.
guard let source = CGImageSourceCreateWithData(data as CFData, nil), let sourceType = CGImageSourceGetType(source),
["public.heic", "public.heif"].contains(sourceType as String), limits.imageMimeTypes.contains("image/jpeg"),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
width > 0, height > 0, width <= limits.dimensionLimit, height <= limits.dimensionLimit, width <= limits.pixelLimit / height,
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: min(2000, limits.dimensionLimit), kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary),
let jpeg = UIImage(cgImage: image).jpegData(compressionQuality: 0.88) else {
throw AgentAttachmentIssue.local("This photo could not be prepared as a supported image. Select its original in Files to upload it as a file.", code: "invalid_image")
}
let safe = try filename(originalName ?? "photo.heic")
let stem = (safe as NSString).deletingPathExtension
return try prepare(jpeg, filename: stem + ".jpg", mimeType: "image/jpeg", limits: limits)
}
}
struct AgentMultipart: Sendable {
let boundary: String
let body: Data
init(file: AgentPreparedAttachment, uploadID: String) throws {
guard UUID(uuidString: uploadID) != nil else { throw AgentAttachmentIssue.local("Invalid upload identity.") }
let name = try AgentAttachmentPreparation.filename(file.filename)
let media = try AgentAttachmentPreparation.mime(file.mimeType)
var boundary: String
repeat { boundary = "Sybil-" + UUID().uuidString } while file.data.range(of: Data(boundary.utf8)) != nil
self.boundary = boundary
let quoted = name.replacingOccurrences(of: "\"", with: "\\\"")
var bytes = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(quoted)\"\r\nContent-Type: \(media)\r\n\r\n".utf8)
bytes.append(file.data)
bytes.append(Data("\r\n--\(boundary)--\r\n".utf8))
guard bytes.count <= 8 * 1024 * 1024 + 64 * 1024 else { throw AgentAttachmentIssue.local("The upload exceeds the request size limit.", code: "attachment_limit_exceeded") }
body = bytes
}
}
@@ -53,6 +53,7 @@ struct AgentDescriptor: Decodable, Sendable {
var `protocol`: String
var activity: AgentActivity
var websocket: String
var capabilities: AgentCapabilities? = nil
}
struct AgentPage: Codable, Sendable {
var conversationId: String
@@ -77,12 +78,16 @@ struct AgentReceipt: Codable, Sendable {
var id: Int64
var status: String
var messageId: String
func validate(requestID: String) throws {
var attachmentIds: [String]? = nil
func validate(requestID: String, attachmentIDs: [String] = []) throws {
guard id > 0, id <= 9_007_199_254_740_991,
["queued", "placed", "done", "unanswered"].contains(status),
messageId == "inbound:sybil:\(requestID)" else {
throw AgentFailure(message: "Invalid Agent receipt. Retry to confirm delivery.")
}
if !attachmentIDs.isEmpty, attachmentIds != attachmentIDs {
throw AgentAttachmentIssue.local("Agent receipt did not confirm the exact ordered attachments. Retry this message to confirm delivery.", code: "invalid_upstream_response")
}
}
}
@@ -9,10 +9,13 @@ struct AgentOutboxRow: Codable, Equatable, Sendable {
var seenOnServer = false
var confirmed = false
var error: String?
var attachments: [AgentAttachment]? = nil // Optional for legacy text-only outboxes.
var draftID: String? = nil // Recovers a crash between outbox persistence and clearing the composer.
var id: String { "inbound:sybil:\(requestID)" }
var message: Message {
var info: [String: JSONValue] = ["kind": .string("message"), "status": .string(status), "requestId": .string(requestID)]
if let error { info["error"] = .string(error) }
if let attachments, !attachments.isEmpty { info["agentAttachments"] = .array(attachments.map(\.metadataValue)) }
return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info))
}
}
@@ -16,12 +16,23 @@ final class SybilAgentRoom {
private(set) var error: String?
private(set) var isLoading = false
private(set) var outbox: [AgentOutboxRow] = []
var composer = ""
let attachmentDraft = AgentAttachmentDraft()
private(set) var attachmentSessionID = UUID()
private var attachmentModals: Set<UUID> = []
var isAttachmentModalPresented: Bool { !attachmentModals.isEmpty }
func setAttachmentModal(_ id: UUID, presented: Bool) {
if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) }
}
var composer: String {
get { attachmentDraft.caption }
set { attachmentDraft.caption = newValue }
}
var isObscured = false
var isModalPresented = false
var isSelected = false
private(set) var isActive = true
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
@ObservationIgnored private var client: (any AgentAPIClienting)?
@ObservationIgnored private var storage: AgentOutboxStorage?
@ObservationIgnored private var storageReadable = true
@@ -56,6 +67,10 @@ final class SybilAgentRoom {
messages[index].createdAt = row.createdAt
if !row.confirmed {
var info = messages[index].metadata?.objectValue ?? [:]
if let attachments = row.attachments, messages[index].agentAttachments.map(\.id) != attachments.map(\.id) {
info["agentAttachments"] = .array(attachments.map(\.metadataValue))
messages[index].content = row.content
}
info["status"] = .string(row.status)
if let error = row.error { info["error"] = .string(error) }
messages[index].metadata = .object(info)
@@ -65,12 +80,17 @@ final class SybilAgentRoom {
return AgentRoomState.merge([], messages)
}
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage) {
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage, draftStorage: AgentDraftStorage? = nil) {
stop()
self.client = client
self.storage = storage
attachmentSessionID = UUID()
let oldDownloads = attachmentDownloads
Task { await oldDownloads.invalidate() }
attachmentDownloads = AgentAttachmentDownloads()
attachmentDraft.configure(client: client, storage: draftStorage)
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false; composer = ""
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
error = nil; storageReadable = true
do {
outbox = try storage.read().map { row in
@@ -81,6 +101,9 @@ final class SybilAgentRoom {
}
return restored
}
if outbox.contains(where: { $0.draftID == attachmentDraft.id }) {
attachmentDraft.clearAfterSubmission()
}
} catch {
outbox = []; storageReadable = false
self.error = "Could not restore pending messages: \(error.localizedDescription)"
@@ -91,9 +114,15 @@ final class SybilAgentRoom {
func disconnect() {
stop()
client = nil
attachmentDraft.updateCapability(nil)
attachmentSessionID = UUID()
let oldDownloads = attachmentDownloads
Task { await oldDownloads.invalidate() }
attachmentDownloads = AgentAttachmentDownloads()
}
func stop() {
attachmentDraft.suspend()
generation = UUID()
connectionTask?.cancel(); connectionTask = nil
maintenanceTask?.cancel(); maintenanceTask = nil
@@ -117,7 +146,7 @@ final class SybilAgentRoom {
func setActive(_ active: Bool) {
guard isActive != active else { return }
isActive = active
if active { start() }
if active { attachmentDraft.resume(); start() }
else {
// Publish successfully merged input before cancelling; replay resumes here.
flush()
@@ -151,6 +180,8 @@ final class SybilAgentRoom {
do {
let descriptor = try await client.agentDescriptor()
guard self.generation == run else { return }
self.attachmentDraft.updateCapability(descriptor.capabilities?.attachments)
self.attachmentDraft.resume()
try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded)
try await self.refreshRead(client: client, run: run)
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
@@ -176,7 +207,11 @@ final class SybilAgentRoom {
guard self.generation == run, !Task.isCancelled else { return }
self.socket = nil
self.isLoading = false
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings." : error.localizedDescription
let attachmentsUnavailable = code == 4503 || (error as? AgentAttachmentIssue)?.code == "attachments_unavailable"
if attachmentsUnavailable { self.attachmentDraft.updateCapability(nil) }
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings."
: code == 4503 ? "Agent attachments are unavailable. The server requires a safely authenticated Agent connection. [attachments_unavailable, 4503]"
: error.localizedDescription
self.connection = code == 4401 ? "Offline" : "Reconnecting"
if code == 4401 { return }
}
@@ -285,7 +320,7 @@ final class SybilAgentRoom {
schedulePublish()
}
func markRead(renderedCursor: String) {
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented else { return }
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
catch { self.error = error.localizedDescription }
}
@@ -309,17 +344,51 @@ final class SybilAgentRoom {
}
}
func downloadAttachment(_ attachment: AgentAttachment) async throws -> Data {
guard let client else { throw AgentAttachmentIssue.unavailable }
let session = attachmentSessionID
let data = try await attachmentDownloads.data(for: attachment, client: client)
try Task.checkCancellation()
guard attachmentSessionID == session else { throw CancellationError() }
return data
}
@discardableResult
func send(_ content: String) -> Bool {
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, content.utf16.count <= 40_000 else {
error = "Enter a message of at most 40,000 characters."; return false
}
func sendComposer() -> Bool {
do {
let attachments = try attachmentDraft.validateReady()
guard attachmentDraft.canSend, send(composer, attachments: attachments, draftID: attachmentDraft.id) else { return false }
return true
} catch { self.error = error.localizedDescription; return false }
}
@discardableResult
func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool {
do {
_ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments)
if !attachments.isEmpty {
guard let limits = attachmentDraft.capability, attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
try limits.validate(attachments)
}
} catch { self.error = error.localizedDescription; return false }
guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false }
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date())
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date(), attachments: attachments.isEmpty ? nil : attachments, draftID: draftID)
outbox.append(row)
do { if !attachments.isEmpty || draftID != nil { try persist() } }
catch {
outbox.removeAll { $0.id == row.id }
self.error = "Could not save message: \(error.localizedDescription)"
return false // Keep the entire draft when durable handoff fails.
}
pendingEntrances.insert(row.id)
presenter.sent(now: Date(), activity: pending.activity)
flush()
if draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() {
outbox[outbox.count - 1].status = "failed"
outbox[outbox.count - 1].error = attachmentDraft.error
try? persist()
return false
}
dispatch(row.id)
return true
}
@@ -330,6 +399,7 @@ final class SybilAgentRoom {
}
private func dispatch(_ id: String) {
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { return }
outbox[index].status = "sending"; outbox[index].error = nil
do { try persist() }
catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return }
@@ -338,8 +408,14 @@ final class SybilAgentRoom {
guard let self else { return }
defer { if self.generation == run { self.sends[id] = nil } }
do {
let receipt = try await client.agentSubmit(content: row.content, requestID: row.requestID)
try receipt.validate(requestID: row.requestID)
let attachments = row.attachments ?? []
if !attachments.isEmpty {
guard let limits = self.attachmentDraft.capability, self.attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
try limits.validate(attachments)
}
let submission = try AgentSubmission(content: row.content, requestID: row.requestID, attachments: attachments)
let receipt = try await client.agentSubmit(submission)
try receipt.validate(requestID: row.requestID, attachmentIDs: submission.attachmentIDs)
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return }
self.outbox[index].seenOnServer = true
self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete"
@@ -353,15 +429,19 @@ final class SybilAgentRoom {
}
}
private func reconcileOutbox() {
let committed = Set(pending.messages.map(\.id))
let queued = Set(pending.activity.queuedMessages.map(\.id))
let committed = pending.messages
let queued = pending.activity.queuedMessages
var changed = false
for index in outbox.indices {
if committed.contains(outbox[index].id) {
let row = outbox[index]
let matches: (Message) -> Bool = { message in
message.id == row.id && ((row.attachments ?? []).isEmpty || message.agentAttachments.map(\.id) == row.attachments?.map(\.id))
}
if committed.contains(where: matches) {
if !outbox[index].confirmed { changed = true }
outbox[index].confirmed = true; outbox[index].seenOnServer = true
outbox[index].status = "complete"; outbox[index].error = nil
} else if queued.contains(outbox[index].id), !outbox[index].confirmed {
} else if queued.contains(where: matches), !outbox[index].confirmed {
changed = true
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
}
@@ -13,6 +13,9 @@ protocol AgentAPIClienting: Sendable {
func agentReadState() async throws -> AgentReadState
func agentMarkRead(cursor: String) async throws -> AgentReadState
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment
func agentDownload(_ attachment: AgentAttachment) async throws -> Data
func agentSocket(after: String) async throws -> any AgentSocket
}
@@ -54,3 +57,34 @@ extension APIConfiguration {
return String(decoding: try JSONEncoder().encode(value), as: UTF8.self)
}
}
// Text-only test/legacy clients fail closed for the new wire path.
extension AgentAPIClienting {
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
guard submission.attachmentIDs.isEmpty else { throw AgentAttachmentIssue.unavailable }
return try await agentSubmit(content: submission.content, requestID: submission.requestId)
}
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment { throw AgentAttachmentIssue.unavailable }
func agentDownload(_ attachment: AgentAttachment) async throws -> Data { throw AgentAttachmentIssue.unavailable }
}
final class AgentAttachmentRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
// Reject every redirect; attachment credentials are valid only for the configured API.
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
completionHandler(nil)
}
}
extension APIConfiguration {
func agentAttachmentContentURL(id: String) throws -> URL {
guard !id.isEmpty, var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
["http", "https"].contains(parts.scheme), parts.host != nil, parts.user == nil, parts.password == nil,
let escaped = id.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")) else { throw APIError.invalidBaseURL }
let base = parts.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
parts.percentEncodedPath = (base.isEmpty ? "" : "/" + base) + "/v1/agent/attachments/" + escaped + "/content"
parts.query = nil; parts.fragment = nil
guard let url = parts.url else { throw APIError.invalidBaseURL }
return url
}
}
@@ -18,8 +18,14 @@ struct SybilAgentView: View {
private let bottomID = "agent-bottom"
private var readKey: String {
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
}
private var visibleReadingAnchor: AgentReadingAnchor? {
rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollMetrics.height }
.min { $0.value.minY < $1.value.minY }
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
}
var body: some View {
let rendered = room.snapshot
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
@@ -32,8 +38,9 @@ struct SybilAgentView: View {
VStack(alignment: .leading, spacing: 15) {
if allMessages.count > displayed.count {
Button("Load earlier messages") {
if let anchor = rowFrames.filter({ $0.value.maxY > 0 }).min(by: { $0.value.minY < $1.value.minY }) {
restoreAnchor = AgentReadingAnchor(id: anchor.key, offset: anchor.value.minY, expectedCount: min(allMessages.count, displayed.count + 100))
if var anchor = visibleReadingAnchor {
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
restoreAnchor = anchor
}
atLatest = false
historyLimit += 100
@@ -52,7 +59,7 @@ struct SybilAgentView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
AgentMessageRow(message: message, retry: { room.retry(message.id) })
AgentMessageRow(message: message, room: room, retry: { room.retry(message.id) })
.background(GeometryReader { geometry in
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
})
@@ -73,7 +80,10 @@ struct SybilAgentView: View {
}
.scrollTargetLayout()
.padding(.horizontal, 16)
.padding(.vertical, 16)
.padding(.bottom, 16)
// Match normal chat: content clears the header, while the
// viewport extends beneath the shared navigation fade.
.padding(.top, 16 + topInset)
.frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity)
}
@@ -90,7 +100,8 @@ struct SybilAgentView: View {
AgentScrollMetrics(offset: geometry.contentOffset.y,
height: geometry.containerSize.height,
contentHeight: geometry.contentSize.height,
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top)
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
visibleBottom: geometry.visibleRect.maxY)
} action: { previous, next in
scrollMetrics = next
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
@@ -100,19 +111,20 @@ struct SybilAgentView: View {
}
}
.onPreferenceChange(AgentRowFrames.self) { frames in
let previousAnchor = rowFrames.filter { $0.value.maxY > 0 && $0.value.minY < scrollMetrics.height }
.min { $0.value.minY < $1.value.minY }
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? previousAnchor : nil)
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
// Restore the exact pixel offset after insertion or text
// reflow. Normalize the saved frames to
// the requested offset so the next layout cannot undo it.
let requested = scrollMetrics.offset + frame.minY - anchor.offset
let target = min(max(requested, -scrollMetrics.topInset), max(-scrollMetrics.topInset, scrollMetrics.contentHeight - scrollMetrics.height + scrollMetrics.bottomInset))
let target = min(max(requested, -scrollMetrics.topInset), scrollMetrics.maximumOffset)
let appliedShift = target - scrollMetrics.offset
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
if abs(appliedShift) > 1 { scrollPosition.scrollTo(y: target) }
if abs(appliedShift) > 1 {
// Geometry reports the raw offset; ScrollPosition uses
// zero at the top inset, including the safe area.
scrollPosition.scrollTo(y: target + scrollMetrics.topInset)
}
restoreAnchor = nil
} else {
rowFrames = frames
@@ -152,16 +164,15 @@ struct SybilAgentView: View {
statusBar
composer
}
.padding(.top, topInset)
.onAppear { room.isSelected = true }
.onDisappear { room.isSelected = false }
.task(id: readKey) {
// This cursor belongs to the rendered snapshot. New network input cancels
// the task, and cannot be acknowledged while still entering the layout.
let cursor = rendered.room.cursor
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active, !room.isLoading else { return }
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active else { return }
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
room.markRead(renderedCursor: cursor)
}
}
@@ -190,20 +201,23 @@ struct SybilAgentView: View {
}
private var canSendComposer: Bool {
!room.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && room.composer.utf16.count <= 40_000
room.attachmentDraft.canSend
}
private var composer: some View {
HStack(alignment: .bottom, spacing: 10) {
SybilPromptField(
text: $room.composer,
focused: $composerFocused,
submit: submitComposer
)
.accessibilityIdentifier("agent-composer")
VStack(alignment: .leading, spacing: 8) {
AgentAttachmentComposer(room: room)
HStack(alignment: .bottom, spacing: 10) {
SybilPromptField(
text: $room.composer,
focused: $composerFocused,
submit: submitComposer
)
.accessibilityIdentifier("agent-composer")
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
.accessibilityIdentifier("agent-send")
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
.accessibilityIdentifier("agent-send")
}
}
.padding(.horizontal, 14)
.padding(.bottom, 12)
@@ -211,8 +225,7 @@ struct SybilAgentView: View {
}
private func submitComposer() {
guard canSendComposer, room.send(room.composer) else { return }
room.composer = ""
guard canSendComposer, room.sendComposer() else { return }
atLatest = true
}
}
@@ -228,6 +241,7 @@ struct AgentAvatar: View {
private struct AgentMessageRow: View {
var message: Message
var room: SybilAgentRoom
var retry: () -> Void
private var own: Bool { message.role == .user }
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
@@ -235,6 +249,9 @@ private struct AgentMessageRow: View {
HStack(alignment: .bottom, spacing: 8) {
if !own { AgentAvatar().padding(.bottom, 23) }
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
ForEach(message.agentAttachments) { attachment in
AgentAttachmentCard(attachment: attachment, room: room)
}
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
HStack(spacing: 6) {
Text(own ? "You" : "Sybil")
@@ -289,7 +306,12 @@ private struct AgentScrollMetrics: Equatable {
var contentHeight: CGFloat = 0
var bottomInset: CGFloat = 0
var topInset: CGFloat = 0
var atBottom: Bool { height > 0 && contentHeight - offset - height + bottomInset <= 24 }
var visibleBottom: CGFloat = 0
// visibleRect includes the viewport extended into the safe area, as well as
// the alignment inset used when a short transcript is pinned to the bottom.
var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom }
var atBottom: Bool { height > 0 && distanceFromBottom <= 24 }
var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) }
}
private struct AgentReadingAnchor {
var id: String
@@ -680,7 +680,7 @@ final class SybilViewModel {
await loadInitialData(using: client)
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
agent.configure(client: agentClient, storage: .file(baseURL: baseURL))
agent.configure(client: agentClient, storage: .file(baseURL: baseURL), draftStorage: .file(baseURL: baseURL))
agent.setActive(isAppActive)
}
startActiveRunPolling()