Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilAgentRoom.swift
T

523 lines
25 KiB
Swift

import Foundation
import Network
import Observation
@MainActor
@Observable
final class SybilAgentRoom {
struct Snapshot: Equatable {
var room = AgentRoomState()
var isTyping = false
var entranceIDs: Set<String> = []
var revision = 0
}
private(set) var snapshot = Snapshot()
private(set) var connection = "Offline"
private(set) var error: String?
private(set) var isLoading = false
private(set) var requiresReload = false
private(set) var outbox: [AgentOutboxRow] = []
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) }
}
func dismissAttachmentPresentations() {
// Existing picker/share views observe this identity and dismiss themselves.
// Keep draft contents and transcript/view identity intact during routing.
attachmentSessionID = UUID()
}
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 var onAcknowledgedRead: ((AgentReadState) -> Void)?
private enum ReadSourceBinding: Equatable {
case unbound, legacy, source(String)
}
@ObservationIgnored private var readBinding: ReadSourceBinding = .unbound
private var readSourceID: String? {
if case let .source(id) = readBinding { return id }
return nil
}
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
@ObservationIgnored private var client: (any AgentAPIClienting)?
@ObservationIgnored private var storage: AgentOutboxStorage?
@ObservationIgnored private var storageReadable = true
@ObservationIgnored private var pending = AgentRoomState()
@ObservationIgnored private var presenter = AgentPresentation()
@ObservationIgnored private var pendingEntrances: Set<String> = []
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var connectionTask: Task<Void, Never>?
@ObservationIgnored private var maintenanceTask: Task<Void, Never>?
@ObservationIgnored private var batchTask: Task<Void, Never>?
@ObservationIgnored private var typingTask: Task<Void, Never>?
@ObservationIgnored private var readTask: Task<Void, Never>?
@ObservationIgnored private var sends: [String: Task<Void, Never>] = [:]
@ObservationIgnored private var socket: (any AgentSocket)?
@ObservationIgnored private var monitor: NWPathMonitor?
@ObservationIgnored private var lastPacket = Date()
@ObservationIgnored private var socketStarted = Date()
@ObservationIgnored private var desiredRead = "0"
@ObservationIgnored private var acknowledgedRead = "0"
@ObservationIgnored private var hasLoaded = false
@ObservationIgnored private var syncing = false
@ObservationIgnored private var wasNetworkOffline = false
var isBusy: Bool { snapshot.room.activity.isBusy }
var unreadCount: Int { snapshot.room.unreadCount }
var messages: [Message] {
#if DEBUG
AgentRenderDiagnostics.record("messageProjection")
#endif
let committed = Set(snapshot.room.messages.map(\.id))
let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) }
var messages = AgentRoomState.merge(snapshot.room.messages, queued)
for row in outbox {
if let index = messages.firstIndex(where: { $0.id == row.id }) {
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)
}
} else { messages.append(row.message) }
}
return AgentRoomState.merge([], messages)
}
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)
resetHistory()
error = nil; storageReadable = true
do {
outbox = try storage.read().map { row in
var restored = row
if !restored.seenOnServer {
restored.status = "failed"
restored.error = "Delivery was interrupted. Retry to confirm."
}
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)"
}
start()
}
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
batchTask?.cancel(); batchTask = nil
typingTask?.cancel(); typingTask = nil
readTask?.cancel(); readTask = nil
sends.values.forEach { $0.cancel() }; sends = [:]
for index in outbox.indices where !outbox[index].seenOnServer && outbox[index].status == "sending" {
outbox[index].status = "failed"
outbox[index].error = "Delivery was interrupted. Retry to confirm."
}
if !outbox.isEmpty { try? persist() }
monitor?.cancel(); monitor = nil
let old = socket; socket = nil
Task { await old?.close() }
syncing = false
pendingEntrances = []
connection = "Offline"
}
func setActive(_ active: Bool) {
guard isActive != active else { return }
isActive = active
if active { attachmentDraft.resume(); start() }
else {
// Publish successfully merged input before cancelling; replay resumes here.
flush()
stop()
}
}
func retryConnection() {
flush(); stop()
// Explicit Reconnect starts a new history/source boundary. Foreground and
// network recovery must not relabel the preserved old room automatically.
if requiresReload { resetHistory(); error = nil }
if isActive { start() }
}
private func resetHistory() {
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
readBinding = .unbound; requiresReload = false
}
private func requireReload(_ reason: String) -> AgentFailure {
let failure = AgentFailure(message: reason + " Reconnect to load fresh Agent history.")
requiresReload = true
// Preserve already accepted old cursors/history, cancel every old writer,
// and invalidate callbacks before allowing any further source observation.
flush(); stop()
isLoading = false; connection = "Reload required"; error = failure.localizedDescription
return failure
}
private func start() {
guard !requiresReload, isActive, let client, connectionTask == nil else { return }
let run = generation
isLoading = !hasLoaded
connection = "Connecting"
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { [weak self] path in
let satisfied = path.status == .satisfied
Task { @MainActor [weak self] in
guard let self, self.generation == run else { return }
let recovered = self.wasNetworkOffline && satisfied
self.wasNetworkOffline = !satisfied
if recovered && !self.requiresReload { self.retryConnection() }
}
}
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
self.monitor = monitor
connectionTask = Task { [weak self] in
guard let self else { return }
var delay = 1
while !Task.isCancelled, self.generation == run {
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)
guard self.generation == run, !Task.isCancelled else { return }
self.hasLoaded = true; self.isLoading = false; self.flush()
let socket = try await client.agentSocket(after: self.pending.cursor)
guard self.generation == run, !Task.isCancelled else { await socket.close(); return }
self.socket = socket
self.socketStarted = Date(); self.lastPacket = Date()
while !Task.isCancelled, self.generation == run {
let data = try await socket.receive()
guard self.generation == run, !Task.isCancelled else { return }
self.lastPacket = Date()
guard let event = try AgentEvent.decode(data) else { continue }
if case .ready = event { self.connection = "Connected"; self.error = nil; delay = 1 }
try self.receive(event)
}
} catch {
guard self.generation == run, !Task.isCancelled else { return }
let failedSocket = self.socket
let code = await failedSocket?.closeCode()
await failedSocket?.close()
guard self.generation == run, !Task.isCancelled else { return }
self.socket = nil
self.isLoading = false
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 }
}
try? await Task.sleep(for: .seconds(delay))
delay = min(delay * 2, 30)
}
}
maintenanceTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
guard let self, self.generation == run, !Task.isCancelled else { return }
do {
try await self.refreshRead(client: client, run: run)
guard self.generation == run, !Task.isCancelled else { return }
self.flushRead()
if self.connection != "Connected" {
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
}
guard self.generation == run, !Task.isCancelled else { return }
if let socket = self.socket {
if Date().timeIntervalSince(self.lastPacket) > 60 || (self.connection != "Connected" && Date().timeIntervalSince(self.socketStarted) > 12) {
await socket.close()
} else { try await socket.send("{\"type\":\"ping\"}") }
}
} catch {
if self.generation == run { self.error = error.localizedDescription }
}
}
}
}
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
let run = run ?? generation
guard !requiresReload, generation == run, !Task.isCancelled, !syncing else { return }
syncing = true
defer { if generation == run { syncing = false } }
var cursor = pending.cursor
while !Task.isCancelled, generation == run {
let page = try await client.agentMessages(after: cursor, before: nil)
guard generation == run, !Task.isCancelled else { return }
try page.validate()
let next = try AgentCursor.normalize(page.cursor ?? cursor)
guard !AgentCursor.less(next, cursor), !page.hasMore || next != cursor else {
throw AgentFailure(message: "Agent history did not advance its cursor.")
}
try receive(.messages(page), replay: replay)
cursor = next
if !page.hasMore { return }
}
}
func receive(_ event: AgentEvent, replay: Bool = false) throws {
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
let before = pending
var next = pending
switch event {
case let .messages(page):
try next.apply(page)
// History has no source field. Pin even an empty first page before
// publishing it so a later UUID cannot adopt legacy cursor space.
if readBinding == .unbound { readBinding = .legacy }
case let .activity(id, activity):
try next.checkConversation(id)
next.activity = activity
case let .ready(id): if let id { try next.checkConversation(id) }
case let .error(message): throw AgentFailure(message: message)
case let .readState(read): try applyRead(read); return
case .heartbeat: return
}
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
pending = next
if !replay {
let oldIDs = Set(before.messages.map(\.id)).union(outbox.map(\.id))
pendingEntrances.formUnion(next.messages.filter { !oldIDs.contains($0.id) && $0.role != .tool }.map(\.id))
}
reconcileOutbox()
schedulePublish()
}
private func schedulePublish() {
guard batchTask == nil else { return }
batchTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
self?.flush()
}
}
func flush() {
#if DEBUG
AgentRenderDiagnostics.record("snapshotPublish")
#endif
batchTask?.cancel(); batchTask = nil
typingTask?.cancel(); typingTask = nil
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
pendingEntrances = []
if let start = presenter.busySince, !snapshot.isTyping {
let delay = max(0.01, 0.25 - Date().timeIntervalSince(start))
typingTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
self?.flush()
}
}
}
private func refreshRead(client: any AgentAPIClienting, run: UUID) async throws {
let read = try await client.agentReadState()
guard generation == run else { return }
try applyRead(read)
}
func applyRead(_ read: AgentReadState) throws {
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
let cursor = try AgentCursor.normalize(read.cursor)
let incoming: ReadSourceBinding
if let source = read.sourceId {
guard AgentPushPayload.validSource(source) else { throw requireReload("Agent read source is invalid.") }
incoming = .source(source.lowercased())
} else { incoming = .legacy }
guard readBinding == .unbound || readBinding == incoming else {
throw requireReload("Agent read source changed.")
}
readBinding = incoming
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, cursor)
pending.readCursor = acknowledgedRead
schedulePublish()
onAcknowledgedRead?(read)
}
func markRead(renderedCursor: String) {
guard !requiresReload, hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
catch { self.error = error.localizedDescription }
}
private func flushRead() {
guard !requiresReload, readBinding != .unbound, let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
let run = generation
readTask = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.readTask = nil } }
do {
while !self.requiresReload, self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
let captured = self.desiredRead
let read = try await client.agentMarkRead(cursor: captured, sourceID: self.readSourceID)
guard self.generation == run, !Task.isCancelled else { return }
guard let cursor = try? AgentCursor.normalize(read.cursor), !AgentCursor.less(cursor, captured) else {
throw self.requireReload("Agent read state was not acknowledged.")
}
try self.applyRead(read)
}
} catch {
if self.generation == run {
let isHTTPConflict: Bool
if case APIError.httpError(statusCode: 409, message: _) = error { isHTTPConflict = true }
else { isHTTPConflict = (error as? AgentAttachmentIssue)?.statusCode == 409 }
if isHTTPConflict {
_ = self.requireReload("Agent read source changed.")
} else { self.error = error.localizedDescription }
}
}
}
}
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 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 {
guard !requiresReload else { return false }
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(), 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
}
func retry(_ id: String) { dispatch(id) }
private func persist() throws {
guard let storage, storageReadable else { throw AgentFailure(message: "Message storage is unavailable.") }
try storage.write(outbox.filter { !$0.confirmed })
}
private func dispatch(_ id: String) {
guard !requiresReload else { return }
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 }
let row = outbox[index], run = generation
sends[id] = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.sends[id] = nil } }
do {
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"
self.outbox[index].error = nil
try? self.persist()
} catch {
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].seenOnServer else { return }
self.outbox[index].status = "failed"; self.outbox[index].error = error.localizedDescription
try? self.persist()
}
}
}
private func reconcileOutbox() {
let committed = pending.messages
let queued = pending.activity.queuedMessages
var changed = false
for index in outbox.indices {
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(where: matches), !outbox[index].confirmed {
changed = true
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
}
}
if changed { try? persist() }
}
}