273 lines
12 KiB
Swift
273 lines
12 KiB
Swift
import Foundation
|
|
|
|
struct AgentFailure: LocalizedError, Sendable {
|
|
let message: String
|
|
var errorDescription: String? { message }
|
|
}
|
|
|
|
// Compare decimal strings without losing precision through Double or overflowing Int.
|
|
enum AgentCursor {
|
|
static func normalize(_ value: String) throws -> String {
|
|
guard !value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) }) else {
|
|
throw AgentFailure(message: "Invalid Agent cursor.")
|
|
}
|
|
let trimmed = value.drop(while: { $0 == "0" })
|
|
return trimmed.isEmpty ? "0" : String(trimmed)
|
|
}
|
|
static func less(_ a: String, _ b: String) -> Bool {
|
|
a.count == b.count ? a < b : a.count < b.count
|
|
}
|
|
static func advance(_ current: String, _ incoming: String) throws -> String {
|
|
let value = try normalize(incoming)
|
|
return less(current, value) ? value : current
|
|
}
|
|
}
|
|
|
|
struct AgentActivity: Codable, Equatable, Sendable {
|
|
struct Tool: Codable, Equatable, Sendable {
|
|
var id: String
|
|
var name: String
|
|
var status: String
|
|
var output: String?
|
|
}
|
|
struct Wait: Codable, Equatable, Sendable {
|
|
var due: Double
|
|
var reason: String?
|
|
}
|
|
var state: String = "idle"
|
|
var isThinking = false
|
|
var queued = 0
|
|
var backgroundWorkers = 0
|
|
var tools: [Tool] = []
|
|
var queuedMessages: [Message] = []
|
|
var wait: Wait?
|
|
var error: String?
|
|
var isBusy: Bool { isThinking || backgroundWorkers > 0 }
|
|
}
|
|
|
|
struct AgentDescriptor: Decodable, Sendable {
|
|
var id: String
|
|
var title: String
|
|
var type: String
|
|
var model: String
|
|
var `protocol`: String
|
|
var activity: AgentActivity
|
|
var websocket: String
|
|
var capabilities: AgentCapabilities? = nil
|
|
}
|
|
struct AgentPage: Codable, Sendable {
|
|
var conversationId: String
|
|
var messages: [Message]
|
|
var cursor: String?
|
|
var beforeCursor: String?
|
|
var hasMore: Bool
|
|
|
|
func validate() throws {
|
|
guard !conversationId.isEmpty, messages.allSatisfy({ !$0.id.isEmpty }) else {
|
|
throw AgentFailure(message: "Invalid Agent history.")
|
|
}
|
|
if let cursor { _ = try AgentCursor.normalize(cursor) }
|
|
if let beforeCursor { _ = try AgentCursor.normalize(beforeCursor) }
|
|
}
|
|
}
|
|
struct AgentReadState: Codable, Sendable {
|
|
var cursor: String
|
|
var updatedAt: String?
|
|
var sourceId: String? = nil
|
|
}
|
|
struct AgentReceipt: Codable, Sendable {
|
|
var id: Int64
|
|
var status: String
|
|
var messageId: String
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AgentEvent: Sendable {
|
|
case ready(String?)
|
|
case messages(AgentPage)
|
|
case activity(String, AgentActivity)
|
|
case readState(AgentReadState)
|
|
case heartbeat
|
|
case error(String)
|
|
|
|
static func decode(_ data: Data) throws -> AgentEvent? {
|
|
struct Envelope: Decodable {
|
|
var type: String
|
|
var conversationId: String?
|
|
var activity: AgentActivity?
|
|
var error: String?
|
|
}
|
|
let envelope = try AgentCoding.decoder().decode(Envelope.self, from: data)
|
|
switch envelope.type {
|
|
case "ready": return .ready(envelope.conversationId)
|
|
case "messages":
|
|
let page = try AgentCoding.decoder().decode(AgentPage.self, from: data)
|
|
try page.validate()
|
|
return .messages(page)
|
|
case "activity":
|
|
guard let id = envelope.conversationId, let activity = envelope.activity else {
|
|
throw AgentFailure(message: "Invalid Agent activity frame.")
|
|
}
|
|
return .activity(id, activity)
|
|
case "pong", "heartbeat": return .heartbeat
|
|
case "read_state":
|
|
// Additive optional frame: malformed new metadata must not tear down
|
|
// an otherwise compatible Agent connection.
|
|
guard let read = try? AgentCoding.decoder().decode(AgentReadState.self, from: data),
|
|
AgentPushPayload.validCursor(read.cursor) != nil,
|
|
read.sourceId.map(AgentPushPayload.validSource) ?? true else { return nil }
|
|
return .readState(read)
|
|
case "error": return .error(envelope.error ?? "Agent connection error.")
|
|
default: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AgentCoding {
|
|
static func decoder() -> JSONDecoder {
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .custom { decoder in
|
|
let container = try decoder.singleValueContainer()
|
|
let text = try container.decode(String.self)
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = formatter.date(from: text) { return date }
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
guard let date = formatter.date(from: text) else {
|
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid message date")
|
|
}
|
|
return date
|
|
}
|
|
return decoder
|
|
}
|
|
}
|
|
|
|
extension Message {
|
|
var agentEntryCursor: String? {
|
|
let value = metadata?.objectValue?["entryId"]
|
|
if let text = value?.stringValue { return try? AgentCursor.normalize(text) }
|
|
if let number = value?.numberValue, number >= 0, number <= 9_007_199_254_740_991, number.rounded() == number {
|
|
return String(Int64(number))
|
|
}
|
|
return nil
|
|
}
|
|
var isAgentPublicReply: Bool {
|
|
role == .assistant && metadata?.objectValue?["kind"]?.stringValue == "message"
|
|
}
|
|
var agentToolID: String { toolCallMetadata?.toolCallId ?? id }
|
|
}
|
|
|
|
struct AgentRoomState: Equatable, Sendable {
|
|
var conversationID: String?
|
|
var messages: [Message] = []
|
|
var cursor = "0"
|
|
var readCursor = "0"
|
|
var activity = AgentActivity()
|
|
var unreadCount: Int {
|
|
messages.filter { $0.isAgentPublicReply && $0.agentEntryCursor.map { AgentCursor.less(readCursor, $0) } == true }.count
|
|
}
|
|
mutating func checkConversation(_ id: String) throws {
|
|
guard conversationID == nil || conversationID == id else {
|
|
throw AgentFailure(message: "The Agent room changed. Reconnect in Settings to reload it.")
|
|
}
|
|
conversationID = id
|
|
}
|
|
mutating func apply(_ page: AgentPage) throws {
|
|
try page.validate()
|
|
var next = self
|
|
try next.checkConversation(page.conversationId)
|
|
next.cursor = try page.cursor.map { try AgentCursor.advance(cursor, $0) } ?? cursor
|
|
next.messages = Self.merge(messages, page.messages)
|
|
self = next
|
|
}
|
|
static func merge(_ previous: [Message], _ incoming: [Message]) -> [Message] {
|
|
var byID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
|
|
for var message in incoming {
|
|
if let old = byID[message.id] {
|
|
let prior = old.metadata?.objectValue ?? [:]
|
|
let next = message.metadata?.objectValue ?? [:]
|
|
if prior["status"] == .string("complete") && next["status"] == .string("queued") { continue }
|
|
if old.toolCallMetadata != nil, message.toolCallMetadata != nil {
|
|
let stale = ["completed", "failed"].contains(old.toolCallMetadata?.status ?? "") && message.toolCallMetadata?.status == "initiated"
|
|
var merged = prior.merging(next, uniquingKeysWith: { a, b in stale ? a : b })
|
|
if next["startedAt"] == nil || next["startedAt"] == .null { merged["startedAt"] = prior["startedAt"] }
|
|
if let args = prior["args"], next["args"]?.objectValue?.isEmpty != false { merged["args"] = args }
|
|
message.metadata = .object(merged)
|
|
message.createdAt = min(old.createdAt, message.createdAt)
|
|
if stale { message.content = old.content; message.name = old.name }
|
|
}
|
|
}
|
|
byID[message.id] = message
|
|
}
|
|
return byID.values.sorted {
|
|
if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt }
|
|
if let a = $0.agentEntryCursor, let b = $1.agentEntryCursor, a != b { return AgentCursor.less(a, b) }
|
|
return $0.id < $1.id
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter only the transcript projection, before windowing. Keep the room's raw
|
|
// messages and cursors intact for upserts, replay, unread state and activity.
|
|
struct AgentTranscript {
|
|
let messages: [Message]
|
|
let startIndex: Int
|
|
var displayed: [Message] { Array(messages.dropFirst(startIndex)) }
|
|
|
|
init(messages: [Message], startID: String? = nil, limit: Int = 100) {
|
|
let visible = messages.filter {
|
|
$0.role != .tool && $0.metadata?.objectValue?["kind"]?.stringValue != "tool_call"
|
|
}
|
|
self.messages = visible
|
|
startIndex = startID.flatMap { id in visible.firstIndex(where: { $0.id == id }) }
|
|
?? max(0, visible.count - limit)
|
|
}
|
|
}
|
|
|
|
// The transcript latch is independent of the truthful sidebar/worker activity.
|
|
struct AgentPresentation {
|
|
private(set) var suppressed = false
|
|
private(set) var busySince: Date?
|
|
private var seenTools: Set<String> = []
|
|
static let quietTools: Set<String> = ["user_message", "notify_parent", "clock_wait", "event_decision"]
|
|
|
|
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
|
|
switch event {
|
|
case let .messages(page):
|
|
let existing = Set(before.messages.map(\.id))
|
|
let fresh = page.messages.filter { !existing.contains($0.id) && $0.agentEntryCursor.map { AgentCursor.less(before.cursor, $0) } == true }
|
|
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
|
|
if !replay {
|
|
for message in fresh {
|
|
if message.isAgentPublicReply && !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { suppressed = true }
|
|
else if message.role == .user { suppressed = false }
|
|
else if message.toolCallMetadata?.status == "initiated",
|
|
!Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false }
|
|
}
|
|
}
|
|
for message in page.messages where message.role == .tool { seenTools.insert(message.agentToolID) }
|
|
case let .activity(_, activity):
|
|
if !activity.isThinking || activity.tools.contains(where: { !seenTools.contains($0.id) && !Self.quietTools.contains($0.name) }) { suppressed = false }
|
|
seenTools.formUnion(activity.tools.map(\.id))
|
|
default: break
|
|
}
|
|
reconcile(activity: after.activity, now: now)
|
|
}
|
|
mutating func sent(now: Date, activity: AgentActivity) { suppressed = false; reconcile(activity: activity, now: now) }
|
|
private mutating func reconcile(activity: AgentActivity, now: Date) {
|
|
if suppressed || !activity.isBusy { busySince = nil }
|
|
else if busySince == nil { busySince = now }
|
|
}
|
|
func isTyping(now: Date) -> Bool { busySince.map { now.timeIntervalSince($0) >= 0.25 } ?? false }
|
|
}
|