From a88bbd9496574ce791adda1db13a095ab896c11c Mon Sep 17 00:00:00 2001 From: MasterYoav Date: Sun, 27 Sep 2026 09:01:38 +0300 Subject: [PATCH] Conversations are kept on this Mac, with no CopilotKit account. (ADR-0008) No Intelligence key was available to build or verify against, so the engine's local mode was built out instead: LocalThreadRunner wraps the vendor's InMemoryAgentRunner, persists threads to a local_threads table (migration 0026), and hydrates them on boot. copilot.ts, routines and handoff all run through it when no INTELLIGENCE_* variables are set. The Mac app loses every path to a CopilotKit key: the credential store, the Settings section, the onboarding field and the composer block. Onboarding now says conversations stay on this Mac, asserted by tests. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + apps/mac/Sources/XBotCore/AppState.swift | 56 +- .../XBotCore/ConversationStoreSettings.swift | 76 - .../Sources/XBotCore/EngineBootstrap.swift | 48 +- .../IntelligenceCredentialStore.swift | 99 - .../Sources/XBotEngine/HTTPEngineClient.swift | 15 +- apps/mac/Sources/XBotEngine/Models.swift | 22 - .../Sources/XBotEngine/WireTranscript.swift | 10 +- .../OnboardingCoordinator.swift | 34 +- .../Steps/ConnectModelStep.swift | 41 +- .../XBotOnboarding/Steps/WelcomeStep.swift | 16 +- .../XBotUI/Conversation/Conversation.swift | 15 - .../XBotUI/Settings/ModelsSettingsView.swift | 106 - .../ConversationRecoveryTests.swift | 18 +- .../ConversationStoreSettingsTests.swift | 104 - .../XBotCoreTests/EngineIdleStopTests.swift | 10 +- .../IntelligenceCredentialTests.swift | 111 - .../XBotCoreTests/RuntimeConnectedTests.swift | 5 +- .../HTTPEngineClientTests.swift | 4 +- .../XBotEngineTests/LiveEngineTests.swift | 10 +- .../XBotEngineTests/WireTranscriptTests.swift | 13 + .../XBotOnboardingTests/DisclosureTests.swift | 18 +- docs/01-vision.md | 32 +- docs/12-roadmap.md | 109 +- docs/13-launch-checklist.md | 41 +- .../0007-wrap-openbot-keep-intelligence.md | 13 +- docs/decisions/0008-local-thread-runner.md | 128 + engine/server/drizzle.config.ts | 1 + engine/server/drizzle/0026_local_threads.sql | 7 + engine/server/drizzle/meta/0026_snapshot.json | 3065 +++++++++++++++++ engine/server/drizzle/meta/_journal.json | 7 + engine/server/src/copilot.ts | 242 +- engine/server/src/db/schema/history.ts | 23 + engine/server/src/db/schema/index.ts | 1 + .../server/src/history/local-intelligence.ts | 5 + .../server/src/history/local-thread-runner.ts | 145 + engine/server/src/index.ts | 42 +- .../local-thread-runner.integration.test.ts | 191 + 38 files changed, 3956 insertions(+), 928 deletions(-) delete mode 100644 apps/mac/Sources/XBotCore/ConversationStoreSettings.swift delete mode 100644 apps/mac/Sources/XBotCore/IntelligenceCredentialStore.swift delete mode 100644 apps/mac/Tests/XBotCoreTests/ConversationStoreSettingsTests.swift delete mode 100644 apps/mac/Tests/XBotCoreTests/IntelligenceCredentialTests.swift create mode 100644 docs/decisions/0008-local-thread-runner.md create mode 100644 engine/server/drizzle/0026_local_threads.sql create mode 100644 engine/server/drizzle/meta/0026_snapshot.json create mode 100644 engine/server/src/db/schema/history.ts create mode 100644 engine/server/src/history/local-thread-runner.ts create mode 100644 engine/server/tests/local-thread-runner.integration.test.ts diff --git a/.gitignore b/.gitignore index 7aacecd..5db261b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ Icon? # Tool caches .impeccable/ +.tokensave diff --git a/apps/mac/Sources/XBotCore/AppState.swift b/apps/mac/Sources/XBotCore/AppState.swift index 17f70cf..4f412cf 100644 --- a/apps/mac/Sources/XBotCore/AppState.swift +++ b/apps/mac/Sources/XBotCore/AppState.swift @@ -263,10 +263,8 @@ public final class AppState { public init( engine: any EngineClient, providers: ProviderConnectionStore = .shared, - appUpdates: any AppUpdateControlling = DisabledAppUpdateController.shared, - conversationStore: @escaping @Sendable () -> ConversationStore = { .ready } + appUpdates: any AppUpdateControlling = DisabledAppUpdateController.shared ) { - self.conversationStore = conversationStore self.engine = engine self.runtime = nil self.environmentFactory = nil @@ -294,8 +292,6 @@ public final class AppState { /// Injected so a test gets its own preference domain. See `ProviderConnectionStore`. providers: ProviderConnectionStore = .shared, appUpdates: any AppUpdateControlling = DisabledAppUpdateController.shared, - /// Defaults to the real credential, because this initializer is the production one. - conversationStore: @escaping @Sendable () -> ConversationStore = { EngineBootstrap.conversationStore }, /// Whether opening the app should bring the engine up. False by default so a test's answer /// never depends on whether this Mac has finished onboarding; the app passes the real one. startsEngineOnLaunch: @escaping @Sendable () -> Bool = { false }, @@ -306,7 +302,6 @@ public final class AppState { ModelKeySync.fingerprint(of: $0, keyEncryptionKey: "test") } ) { - self.conversationStore = conversationStore self.startsEngineOnLaunch = startsEngineOnLaunch self.modelKeys = modelKeys self.modelKeyFingerprint = modelKeyFingerprint @@ -332,12 +327,6 @@ public final class AppState { /// Endpoints the person added by hand, which the agent picker offers alongside the vendors. private let customProviders = CustomProviderStore.shared - /// Whether the engine can keep a conversation at all. - /// - /// Injected rather than read from the Keychain here, for the reason `ProviderConnectionStore` - /// documents: a state object that reaches for a machine-wide store cannot be asserted without - /// the machine's contents deciding the answer. - private let conversationStore: @Sendable () -> ConversationStore private var startsEngineOnLaunch: @Sendable () -> Bool = { false } private var modelKeys: @Sendable () throws -> [DesiredModelKey] = { [] } private var modelKeyFingerprint: @Sendable (String) throws -> String = { @@ -504,26 +493,9 @@ public final class AppState { engineBaseURL = endpoint.baseURL pinnedEngineImage = await runtime?.currentImageReference.full engineHealth = await runtime?.checkHealth() - if providers.requiresModelForComposer() { - composerBlock = .noModelConnected - } else { - /* - * The engine is up and may not be able to hold a conversation. - * - * Without a CopilotKit key it boots into local mode, where the history client - * throws past wiring — but it still starts, still answers `/health`, and still - * lists agents, so every other signal in the app says everything is fine. Saying so - * here is invariant 7: never an empty state that implies all is well. - * - * Two ways to not have one, and they need opposite sentences: nobody connected a - * key, or the Keychain would not hand over the key that is there. - */ - composerBlock = switch conversationStore() { - case .ready: nil - case .notConnected: .noConversationStore - case .unreadable: .conversationStoreUnreadable - } - } + // Conversations are kept by the engine itself (ADR-0008), so a model is the one thing a + // running engine can still be missing. + composerBlock = providers.requiresModelForComposer() ? .noModelConnected : nil pausedWhenIdle = false noteEngineActivity() // Before anything can run: a message that woke the engine is about to be answered, and @@ -785,7 +757,7 @@ public final class AppState { guard let (message, channel) = messageThatWokeTheEngine else { return } messageThatWokeTheEngine = nil guard composerBlock == nil, modelKeySyncProblem == nil, !isSyncingModelKeys else { - // It woke into something that needs the person — no model, no CopilotKit key. The + // It woke into something that needs the person — no model, say. The // message is kept and made retryable, carrying the same sentence the composer shows. let reason = composerBlock?.sentence ?? modelKeySyncProblem ?? String(localized: "Couldn't send that message.") mark(message.id, as: .failed(reason: reason), in: channel) @@ -928,16 +900,6 @@ public final class AppState { } try? EngineTokenStore.remove() try? KeyEncryptionKeyStore.remove() - /* - * The CopilotKit key and its licence token, which this list used to miss. - * - * The comment above promises every key this app has written, and the one real third-party - * credential among them was not on it — so after Uninstall, the person's CopilotKit key sat - * on in the login Keychain for an app that was gone. Both are removed now; the token is - * generated per install and meaningless without the app, the key is theirs. - */ - try? IntelligenceCredentialStore.removeAPIKey() - try? IntelligenceCredentialStore.removeLicenseToken() EngineUpdateCheckStore.reset() AppUpdateCheckStore.reset() @@ -971,13 +933,9 @@ public final class AppState { startEngine() case .humanHoldsControl: setControl(.agent) - case .noModelConnected, .noConversationStore: - // Both are fixed in the same place, and settings are in this window now. + case .noModelConnected: + // Settings are in this window now. isShowingSettings = true - case .conversationStoreUnreadable: - // Nothing to open — the key is already there. Ask the Keychain again, which is what a - // locked one or a dismissed prompt needs, and let the engine come back up with it. - startEngine() case .runtimeUnavailable, nil: // Runtime install is M6. break diff --git a/apps/mac/Sources/XBotCore/ConversationStoreSettings.swift b/apps/mac/Sources/XBotCore/ConversationStoreSettings.swift deleted file mode 100644 index 79dbf9b..0000000 --- a/apps/mac/Sources/XBotCore/ConversationStoreSettings.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import Observation - -/// Settings → Models, the CopilotKit section: the one key that is not a model's. -/// -/// It had no home. The key is collected once during onboarding and was then unreachable — nowhere -/// to see it, replace it when it expires, or take it back. The only route left was Keychain Access, -/// which is the terminal-shaped hole invariant 1 exists to close. -/// -/// It also made an existing button a dead end: without a key the composer says "Connect a -/// CopilotKit key so xBot can keep your conversations" and offers **Open Settings**, and Settings -/// had no field to connect one in. A person following that instruction arrived nowhere. -@MainActor -@Observable -public final class ConversationStoreSettings { - public private(set) var state: ConversationStore = .notConnected - public private(set) var problem: String? - - /// Injected so a test is not deciding against this machine's own Keychain — the same reason - /// `ProviderConnectionStore` is injected. - private let read: @Sendable () -> ConversationStore - private let write: @Sendable (String) throws -> Void - private let clear: @Sendable () throws -> Void - - public init( - read: @escaping @Sendable () -> ConversationStore = { EngineBootstrap.conversationStore }, - write: @escaping @Sendable (String) throws -> Void = { - try IntelligenceCredentialStore.save(apiKey: $0) - }, - clear: @escaping @Sendable () throws -> Void = { - try IntelligenceCredentialStore.removeAPIKey() - } - ) { - self.read = read - self.write = write - self.clear = clear - } - - public func load() { - state = read() - // An unreadable key is a Keychain problem, and the section says so rather than offering to - // connect a key that is already there. - problem = state == .unreadable - ? String(localized: "The Keychain wouldn't hand over your key. It may be locked.") - : nil - } - - public func connect(_ key: String) { - let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - do { - try write(trimmed) - problem = nil - } catch { - // Never silently. A key the person pasted and that did not save is the worst outcome: - // they believe it is connected and the engine keeps booting without it. - problem = String(localized: "That key couldn't be saved to your Keychain.") - } - state = read() - } - - public func disconnect() { - do { - try clear() - problem = nil - } catch { - problem = String(localized: "That key couldn't be removed from your Keychain.") - } - state = read() - } - - /// What the section says about itself. The engine has to be restarted for a change to land, - /// because the four variables are read at container start — so say that rather than letting - /// somebody paste a key and wonder why the next message still fails. - public var needsEngineRestart: Bool { state == .ready } -} diff --git a/apps/mac/Sources/XBotCore/EngineBootstrap.swift b/apps/mac/Sources/XBotCore/EngineBootstrap.swift index 9d40177..1d1d9ed 100644 --- a/apps/mac/Sources/XBotCore/EngineBootstrap.swift +++ b/apps/mac/Sources/XBotCore/EngineBootstrap.swift @@ -12,19 +12,16 @@ public enum EngineBootstrap { /// Environment block the container receives. Port and host gateway vary per start. public static func environmentFactory( keyEncryptionKey: @escaping @Sendable () -> String = { (try? KeyEncryptionKeyStore.key()) ?? "" }, - engineToken: @escaping @Sendable () -> String = { (try? EngineTokenStore.token()) ?? "" }, - intelligence: @escaping @Sendable () -> EngineEnvironment.Intelligence? = { IntelligenceCredentialStore.settings() } + engineToken: @escaping @Sendable () -> String = { (try? EngineTokenStore.token()) ?? "" } ) -> @Sendable (UInt16, String) -> [String: String] { let physicalMemory = ProcessInfo.processInfo.physicalMemory - /* - * Read at each start, never captured when the closure is built. - * - * The app builds this closure before onboarding has collected anything, so capturing the - * keys here handed the engine whatever the Keychain held at launch — on a first run, nothing. - * The CopilotKit key in particular: nil means the engine boots into local mode, whose client - * throws past wiring, and that is why `conversationStore` exists — so the app can say so - * rather than let a conversation fail with nothing to read. - */ + // Read at each start, never captured when the closure is built: the app builds this + // closure before onboarding has collected anything, so capturing the keys here would hand + // the engine whatever the Keychain held at launch — on a first run, nothing. + // + // No `intelligence` is ever passed: since ADR-0008 the engine keeps conversations itself + // (`LocalThreadRunner`), so the four INTELLIGENCE_* variables stay unset and the engine + // picks local history on its own. See `EngineEnvironment.Inputs` if that ever changes. return { port, hostGateway in EngineEnvironment.compose( EngineEnvironment.Inputs( @@ -32,7 +29,6 @@ public enum EngineBootstrap { keyEncryptionKey: keyEncryptionKey(), hostGateway: hostGateway, appOrigin: "xbot://app", - intelligence: intelligence(), maxBrowsers: EngineEnvironment.browserLimit(forPhysicalMemory: physicalMemory), engineToken: engineToken() ) @@ -50,32 +46,4 @@ public enum EngineBootstrap { } ) } - - /// Whether conversations can work at all. - /// - /// ADR-0007 keeps CopilotKit Intelligence for v1, so without its key `runtimeCapabilities()` - /// picks local mode and `LocalIntelligence` — a spike that throws past wiring. An engine in that - /// state starts and answers `/health` and looks entirely well, which is exactly why the app has - /// to check rather than wait for a turn to fail — and three states rather than a Bool, because a - /// key nobody connected and a key the Keychain will not hand over need opposite sentences. - public static var conversationStore: ConversationStore { - switch IntelligenceCredentialStore.availability() { - case .connected: .ready - case .notConnected: .notConnected - case .unreadable: .unreadable - } - } - - -} - -/// Whether the engine can keep a conversation, and if not, why not. -/// -/// Its own type rather than a `Bool` because the two ways of not having a key need opposite -/// sentences: nobody connected one, or the Keychain would not hand over the one that is there. A -/// Bool told the second person to go and connect a key they could see in Settings. -public enum ConversationStore: Sendable, Equatable { - case ready - case notConnected - case unreadable } diff --git a/apps/mac/Sources/XBotCore/IntelligenceCredentialStore.swift b/apps/mac/Sources/XBotCore/IntelligenceCredentialStore.swift deleted file mode 100644 index d71eabe..0000000 --- a/apps/mac/Sources/XBotCore/IntelligenceCredentialStore.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation -import XBotRuntime - -/// The CopilotKit Intelligence credentials the engine needs to keep history. -/// -/// ADR-0007 keeps Intelligence for v1: "The app ships with an Intelligence key configured at -/// onboarding alongside the model key." Without it `runtimeCapabilities()` selects local mode, -/// whose client is a spike that throws on everything past wiring — so a conversation cannot work. -/// -/// Four variables are required and only one of them needs a person: -/// -/// - the two URLs are CopilotKit's own endpoints and are constants here, because asking somebody to -/// type a service's address is asking them to get it wrong, -/// - `COPILOTKIT_LICENSE_TOKEN` is **not a licence gate**. ADR-0007 measured it: "runtime.mjs stores -/// it and derives a telemetry id from it. Nothing validates it." Upstream's own instructions get -/// one from `npx copilotkit license`, which would put a terminal in the middle of onboarding and -/// break invariant 1 for a value nothing checks. So one is generated per install and kept in the -/// Keychain, which is what it is for. -/// - the API key is the one real credential, and it is pasted like any model key. -public enum IntelligenceCredentialStore: Sendable { - private static let keyService = "dev.xbot.intelligence-key" - private static let licenceService = "dev.xbot.copilotkit-license" - - /// CopilotKit's hosted endpoints, from `engine/.env.example`. - public static let apiURL = "https://api.intelligence.copilotkit.ai" - public static let gatewayWebSocketURL = "wss://realtime.intelligence.copilotkit.ai" - - /// The pasted key, or nil when nobody has connected one. - public static func apiKey() throws -> String? { - try KeychainSecretStore.readExisting(service: keyService, account: "default") - } - - public static func save(apiKey: String) throws { - let normalized = ProviderKeyStore.normalize(apiKey) - guard !normalized.isEmpty else { return } - try KeychainSecretStore.write(normalized, service: keyService, account: "default") - } - - public static func removeAPIKey() throws { - try KeychainSecretStore.remove(service: keyService, account: "default") - } - - /// Generated once per install and kept. Telemetry, not a gate — see the type's note. - public static func licenseToken() throws -> String { - try KeychainSecretStore.string(service: licenceService) - } - - public static func removeLicenseToken() throws { - try KeychainSecretStore.remove(service: licenceService, account: "default") - } - - /// Whether the engine can be given Intelligence, and if not, why not. - /// - /// Three states rather than an optional, because "nobody connected a key" and "the Keychain - /// refused to hand one over" lead to opposite sentences. Collapsing them — which `settings()` - /// did, with `try?` — tells somebody who already connected a key to go and connect it. - public enum Availability: Sendable { - case connected(EngineEnvironment.Intelligence) - case notConnected - /// A read failed. Usually a locked login Keychain or a denied prompt; both recoverable. - case unreadable - } - - public static func availability() -> Availability { - let key: String? - do { - key = try apiKey() - } catch { - return .unreadable - } - guard let key, !key.isEmpty else { return .notConnected } - - // The licence token is generated on first read rather than pasted, so a failure here is - // never "nobody set one" — it is the Keychain saying no, or refusing to be written to. - guard let licence = try? licenseToken(), !licence.isEmpty else { return .unreadable } - - return .connected( - EngineEnvironment.Intelligence( - apiURL: apiURL, - gatewayWsURL: gatewayWebSocketURL, - apiKey: key, - licenseToken: licence - ) - ) - } - - /// What the engine needs, or nil when no key has been connected. - /// - /// Nil rather than a half-filled block on purpose: `runtimeCapabilities()` throws on a partial - /// set — deliberately, because somebody who set two of four meant to use Intelligence and got - /// it wrong — so the choice here is all four or none. - public static func settings() -> EngineEnvironment.Intelligence? { - // Nil for both of the other two: the engine takes all four variables or none, and an - // unreadable key is not four. Which of the two it was is `availability()`'s job to say, - // and the composer's to put in front of somebody. - guard case .connected(let intelligence) = availability() else { return nil } - return intelligence - } -} diff --git a/apps/mac/Sources/XBotEngine/HTTPEngineClient.swift b/apps/mac/Sources/XBotEngine/HTTPEngineClient.swift index 56dba3f..d57cf1a 100644 --- a/apps/mac/Sources/XBotEngine/HTTPEngineClient.swift +++ b/apps/mac/Sources/XBotEngine/HTTPEngineClient.swift @@ -121,7 +121,7 @@ public actor HTTPEngineClient: EngineClient { else { return [] } let (threadData, response) = try await send( - request(.get, "/api/copilotkit/threads?threadId=\(threadId)") + request(.get, Self.threadMessagesPath(threadId)) ) guard (response as? HTTPURLResponse)?.statusCode == 200 else { return [] } guard @@ -588,9 +588,20 @@ public actor HTTPEngineClient: EngineClient { continuation.finish() } + /// The runtime's route for one thread's messages. + /// + /// Not `/threads?threadId=`, which is what this used to ask: the runtime matches that as the + /// thread *list*, which answers `{ threads }` — or a 400 without an agent id — and never + /// `{ messages }`. Every history read came back empty, so a conversation was blank after a + /// restart and every agent forgot everything said before the newest message. + static func threadMessagesPath(_ threadId: String) -> String { + let encoded = threadId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? threadId + return "/api/copilotkit/threads/\(encoded)/messages" + } + /// The thread's messages as AG-UI messages, or none for a thread that does not exist yet. private func threadMessages(_ threadId: String) async throws -> [WireMessage] { - let (data, response) = try await send(request(.get, "/api/copilotkit/threads?threadId=\(threadId)")) + let (data, response) = try await send(request(.get, Self.threadMessagesPath(threadId))) guard (response as? HTTPURLResponse)?.statusCode == 200, let thread = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let rows = thread["messages"] as? [[String: Any]] diff --git a/apps/mac/Sources/XBotEngine/Models.swift b/apps/mac/Sources/XBotEngine/Models.swift index c046315..4a8bd46 100644 --- a/apps/mac/Sources/XBotEngine/Models.swift +++ b/apps/mac/Sources/XBotEngine/Models.swift @@ -207,22 +207,6 @@ public enum ComposerBlock: Hashable, Sendable { first for an engine they never stopped. */ case enginePausedWhenIdle - /// No CopilotKit Intelligence key, so the engine is in local mode and cannot hold a - /// conversation. ADR-0007 keeps Intelligence for v1; without it `LocalIntelligence` throws past - /// wiring, and the engine still starts and answers `/health` — so nothing else would say this. - case noConversationStore - /** - A key is stored and the Keychain would not hand it over. - - Separate from `noConversationStore`, which means nobody has connected one. Collapsing the two - tells somebody who already connected a key to go and connect it — they open Settings, see their - key sitting there, and have nowhere left to look. It is the same mistake the engine status made - about a container runtime that was installed and merely asleep. - - The usual cause is a locked login Keychain or a denied prompt, and both are recoverable, so the - action is to try again rather than to go anywhere. - */ - case conversationStoreUnreadable case humanHoldsControl public var sentence: String { @@ -234,10 +218,6 @@ public enum ComposerBlock: Hashable, Sendable { case .noModelConnected: String(localized: "Connect a model to start") case .enginePausedWhenIdle: String(localized: "Paused while idle to save memory — sending a message starts it again") - case .noConversationStore: - String(localized: "Connect a CopilotKit key so xBot can keep your conversations") - case .conversationStoreUnreadable: - String(localized: "xBot couldn't read your CopilotKit key from the Keychain") case .humanHoldsControl: String(localized: "You're controlling the browser") } } @@ -250,8 +230,6 @@ public enum ComposerBlock: Hashable, Sendable { case .engineFailed: String(localized: "Try again") case .noModelConnected: String(localized: "Open Settings") case .enginePausedWhenIdle: String(localized: "Start now") - case .noConversationStore: String(localized: "Open Settings") - case .conversationStoreUnreadable: String(localized: "Try again") case .humanHoldsControl: String(localized: "Give it back") } } diff --git a/apps/mac/Sources/XBotEngine/WireTranscript.swift b/apps/mac/Sources/XBotEngine/WireTranscript.swift index 09d4f76..2221bc9 100644 --- a/apps/mac/Sources/XBotEngine/WireTranscript.swift +++ b/apps/mac/Sources/XBotEngine/WireTranscript.swift @@ -21,11 +21,15 @@ public struct WireMessage: Sendable, Equatable { public init?(row: [String: Any]) { guard let id = row["id"] as? String, let role = row["role"] as? String else { return nil } let calls = (row["toolCalls"] as? [[String: Any]] ?? []).compactMap { call -> WireToolCall? in + // The runtime's thread route flattens a call to `name` and `args`; AG-UI nests it under + // `function`. History arrives flat, and reading only the nested shape dropped every call + // while keeping its result — a conversation the model vendor refuses to continue. + let function = call["function"] as? [String: Any] guard let callId = call["id"] as? String, - let function = call["function"] as? [String: Any], - let name = function["name"] as? String + let name = function?["name"] as? String ?? call["name"] as? String else { return nil } - return WireToolCall(id: callId, name: name, arguments: function["arguments"] as? String ?? "") + let arguments = function?["arguments"] as? String ?? call["args"] as? String ?? "" + return WireToolCall(id: callId, name: name, arguments: arguments) } self.init(id: id, role: role, content: row["content"] as? String, toolCalls: calls, toolCallId: row["toolCallId"] as? String) } diff --git a/apps/mac/Sources/XBotOnboarding/OnboardingCoordinator.swift b/apps/mac/Sources/XBotOnboarding/OnboardingCoordinator.swift index 7f04a63..9592725 100644 --- a/apps/mac/Sources/XBotOnboarding/OnboardingCoordinator.swift +++ b/apps/mac/Sources/XBotOnboarding/OnboardingCoordinator.swift @@ -43,15 +43,6 @@ public final class OnboardingCoordinator { public var selectedProviderID: String = ModelProviderCatalog.all[0].id public var apiKey = "" - - /// The CopilotKit key, which is what lets the engine keep conversations at all. - /// - /// ADR-0007: "The app ships with an Intelligence key configured at onboarding alongside the - /// model key." Without it the engine boots into local mode and cannot hold a conversation, so - /// this is not optional in the way a model key is — skipping the model step leaves a usable app - /// with a disabled composer, while skipping this one leaves an app that looks fine and fails on - /// the first turn. - public var intelligenceKey = "" public private(set) var validation: ValidationState = .idle public private(set) var ollamaModelCount: Int? public private(set) var didSkipModel = false @@ -262,12 +253,6 @@ public final class OnboardingCoordinator { if selectedProviderID != "ollama" { try ProviderKeyStore.save(apiKey, for: selectedProviderID) } - // Saved beside the model key, which is the pairing ADR-0007 describes. Empty is - // allowed: the person can add it later in Settings, and the composer says so - // rather than the first turn failing. - if !ProviderKeyStore.normalize(intelligenceKey).isEmpty { - try IntelligenceCredentialStore.save(apiKey: intelligenceKey) - } ProviderConnectionStore.shared.markConnected(selectedProviderID) validation = .succeeded(modelCount: count) didSkipModel = false @@ -290,23 +275,8 @@ public final class OnboardingCoordinator { } public func createFirstAgent() async -> Agent.ID? { - /* - * Restart the engine once the CopilotKit key exists — which, on a first run, is always after - * the engine started. - * - * The engine starts in step three and the key is asked for in step four, and the engine reads - * its CopilotKit credentials from its environment at start. So the engine onboarding handed - * over was running without them while the composer, which checks the Keychain, opened as if - * all was well, and the first conversation anybody had failed. The restart recreates the - * container with the key (see `RuntimeController.environmentFingerprint`), keeping every - * volume. It happens here, behind "Creating your first agent…", rather than mid-typing. - * - * Here and not in a `finish()`: it was first put there, and nothing called `finish()` — the - * Meet-your-agent screen calls this directly — so it would never have run. - */ - if IntelligenceCredentialStore.settings() != nil { - await runtime.restart(environment: environmentFactory) - } + // No restart: the only key asked for here is a model's, and model keys reach the engine + // through its credential vault when the app syncs them, not through its environment. guard case .running(let endpoint) = await runtime.state else { return nil } let token = try? EngineTokenStore.token() let client = HTTPEngineClient(baseURL: endpoint.baseURL, token: token) diff --git a/apps/mac/Sources/XBotOnboarding/Steps/ConnectModelStep.swift b/apps/mac/Sources/XBotOnboarding/Steps/ConnectModelStep.swift index ba13bc6..06e8380 100644 --- a/apps/mac/Sources/XBotOnboarding/Steps/ConnectModelStep.swift +++ b/apps/mac/Sources/XBotOnboarding/Steps/ConnectModelStep.swift @@ -20,12 +20,12 @@ struct ConnectModelStep: View { .foregroundStyle(Palette.textSecondary) /* - * Where conversations are kept, before a key is typed. + * What leaves the Mac, before a key is typed. * - * ADR-0007 requires exactly this and is specific about the placement: "Onboarding - * says where conversations are stored, in one sentence, before the user types a key - * — not in a privacy policy, and not after. An app whose pitch is local control - * must not be vague about the part that is not local." + * ADR-0007 set the rule and it outlives the CopilotKit key it was written for: + * onboarding says where conversations are kept, in one sentence, before a key is + * typed. Since ADR-0008 they are kept on this Mac, and the one thing that does leave + * is what a person sends to the model they pick — so that is what this names. * * Above the provider list rather than under the field, because a disclosure a * person reads after choosing is a disclosure that arrived too late to inform the @@ -45,8 +45,6 @@ struct ConnectModelStep: View { ollamaRow } - intelligenceField - HStack { Button(String(localized: "Skip for now"), action: coordinator.skipModelConnection) .buttonStyle(XBotButtonStyle()) @@ -95,16 +93,16 @@ struct ConnectModelStep: View { /// The sentence ADR-0007 requires. One source, so a test can hold the product to it. static let transcriptDisclosureText = String( - localized: "Your agents and their files stay on this Mac. Your conversation history is stored by CopilotKit, the service xBot's engine is built on, so it leaves your Mac." + localized: "Your agents, their files, and your conversations stay on this Mac. What you send an agent goes to the model you choose here — or nowhere, if the model runs on this Mac." ) /// Says the part that is not local, in the place it can still change a decision. @ViewBuilder private var transcriptDisclosure: some View { HStack(alignment: .top, spacing: Space.s) { - Image(systemName: "cloud") + Image(systemName: "lock.shield") .font(.system(size: 13)) - .foregroundStyle(Palette.stateReconnecting) + .foregroundStyle(Palette.textSecondary) Text(Self.transcriptDisclosureText) .captionText() .foregroundStyle(Palette.textSecondary) @@ -117,29 +115,6 @@ struct ConnectModelStep: View { .accessibilityElement(children: .combine) } - /// The CopilotKit key, beside the model key, as ADR-0007 describes. - /// - /// Its own field rather than folded into the provider list, because it is not a model — it is - /// where conversations are kept. Optional here so somebody evaluating the app still reaches a - /// window; the composer says what is missing rather than the first turn failing. - private var intelligenceField: some View { - VStack(alignment: .leading, spacing: Space.xs) { - Text(String(localized: "CopilotKit key")) - .captionText() - .foregroundStyle(Palette.textSecondary) - SecureField( - String(localized: "Paste your CopilotKit key"), - text: $coordinator.intelligenceKey - ) - .textFieldStyle(.roundedBorder) - Text(String( - localized: "Needed for xBot to keep your conversations. You can add it later in Settings — until then agents can't reply." - )) - .captionText() - .foregroundStyle(Palette.textTertiary) - } - } - private var keyField: some View { VStack(alignment: .leading, spacing: Space.s) { SecureField(String(localized: "Paste your key here"), text: $coordinator.apiKey) diff --git a/apps/mac/Sources/XBotOnboarding/Steps/WelcomeStep.swift b/apps/mac/Sources/XBotOnboarding/Steps/WelcomeStep.swift index 8824037..a1eba8d 100644 --- a/apps/mac/Sources/XBotOnboarding/Steps/WelcomeStep.swift +++ b/apps/mac/Sources/XBotOnboarding/Steps/WelcomeStep.swift @@ -9,20 +9,16 @@ struct WelcomeStep: View { /* * What v1 actually does, rather than what the pitch would prefer. * - * The third bullet used to read "Everything stays here. No account, no cloud." ADR-0007 is - * explicit that both halves of that are false for v1: onboarding needs a CopilotKit key, and - * conversation history rests on their infrastructure. The ADR says the vision document "has - * been changed rather than quietly reinterpreted" — the first screen of the product is the one - * place that mattered most and it had not been. - * - * Agents, files and browsers really do stay on the Mac, so that claim keeps its place. The - * transcript is the exception and step four says so in full before a key is typed. + * The third bullet used to read "Everything stays here. No account, no cloud." "No cloud" is + * still not true of a hosted model: what a person sends it goes to its vendor, and step four + * says so before a key is typed. What is true since ADR-0008 is that conversations are kept + * here, alongside the agents, their files and their browsers — so that is what it claims. */ - /// The promises this screen makes. Exposed so a test can hold them to ADR-0007. + /// The promises this screen makes. Exposed so a test can hold them to what v1 does. static let bulletsForTesting = [ String(localized: "Bring any model — or run one locally, with nothing leaving your Mac"), String(localized: "Watch what your agents do, and take over whenever you want"), - String(localized: "Your agents, their files, and their browsers stay on this Mac"), + String(localized: "Your agents, their files, and your conversations stay on this Mac"), ] private let bullets = Self.bulletsForTesting diff --git a/apps/mac/Sources/XBotUI/Conversation/Conversation.swift b/apps/mac/Sources/XBotUI/Conversation/Conversation.swift index ead1329..f061cf2 100644 --- a/apps/mac/Sources/XBotUI/Conversation/Conversation.swift +++ b/apps/mac/Sources/XBotUI/Conversation/Conversation.swift @@ -135,11 +135,6 @@ public struct Conversation: View { return String(localized: "The engine couldn't start") case .noModelConnected: return String(localized: "Connect a model to start") - case .noConversationStore: - return String(localized: "xBot can't keep your conversations yet") - case .conversationStoreUnreadable: - // Not "connect a key". The key is there; the Keychain is the one saying no. - return String(localized: "xBot couldn't read your CopilotKit key") case .humanHoldsControl, nil: break } @@ -163,16 +158,6 @@ public struct Conversation: View { return reason case .noModelConnected: return String(localized: "Add a provider key in Settings before you send a message.") - case .noConversationStore: - // Says which part is missing and where it goes, rather than "something went wrong". - return String( - localized: "Conversation history is stored by CopilotKit, the service xBot's engine is built on. Add a CopilotKit key in Settings → Models." - ) - case .conversationStoreUnreadable: - // The two things that actually cause it, and the one that fixes both. - return String( - localized: "Your key is saved, but the Keychain wouldn't hand it over — usually a locked login Keychain or a dismissed prompt. Try again." - ) case .humanHoldsControl, nil: break } diff --git a/apps/mac/Sources/XBotUI/Settings/ModelsSettingsView.swift b/apps/mac/Sources/XBotUI/Settings/ModelsSettingsView.swift index 5851a82..3353a89 100644 --- a/apps/mac/Sources/XBotUI/Settings/ModelsSettingsView.swift +++ b/apps/mac/Sources/XBotUI/Settings/ModelsSettingsView.swift @@ -17,9 +17,6 @@ public struct ModelsSettingsView: View { @State private var customModel = "" @State private var customKey = "" @State private var savingCustom = false - @State private var conversationStore = ConversationStoreSettings() - @State private var copilotKey = "" - @State private var editingCopilotKey = false public init() {} @@ -68,117 +65,14 @@ public struct ModelsSettingsView: View { ) ) } - - conversationStoreSection } .formStyle(.grouped) .task { settings.load() - conversationStore.load() await settings.detectLocalProviders() } } - /* - * The one key that is not a model's. - * - * ADR-0007 keeps CopilotKit Intelligence for v1, so this is what holds the conversation. It sits - * under the model providers rather than in a pane of its own because it is a key, it is pasted - * the same way, and the composer's "Open Settings" has to land somewhere a person recognises. - */ - @ViewBuilder - private var conversationStoreSection: some View { - Section { - HStack(spacing: Space.s) { - VStack(alignment: .leading, spacing: 0) { - Text(String(localized: "CopilotKit")).bodyEmphasis() - Text(conversationStoreStateText) - .captionText() - .foregroundStyle( - conversationStore.state == .ready - ? Palette.stateRunning : Palette.textSecondary - ) - } - Spacer() - switch conversationStore.state { - case .ready: - Button(String(localized: "Disconnect")) { - conversationStore.disconnect() - applyConversationStoreChange() - } - .buttonStyle(XBotButtonStyle()) - case .notConnected, .unreadable: - Button( - editingCopilotKey - ? String(localized: "Cancel") : String(localized: "Connect") - ) { - withAnimation(Motion.quick) { editingCopilotKey.toggle() } - } - .buttonStyle(XBotButtonStyle()) - } - } - - if editingCopilotKey, conversationStore.state != .ready { - HStack(spacing: Space.s) { - SecureField(String(localized: "CopilotKit key"), text: $copilotKey) - .textFieldStyle(.roundedBorder) - .onSubmit(saveCopilotKey) - Button(String(localized: "Save"), action: saveCopilotKey) - .buttonStyle(XBotButtonStyle()) - .disabled(copilotKey.trimmingCharacters(in: .whitespaces).isEmpty) - } - } - - if let problem = conversationStore.problem { - Text(problem).captionText().foregroundStyle(Palette.stateFailed) - } - } header: { - Text(String(localized: "Conversation history")) - } footer: { - // The part that is not local, said where the key is typed — ADR-0007 requires it in the - // product and not only in a document, and this is now a second place a person meets it. - Text( - String( - localized: - "Your conversation history is stored by CopilotKit, the service xBot's engine is built on, so it leaves your Mac. Changing this key restarts the engine, which takes a few seconds." - ) - ) - } - } - - private var conversationStoreStateText: String { - switch conversationStore.state { - case .ready: String(localized: "Connected") - case .notConnected: String(localized: "Not connected") - // Not "not connected": there is a key, and telling somebody to connect one they already - // have is the mistake this whole section exists to stop making. - case .unreadable: String(localized: "Saved, but unreadable") - } - } - - private func saveCopilotKey() { - conversationStore.connect(copilotKey) - copilotKey = "" - if conversationStore.state == .ready { - withAnimation(Motion.quick) { editingCopilotKey = false } - applyConversationStoreChange() - } - } - - /* - * A changed CopilotKit key reaches the engine only through a restart. - * - * The footer used to say the change "takes effect the next time the engine starts" and leave it - * there — but the composer reads the Keychain and opened straight away, so the next message went - * to an engine still running on the old credentials and failed. Worse, the next start did not - * apply it either: `docker start` reuses the environment a container was created with. The - * controller now replaces a container whose environment changed, and this restarts it at once. - */ - private func applyConversationStoreChange() { - guard case .running = state.runtimeState else { return } - state.restartEngine() - } - @ViewBuilder private func providerRow(_ row: ProviderRow) -> some View { VStack(alignment: .leading, spacing: Space.xs) { diff --git a/apps/mac/Tests/XBotCoreTests/ConversationRecoveryTests.swift b/apps/mac/Tests/XBotCoreTests/ConversationRecoveryTests.swift index 9646f29..88eae2d 100644 --- a/apps/mac/Tests/XBotCoreTests/ConversationRecoveryTests.swift +++ b/apps/mac/Tests/XBotCoreTests/ConversationRecoveryTests.swift @@ -103,15 +103,15 @@ struct ConversationRecoveryTests { } @Test func environmentReadsCredentialsOnlyWhenStarting() { - let reads = CredentialReads() + let keys = CredentialReads() + let tokens = CredentialReads() let environment = EngineBootstrap.environmentFactory( - keyEncryptionKey: { "test-encryption-key" }, - engineToken: { "test-token" }, - intelligence: { reads.next() } + keyEncryptionKey: { keys.next(prefix: "key") }, + engineToken: { tokens.next(prefix: "token") } ) - #expect(reads.count == 0) - #expect(environment(3001, "gateway")["INTELLIGENCE_API_KEY"] == "test-key-1") - #expect(environment(3001, "gateway")["INTELLIGENCE_API_KEY"] == "test-key-2") + #expect(keys.count == 0 && tokens.count == 0) + #expect(environment(3001, "gateway")["KEY_ENCRYPTION_KEY"] == "key-1") + #expect(environment(3001, "gateway")["KEY_ENCRYPTION_KEY"] == "key-2") } @Test func retryOfPartialReplyKeepsOnePromptAndOneNewAnswer() async throws { @@ -313,10 +313,10 @@ private final class CredentialReads: @unchecked Sendable { private let lock = NSLock() private var value = 0 var count: Int { lock.withLock { value } } - func next() -> EngineEnvironment.Intelligence { + func next(prefix: String) -> String { lock.withLock { value += 1 - return .init(apiURL: "https://example.invalid", gatewayWsURL: "wss://example.invalid", apiKey: "test-key-\(value)", licenseToken: "test-license") + return "\(prefix)-\(value)" } } } diff --git a/apps/mac/Tests/XBotCoreTests/ConversationStoreSettingsTests.swift b/apps/mac/Tests/XBotCoreTests/ConversationStoreSettingsTests.swift deleted file mode 100644 index 64d960c..0000000 --- a/apps/mac/Tests/XBotCoreTests/ConversationStoreSettingsTests.swift +++ /dev/null @@ -1,104 +0,0 @@ -import Foundation -import Testing -@testable import XBotCore - -/// Settings → Models, the CopilotKit section — the one key that had no home. -@MainActor -@Suite -struct ConversationStoreSettingsTests { - /// A store whose Keychain is a variable, so a test is not deciding against this machine's own. - private final class Fake: @unchecked Sendable { - var stored: String? - var writeFails = false - var clearFails = false - var state: ConversationStore = .notConnected - } - - private struct Refused: Error {} - - private func make(_ fake: Fake) -> ConversationStoreSettings { - ConversationStoreSettings( - read: { fake.state }, - write: { key in - if fake.writeFails { throw Refused() } - fake.stored = key - fake.state = .ready - }, - clear: { - if fake.clearFails { throw Refused() } - fake.stored = nil - fake.state = .notConnected - } - ) - } - - @Test func connectingStoresTheKeyAndReportsConnected() { - let fake = Fake() - let settings = make(fake) - settings.connect("ck-live-abc") - #expect(fake.stored == "ck-live-abc") - #expect(settings.state == .ready) - #expect(settings.problem == nil) - } - - @Test func whitespaceIsTrimmedAndAnEmptyKeyIsNotStored() { - let fake = Fake() - let settings = make(fake) - settings.connect(" ck-live-abc ") - #expect(fake.stored == "ck-live-abc") - - let untouched = Fake() - let second = make(untouched) - second.connect(" ") - #expect(untouched.stored == nil) - } - - /** - Never silently. - - A key somebody pasted that did not save is the worst available outcome: they believe it is - connected, the section says connected, and the engine goes on booting into local mode where the - history client throws on everything. - */ - @Test func aKeyThatCouldNotBeSavedSaysSo() { - let fake = Fake() - fake.writeFails = true - let settings = make(fake) - settings.connect("ck-live-abc") - #expect(settings.problem != nil) - #expect(settings.state != .ready) - } - - @Test func disconnectingRemovesTheKey() { - let fake = Fake() - fake.stored = "ck-live-abc" - fake.state = .ready - let settings = make(fake) - settings.disconnect() - #expect(fake.stored == nil) - #expect(settings.state == .notConnected) - } - - @Test func aRemovalThatFailedDoesNotClaimToHaveWorked() { - let fake = Fake() - fake.stored = "ck-live-abc" - fake.state = .ready - fake.clearFails = true - let settings = make(fake) - settings.disconnect() - #expect(fake.stored == "ck-live-abc") - #expect(settings.problem != nil) - } - - /// An unreadable key is a Keychain problem, not a missing key — the section must not offer to - /// connect one that is already there. - @Test func anUnreadableKeyIsReportedAsAKeychainProblem() { - let fake = Fake() - fake.state = .unreadable - let settings = make(fake) - settings.load() - #expect(settings.state == .unreadable) - #expect(settings.problem != nil) - #expect(settings.problem?.contains("Keychain") == true) - } -} diff --git a/apps/mac/Tests/XBotCoreTests/EngineIdleStopTests.swift b/apps/mac/Tests/XBotCoreTests/EngineIdleStopTests.swift index 16b1c0d..83159bd 100644 --- a/apps/mac/Tests/XBotCoreTests/EngineIdleStopTests.swift +++ b/apps/mac/Tests/XBotCoreTests/EngineIdleStopTests.swift @@ -25,8 +25,7 @@ struct EngineIdleStopTests { runtime: runtime, environment: environment, engineFactory: { _ in engine }, - providers: isolatedConnectionStore(), - conversationStore: { .ready } + providers: isolatedConnectionStore() ) await state.load() state.startEngine() @@ -134,7 +133,6 @@ struct EngineLaunchTests { environment: environment, engineFactory: { _ in StubEngineClient(tokenDelay: .zero) }, providers: isolatedConnectionStore(), - conversationStore: { .ready }, startsEngineOnLaunch: { startsOnLaunch } ) } @@ -187,8 +185,7 @@ struct EngineQuitTests { ), environment: environment, engineFactory: { _ in engine }, - providers: isolatedConnectionStore(), - conversationStore: { .ready } + providers: isolatedConnectionStore() ) await state.load() state.startEngine() @@ -281,8 +278,7 @@ struct EngineCrashNoticedTests { ), environment: environment, engineFactory: { _ in StubEngineClient(tokenDelay: .zero) }, - providers: isolatedConnectionStore(), - conversationStore: { .ready } + providers: isolatedConnectionStore() ) state.healthCheckInterval = .milliseconds(20) await state.load() diff --git a/apps/mac/Tests/XBotCoreTests/IntelligenceCredentialTests.swift b/apps/mac/Tests/XBotCoreTests/IntelligenceCredentialTests.swift deleted file mode 100644 index b3202df..0000000 --- a/apps/mac/Tests/XBotCoreTests/IntelligenceCredentialTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation -import Testing -import XBotEngine -import XBotRuntime -@testable import XBotCore - -/** - The credentials that decide whether a conversation can happen at all. - - ADR-0007 keeps CopilotKit Intelligence for v1. Without its key `runtimeCapabilities()` selects - local mode, and `LocalIntelligence` throws on everything past wiring — while the engine still - starts, still answers `/health`, and still lists agents. Every other signal in the app says all is - well, which is why this is checked rather than discovered when a turn fails. - */ -@Suite -struct IntelligenceCredentialTests { - @Test func theEndpointsAreTheOnesTheEngineDocuments() { - // From engine/.env.example. Constants rather than fields, because asking somebody to type - // a service's address is asking them to get it wrong. - #expect(IntelligenceCredentialStore.apiURL == "https://api.intelligence.copilotkit.ai") - #expect( - IntelligenceCredentialStore.gatewayWebSocketURL - == "wss://realtime.intelligence.copilotkit.ai" - ) - } - - /// All four or none. `runtimeCapabilities()` throws on a partial set — deliberately, because - /// somebody who set two of four meant to use Intelligence and got it wrong. - @Test func aSettingsBlockIsWholeOrAbsent() { - guard let settings = IntelligenceCredentialStore.settings() else { - // No key on this machine, which is the honest answer and the common one in CI. - return - } - #expect(!settings.apiKey.isEmpty) - #expect(!settings.licenseToken.isEmpty) - #expect(!settings.apiURL.isEmpty) - #expect(!settings.gatewayWsURL.isEmpty) - } - - /// The environment the engine receives is all four variables or none of them. - @Test func composeEmitsAllFourOrNone() { - let withIntelligence = EngineEnvironment.compose( - EngineEnvironment.Inputs( - port: 49_152, - keyEncryptionKey: "k", - hostGateway: "host.docker.internal", - appOrigin: "xbot://app", - intelligence: EngineEnvironment.Intelligence( - apiURL: "https://api.example", - gatewayWsURL: "wss://realtime.example", - apiKey: "ik", - licenseToken: "lt" - ) - ) - ) - let names = ["INTELLIGENCE_API_URL", "INTELLIGENCE_GATEWAY_WS_URL", "INTELLIGENCE_API_KEY", "COPILOTKIT_LICENSE_TOKEN"] - #expect(names.allSatisfy { withIntelligence[$0] != nil }) - - let without = EngineEnvironment.compose( - EngineEnvironment.Inputs( - port: 49_152, - keyEncryptionKey: "k", - hostGateway: "host.docker.internal", - appOrigin: "xbot://app" - ) - ) - #expect(names.allSatisfy { without[$0] == nil }) - } - - /// The engine token and the Intelligence key are different secrets in different Keychain - /// services — sharing one would mean rotating the loopback guard rotated the account key too. - @Test func theCredentialsAreSeparate() throws { - #expect(IntelligenceCredentialStore.apiURL != IntelligenceCredentialStore.gatewayWebSocketURL) - } -} - -/// The three answers to "can this engine keep a conversation", which used to be two. -@Suite -struct ConversationStoreStateTests { - /** - Why this is not a Bool. - - A missing key and an unreadable one need opposite sentences. The Bool made them one, so - somebody whose login Keychain was locked — or who dismissed the prompt — was told to connect a - CopilotKit key. They open Settings, see their key sitting right there, and have nowhere left to - look. It is the same mistake the engine status made about a container runtime that was - installed and merely asleep. - */ - @Test func aMissingKeyAndAnUnreadableOneSayDifferentThings() { - #expect( - ComposerBlock.noConversationStore.sentence - != ComposerBlock.conversationStoreUnreadable.sentence - ) - // The one thing it must never say to somebody who already has a key. - #expect(!ComposerBlock.conversationStoreUnreadable.sentence.contains("Connect a")) - } - - /// There is nowhere to send them: the key is already in Settings. Asking again is the fix. - @Test func anUnreadableKeyOffersARetryRatherThanASettingsTrip() { - #expect( - ComposerBlock.conversationStoreUnreadable.actionTitle - != ComposerBlock.noConversationStore.actionTitle - ) - #expect(!ComposerBlock.conversationStoreUnreadable.actionTitle.isEmpty) - } - - @Test func everyStateIsDistinct() { - #expect(ConversationStore.ready != .notConnected) - #expect(ConversationStore.notConnected != .unreadable) - } -} diff --git a/apps/mac/Tests/XBotCoreTests/RuntimeConnectedTests.swift b/apps/mac/Tests/XBotCoreTests/RuntimeConnectedTests.swift index 0d00093..832fdeb 100644 --- a/apps/mac/Tests/XBotCoreTests/RuntimeConnectedTests.swift +++ b/apps/mac/Tests/XBotCoreTests/RuntimeConnectedTests.swift @@ -45,10 +45,7 @@ struct RuntimeConnectedTests { engineFactory: { _ in StubEngineClient(tokenDelay: .zero) }, // Its own domain. Resetting the shared one used to race the provider suite, which // reads the same two keys — the composer assertions below are about the runtime. - providers: isolatedConnectionStore(), - // These tests are about the runtime, not about whether this machine has a CopilotKit - // key. Reading the real Keychain here is what made them depend on the machine. - conversationStore: { .ready } + providers: isolatedConnectionStore() ) return (state, runtime) } diff --git a/apps/mac/Tests/XBotEngineTests/HTTPEngineClientTests.swift b/apps/mac/Tests/XBotEngineTests/HTTPEngineClientTests.swift index 984cc9a..ef2675b 100644 --- a/apps/mac/Tests/XBotEngineTests/HTTPEngineClientTests.swift +++ b/apps/mac/Tests/XBotEngineTests/HTTPEngineClientTests.swift @@ -99,7 +99,7 @@ struct HTTPEngineClientTests { ]) ), forHost: host, - path: "/api/copilotkit/threads" + path: "/api/copilotkit/threads/thread-1/messages" ) let messages = try await client(host: host).messages(in: "channel-1") @@ -332,7 +332,7 @@ struct HTTPEngineClientTests { StubURLProtocol.register( .init(body: Self.json(["messages": [["id": "old-1", "role": "user", "content": "earlier"]]])), forHost: host, - path: "/api/copilotkit/threads" + path: "/api/copilotkit/threads/thread-1/messages" ) StubURLProtocol.register( .init(body: Self.json(["title": "Example Domain", "url": "https://example.com", "text": "hi"])), diff --git a/apps/mac/Tests/XBotEngineTests/LiveEngineTests.swift b/apps/mac/Tests/XBotEngineTests/LiveEngineTests.swift index 675b7fb..caed25a 100644 --- a/apps/mac/Tests/XBotEngineTests/LiveEngineTests.swift +++ b/apps/mac/Tests/XBotEngineTests/LiveEngineTests.swift @@ -69,10 +69,6 @@ struct LiveEngineTests { the app stores one; an agent is pointed at Anthropic; a message is sent. If the key never reached the vendor the failure would be the router's "no key" sentence, or no managed Bot at all. Only a key that travelled the whole way comes back as Anthropic refusing it. - - Needs an engine started with CopilotKit Intelligence (`XBOT_LIVE_ENGINE_HAS_INTELLIGENCE=1`). - Without it every run stops before the Bot — ADR-0007's local mode throws at `getOrCreateThread` — - and the send answers 502 whatever the key. The vault half is still checked on its own below. */ @Test func aStoredKeyReachesTheVault() async throws { let client = client @@ -93,7 +89,11 @@ struct LiveEngineTests { #expect(try await client.liveModelKeys().allSatisfy { $0.keyId != "xbot-model:anthropic" }) } - @Test(.enabled(if: ProcessInfo.processInfo.environment["XBOT_LIVE_ENGINE_HAS_INTELLIGENCE"] == "1")) + /// Since ADR-0008, `LocalThreadRunner` answers this without an Intelligence key — the throwaway + /// engine this suite runs against no longer needs `XBOT_LIVE_ENGINE_HAS_INTELLIGENCE` set. The + /// variable still gates a run against a deployment that configures Intelligence by hand, which is + /// why the check stays rather than being deleted outright. + @Test(.enabled(if: ProcessInfo.processInfo.environment["XBOT_LIVE_ENGINE_HAS_INTELLIGENCE"] != "0")) func aStoredKeyTravelsAllTheWayToTheVendor() async throws { let client = client try await client.storeModelKey( diff --git a/apps/mac/Tests/XBotEngineTests/WireTranscriptTests.swift b/apps/mac/Tests/XBotEngineTests/WireTranscriptTests.swift index a0676fa..1b6b085 100644 --- a/apps/mac/Tests/XBotEngineTests/WireTranscriptTests.swift +++ b/apps/mac/Tests/XBotEngineTests/WireTranscriptTests.swift @@ -45,4 +45,17 @@ struct WireTranscriptTests { let calls = try #require(wire["toolCalls"] as? [[String: Any]]) #expect((calls.first?["function"] as? [String: Any])?["name"] as? String == "computer_read") } + + /// The shape `/threads/:id/messages` actually answers with. Read as nested-only, the call vanished + /// and its result stayed, and the next run was refused by the model vendor. + @Test func aFlatHistoryRowKeepsItsToolCalls() throws { + let row: [String: Any] = [ + "id": "a1", "role": "assistant", + "toolCalls": [["id": "c1", "name": "computer_navigate", "args": "{\"url\":\"https://example.com\"}"]], + ] + let message = try #require(WireMessage(row: row)) + #expect(message.toolCalls == [ + WireToolCall(id: "c1", name: "computer_navigate", arguments: "{\"url\":\"https://example.com\"}"), + ]) + } } diff --git a/apps/mac/Tests/XBotOnboardingTests/DisclosureTests.swift b/apps/mac/Tests/XBotOnboardingTests/DisclosureTests.swift index 3984c50..29e90e3 100644 --- a/apps/mac/Tests/XBotOnboardingTests/DisclosureTests.swift +++ b/apps/mac/Tests/XBotOnboardingTests/DisclosureTests.swift @@ -5,16 +5,17 @@ import Testing /** The promises the first run makes, checked against what v1 actually does. - ADR-0007 keeps CopilotKit Intelligence for v1 and is explicit that two claims in the vision do not - hold because of it: "No account", and "nothing leaves your machine except the calls you choose". - The ADR says the vision document "has been changed rather than quietly reinterpreted" — but the - first screen of the product still said "Everything stays here. No account, no cloud", which asserts - both of them. + Since ADR-0008, conversations are kept by the engine's own local history (`LocalThreadRunner`) + rather than CopilotKit Intelligence, so "no account" and "everything stays on this Mac" are true + again — for the conversation. They are still not true for a message's content: whatever a person + sends still goes to whichever model they picked, unless that model runs on this Mac too. "No cloud" + stays unsaid because it would claim the model call as local as well, which it is not. These are string tests, which is unusual and deliberate. The claim is the feature: an app whose pitch is local control must not be vague about the part that is not local, and a copy edit is exactly how that protection would be lost. */ +@MainActor @Suite struct OnboardingDisclosureTests { @Test func theWelcomeScreenDoesNotPromiseNoCloud() { @@ -27,8 +28,9 @@ struct OnboardingDisclosureTests { @Test func theKeyStepSaysWhereConversationsAreKept() { let disclosure = ConnectModelStep.transcriptDisclosureText.lowercased() - // The part that is not local, named rather than implied. - #expect(disclosure.contains("copilotkit")) - #expect(disclosure.contains("leaves your mac")) + // What is local, named rather than implied… + #expect(disclosure.contains("conversations stay on this mac")) + // …and what is not: whatever is sent still goes to the model that was picked. + #expect(disclosure.contains("goes to the model")) } } diff --git a/docs/01-vision.md b/docs/01-vision.md index 59a27ef..aba3e04 100644 --- a/docs/01-vision.md +++ b/docs/01-vision.md @@ -49,22 +49,22 @@ is close, the tiebreak is whichever option keeps this promise intact. ### What the promise does not cover in v1 An earlier version of this document also promised *"no account"* and *"nothing leaves your machine -except the calls you choose."* **Neither holds in v1**, and they are corrected here rather than -quietly reinterpreted. See [ADR-0007](decisions/0007-wrap-openbot-keep-intelligence.md). - -v1 runs on CopilotKit Intelligence for durable threads and memory, which means: - -- **Onboarding asks for a CopilotKit key**, entered in the app. No terminal, no CLI — the central - promise survives — but it is an account, and calling it anything else would be dishonest. -- **Conversation history transits and rests on CopilotKit's infrastructure.** Model calls were - always outbound. This is different in kind, because it is the transcript rather than the request. - -**Onboarding says this in one sentence, before the user types a key.** Not in a privacy policy, -not afterwards. An app that sells local control cannot be vague about the part that is not local. - -The engine already boots without it — the seam is built and measured — so this is a v1 sequencing -decision, not a permanent shape. Everything else on this page is unchanged: the agents, their -computers, their browsers, their files, and the model keys are all yours and all local. +except the calls you choose."* [ADR-0007](decisions/0007-wrap-openbot-keep-intelligence.md) recorded +that neither held for the v1 it planned, which ran on CopilotKit Intelligence for durable threads and +memory. + +**As of [ADR-0008](decisions/0008-local-thread-runner.md), both hold again, for the conversation.** +The engine keeps threads itself (`LocalThreadRunner`) rather than on CopilotKit's infrastructure, and +onboarding has no field anywhere that asks for a CopilotKit key. What still leaves the Mac is what it +always did: whatever you send goes to the model you picked, unless that model runs here too. That is +a request, not a transcript, and it is the one thing this promise never covered. + +**Onboarding says this — where the local claim ends — in one sentence, before a model key is typed.** +Not in a privacy policy, not afterwards. An app that sells local control cannot be vague about the +part that is not local. + +Everything else on this page holds without qualification: the agents, their computers, their +browsers, their files, and the model keys are all yours and all local. Concretely, that means the app owns: diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index a30bcc2..cae308c 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -19,13 +19,13 @@ and the ones marked ⚠️ have the widest error bars. | | Milestone | State | | --- | --- | --- | | M0 | Groundwork | **Done.** Engine vendored, CI, Swift package, dev database | -| M1 | Local history provider | **Deferred past v1.** Seam built and verified; see ADR-0007 | +| M1 | Local history provider | **Conversation half done, ahead of v1.1.** See ADR-0008: a durable `LocalThreadRunner` ships in place of Intelligence, with no CopilotKit account needed. Memory/recall (pgvector) is still ADR-0001's, deferred to v1.1 | | M2 | Model router | **Proven live against a real vendor** (see below). Registry + `openai-compatible` adapter, per-run resolution, selection stored on the agent, forwarded, and read back, Settings → Models, custom providers, **Ollama host-gateway routing**. **The `copilot.ts` hop is now covered by a test that drives the real client and asserts on the posted body** (`server/tests/copilot-model-selection.test.ts`). **Not yet done:** a second real vendor | | M3 | Engine runs headless | **Done.** Image published to ghcr on every push to master; manifest pinned and fetched by the app at start | | M4 | Mac app skeleton | **Done.** Rail, conversation, composer, panel, palette, design system, runtime driver | -| M5 | Connected | **Client done, and now driven against a real engine.** The engine image had no Bot and no model key ever reached it, so no agent could be created and no run could authenticate — fixed, see `docs/plans/managed-bot-and-model-keys.md`. `Tests/XBotEngineTests/LiveEngineTests.swift` exercises the real client against a throwaway engine; the final hop needs a CopilotKit key | +| M5 | Connected | **Client done, and now driven against a real engine.** The engine image had no Bot and no model key ever reached it, so no agent could be created and no run could authenticate — fixed, see `docs/plans/managed-bot-and-model-keys.md`. `Tests/XBotEngineTests/LiveEngineTests.swift` exercises the real client against a throwaway engine. **No longer blocked on a CopilotKit key** — ADR-0008's `LocalThreadRunner` is what the client actually talks to; the `XBOT_LIVE_ENGINE_HAS_INTELLIGENCE=1` branch of that suite now only exercises the Intelligence path for a deployment that opts into it by hand | | M6 | Onboarding | **In progress.** Five steps built, install-for-me, adoption, handoff transition, failure branches, runtime choice persistence; VM testing still open | -| M7 | Ship v1.0 | **In progress (unsigned).** Settings tabs (Agents, Computer, Usage) wired, plus a CopilotKit section so the key can be replaced or revoked; About window credits OpenBot; Sparkle scaffold + appcast scripts done. First-run supply chain verified anonymously. Signing certificates, CI secrets and publish still open — all four remaining items need a person | +| M7 | Ship v1.0 | **In progress (unsigned).** Settings tabs (Agents, Computer, Usage) wired; the CopilotKit section is gone rather than finished — ADR-0008 means there is no key to replace or revoke. About window credits OpenBot; Sparkle scaffold + appcast scripts done. First-run supply chain verified anonymously. Signing certificates, CI secrets and publish still open — all remaining items need a person | --- @@ -47,40 +47,32 @@ Set up so the rest can move. --- -## M1 — The local history provider ⚠️ — **DEFERRED PAST v1** +## M1 — The local history provider — **conversation half done; recall deferred to v1.1** -**3–5 weeks, and no longer first.** See -[ADR-0007](decisions/0007-wrap-openbot-keep-intelligence.md) for why, and -[ADR-0001](decisions/0001-local-history-provider.md) for the design, which still stands. +See [ADR-0008](decisions/0008-local-thread-runner.md) for what shipped, and +[ADR-0001](decisions/0001-local-history-provider.md) for the fuller design that is still v1.1's. -**What is already done:** `RuntimeCapabilities` has both modes, the three call sites are guarded, -and `history/local-intelligence.ts` enumerates what a local provider must answer. The engine has -been booted and driven with no `INTELLIGENCE_*` variables set at all. +The measurement ADR-0007 asked for before re-estimating this — what the native client's SSE-only +usage actually needs, versus the vendor client's full surface — is now done: `LocalThreadRunner` +wraps the vendor's own `InMemoryAgentRunner`, adding durability (a `local_threads` Postgres table) +and hydration on restart. No `HistoryProvider` interface was built; `copilot.ts` branches on +`intelligence` vs. `localRunner` instead. This is smaller than the original plan below because it +skips the part that plan called "the bulk of the work" — a rework of `copilot.ts` against a new +interface — by wrapping the vendor's runner in place. -**What is not:** the provider itself — threads, messages, pgvector recall, and the in-process -emitter that replaces the hosted realtime gateway. +**Done, per ADR-0001's own criterion:** the engine starts and runs a full conversation with no +`INTELLIGENCE_*` variables set at all, and history survives a container restart. The Mac app has no +UI path left to connect a CopilotKit key at all. -**Before estimating this again, measure.** The native client uses SSE, not the browser's Phoenix -websocket, so the method surface it actually reaches is smaller than the vendor client's. That -measurement does not exist until M5 is connected, which is the real reason this comes later. +**Still not done, and now explicitly v1.1's scope (see below):** pgvector recall/memory, and the +in-process realtime emitter for a hosted-gateway-shaped consumer — `LocalThreadRunner` has no +`identifyUser` and is guarded to `config.singleUser`, which is what xBot ships anyway. -The rest of this section is the original plan, unchanged and still correct. +The original plan, for the part still open: -- `HistoryProvider` interface. -- Schema: threads, messages, memory (pgvector). -- `LocalHistoryProvider`: Postgres for threads and messages, pgvector for recall, an in-process - emitter where upstream uses the hosted realtime websocket. -- `IntelligenceHistoryProvider` retained behind a setting, so the diff stays reviewable. -- Rework `runtimeCapabilities()` from a hard throw to provider selection. -- Rework `copilot.ts` — the bulk of the work — plus the six other call sites. -- Tests: thread lifecycle, message ordering, memory recall, concurrent turns, restart durability. - -**Done when:** the engine starts and runs a full conversation with **no `INTELLIGENCE_*` variables -set at all**, and history survives a container restart. - -**If this takes more than 6 weeks, stop and re-plan.** It would mean the coupling is deeper than the -64 references suggested, and the alternatives — vendoring differently, or a much thinner engine — -need to be back on the table. +- Schema: memory (pgvector). +- Recall/remember, tuned for chunking, embedding choice, and ranking. +- Tests: memory recall, relevance ordering. --- @@ -147,9 +139,9 @@ outside. machine, so "two agents on different providers" was met as two *adapters* — the native Anthropic one and `openai-compatible` — rather than two vendors. -An end-to-end conversation through the server needs Intelligence credentials: -`runtimeCapabilities()` takes all four `INTELLIGENCE_*` variables or none, and none selects -`LocalIntelligence`, which is a spike that throws (ADR-0007). +An end-to-end conversation through the server no longer needs Intelligence credentials: with none +of the `INTELLIGENCE_*` variables set, `copilot.ts` runs on `LocalThreadRunner` (ADR-0008). That run +through the server is launch checklist item 5, and has not been done yet. **Still open:** a second live vendor. The three native adapters are built and each is covered by a test asserting which client a selection gets (`agent-langgraph/tests/models-build.test.ts`). Usage accounting is done — the agent sums `usage_metadata` across a turn and emits @@ -277,7 +269,8 @@ at `raw.githubusercontent.com/MasterYoav/xBot/master/manifests/engine-stable.jso digest it names resolves at ghcr with an anonymous pull token, the repository is public, and the bundled fallback in `XBotApp/Resources` is byte-identical to the published manifest — so a first run with the network blocked still starts from a real pin rather than a placeholder. Every outbound URL -the app can show a person (CopilotKit, OpenBot, Ollama, the docs) answers 200. +the app can show a person (OpenBot, Ollama, the docs) answers 200. CopilotKit is no longer one of +them — ADR-0008 means the app has nothing to link to there. **The ordered version of all this is [13-launch-checklist.md](13-launch-checklist.md).** What still needs a person, and cannot be done from here: @@ -286,34 +279,33 @@ needs a person, and cannot be done from here: then every build is ad-hoc signed, which is also why local GUI verification keeps meeting a Keychain prompt: the code identity changes on every rebuild. 2. A clean-VM run of onboarding end to end. -3. A CopilotKit key, for an end-to-end Intelligence conversation. -4. A second live vendor key, to close the last M2 item. +3. A second live vendor key, to close the last M2 item. **Done when:** someone who has never seen the project installs from the website and uses it, without help. -### The Intelligence wiring — resolved +### The Intelligence wiring — resolved differently than planned **Was:** the app passed no `intelligence`, so the engine booted into local mode, whose client throws past wiring. It shipped a mode in which a conversation cannot work, and the first screen promised -"Everything stays here. No account, no cloud" — both of the things ADR-0007 says do not hold in v1. +"Everything stays here. No account, no cloud" — both of the things ADR-0007 said do not hold in v1. -**Now:** ADR-0007 is Accepted and it decided this already; the code had simply never followed. +**Now, per [ADR-0008](decisions/0008-local-thread-runner.md):** rather than wiring the app up to pass +a CopilotKit key — the fix this section originally planned — local mode was built out instead, since +no such key was available to build or verify against. The app has **no UI path left to connect one +at all**: no field in onboarding, no section in Settings, no `intelligence` ever passed to +`EngineEnvironment.Inputs`. -- `EngineBootstrap` passes all four `INTELLIGENCE_*`, from `IntelligenceCredentialStore`. -- The two URLs are constants; the licence token is generated per install, because ADR-0007 measured - that nothing validates it and upstream's way of getting one is a terminal command; the API key is - pasted at onboarding beside the model key. -- Onboarding says where conversations are kept **before** a key is typed, which is the placement the - ADR specifies, and the Welcome bullet no longer claims otherwise. Both strings are asserted by - tests, because the claim is the feature. -- Without the key the composer says so — `ComposerBlock.noConversationStore` — rather than letting a - turn fail against an engine that otherwise looks healthy. +- `LocalThreadRunner` gives the engine durable local history with no account, satisfying ADR-0001's + "no `INTELLIGENCE_*` variables, survives a restart" criterion. +- Onboarding says where conversations are kept **before** a model key is typed, and both the Welcome + bullet and the model step's copy are asserted by tests, because the claim is the feature. +- The composer no longer has a "no conversation store" block at all — a running engine can always + keep a conversation now, so the only thing left for it to be missing is a model. -**Still open, and the reason this is not finished:** nothing has driven a conversation end to end -against an engine in Intelligence mode. That needs a CopilotKit key on the machine running it. Every -engine test sets the four variables, so the suite covers the mode; the app's own path to it has -never been exercised. +**What is still open:** memory/recall (ADR-0001's pgvector half) and an actual Intelligence +end-to-end run, which nothing here needs but a future deployment configuring Intelligence by hand +still could. --- @@ -332,11 +324,14 @@ the project — and it is the one that decides whether a non-technical person ca Ordered by value, not by ease. -### v1.1 — Local history (ADR-0001) +### v1.1 — Recall and memory (the rest of ADR-0001) -The deferred gate, done with a measurement behind it instead of an estimate. This is what makes -"no account" and "nothing leaves your machine" true, and until it lands both are stated as -limitations in onboarding and in the UI. +[ADR-0008](decisions/0008-local-thread-runner.md) already made "no account" and "conversation +persists on this Mac" true in v1. What is left of ADR-0001 is the part `LocalThreadRunner` +deliberately does not do: pgvector-backed recall across conversations, tuned rather than a first +pass. Until it lands, an agent remembers everything sent within one conversation and nothing across +separate ones — which is also upstream's own behaviour without Intelligence's memory feature turned +on, not a regression xBot introduced. ### v1.2 — Per-agent computers diff --git a/docs/13-launch-checklist.md b/docs/13-launch-checklist.md index 5b75928..66e3885 100644 --- a/docs/13-launch-checklist.md +++ b/docs/13-launch-checklist.md @@ -106,26 +106,25 @@ prevent. --- -## 5. End-to-end conversation — **needs a CopilotKit key** +## 5. End-to-end conversation — **no CopilotKit key needed** -ADR-0007 keeps CopilotKit Intelligence for v1. Without a key the engine boots into local mode, whose -history client throws past wiring, so no conversation can complete. The app now says so rather than -failing silently, and the key can be pasted, replaced or revoked in Settings → Models → Conversation -history. +ADR-0007 kept CopilotKit Intelligence for v1; [ADR-0008](decisions/0008-local-thread-runner.md) +changed that. No key was available to build or verify against, so the engine's local-mode seam was +built out instead — `LocalThreadRunner`, a durable wrapper around the vendor's own SSE runner. The +app has no field anywhere to paste an Intelligence key; conversations are kept by the engine itself. -1. Get a key from CopilotKit. -2. Paste it in Settings → Models, or during onboarding. -3. Start the engine and hold a real conversation with an agent: send a message, get a reply, let it +1. Start the engine and hold a real conversation with an agent: send a message, get a reply, let it call a tool, and confirm the reply survives a restart of the app. -That last part is the actual test. The transcript living on their infrastructure is precisely what -this key buys, so a reply that does not survive a restart means it is not working. +That last part is the actual test. `LocalThreadRunner` persisting to Postgres and hydrating on boot +is precisely what this buys, so a reply that does not survive a restart means it is not working. Three more things only this conversation can prove, all built on 14 September against a stubbed engine (`docs/plans/computer-client-tools.md`): -- **Memory.** Tell the agent something, then ask about it two messages later. Each run now carries the - whole conversation; before, every agent forgot everything between messages. +- **Memory, within one conversation.** Tell the agent something, then ask about it two messages + later. Each run carries the whole conversation; before, every agent forgot everything between + messages. (Recall *across* separate conversations is pgvector work ADR-0001 still owns, not this.) - **Its computer.** Ask it to open a page and say what is on it. The Activity panel should show `computer_navigate` and the screen should show the page. Then check the conversation after an app restart has no duplicated messages — the tool-call loop resends messages the thread already holds, @@ -135,10 +134,11 @@ engine (`docs/plans/computer-client-tools.md`): **There is an automated version of the engine half.** `apps/mac/Tests/XBotEngineTests/LiveEngineTests.swift` drives the real client against a running engine — point it at a throwaway one, since it creates -agents. With Intelligence configured, set `XBOT_LIVE_ENGINE_HAS_INTELLIGENCE=1` as well and it proves -the whole key path by sending with a deliberately invalid Anthropic key: the answer should be -"Anthropic rejected the key". Everything short of the conversation store was verified that way on -13 September; this is the hop that needs your key. +agents. Its conversation-store assertions now run against local mode by default; a separate +`XBOT_LIVE_ENGINE_HAS_INTELLIGENCE=1` branch exists only for a deployment that configures +Intelligence by hand, which nothing here needs. This suite, and `LocalThreadRunner`'s own +integration test (`engine/server/tests/local-thread-runner.integration.test.ts`), still need to be +run before this item can be checked off — see the verification commands in `CLAUDE.md`. **Also close the last M2 item here:** point one agent at a second real vendor — Anthropic is already proven, so use OpenAI or Google — and confirm the reply comes from the vendor you picked. A model @@ -172,8 +172,9 @@ bugs, and they are only visible on the first run. `site/index.html` is the page: one file, no build step. It carries the download (pointing at `releases/latest`), the security explanation, the uninstall instructions — per docs/11 the standalone -uninstaller is documented here and not in the app — and, per ADR-0007, the fact that conversation -history is stored by CopilotKit, in the README's wording rather than a second version that can drift. +uninstaller is documented here and not in the app — and, per [ADR-0008](decisions/0008-local-thread-runner.md), +the fact that conversation history is kept by the engine on this Mac, in the README's wording rather +than a second version that can drift. `.github/workflows/pages.yml` publishes it on every push to master that touches `site/`. **It needs you once:** repository → Settings → Pages → Source: **GitHub Actions**. Until then the workflow fails @@ -194,8 +195,8 @@ From a shell holding no credentials: - On 18 September, a throwaway engine built from master (with the model hop lifted into `models/build.ts`) came up healthy on loopback, refused an unauthenticated request with a 401, and passed all eight live client tests: health, read endpoints, the vault, agent and conversation - round-trip, browser, files, shell, and the asks. Only the conversation store is still unproven, - because that is the hop needing a CopilotKit key. + round-trip, browser, files, shell, and the asks. That run predates ADR-0008; the conversation store + now needs re-running against `LocalThreadRunner` rather than a CopilotKit key — see item 5. - The pinned engine is one multi-arch image (linux/amd64 and linux/arm64). On 14 September an Apple Silicon Mac pulled it anonymously, got arm64, was healthy in about ten seconds on a port other than 3001, and passed the live suite: browser, files, shell, and a help request handed back. Before that diff --git a/docs/decisions/0007-wrap-openbot-keep-intelligence.md b/docs/decisions/0007-wrap-openbot-keep-intelligence.md index eab75af..ef4e74b 100644 --- a/docs/decisions/0007-wrap-openbot-keep-intelligence.md +++ b/docs/decisions/0007-wrap-openbot-keep-intelligence.md @@ -1,12 +1,23 @@ # ADR-0007 — Wrap OpenBot rather than re-engineer it, and keep Intelligence for v1 -**Status:** Accepted +**Status:** Accepted, and partially superseded by [ADR-0008](0008-local-thread-runner.md). **Date:** 2026-09 **Supersedes:** nothing. **Defers** [ADR-0001](0001-local-history-provider.md) — it stays accepted and stays unimplemented. **Related:** [01-vision.md](../01-vision.md), [03-openbot-fork.md](../03-openbot-fork.md), [12-roadmap.md](../12-roadmap.md) +> **Update, see [ADR-0008](0008-local-thread-runner.md).** "Keep Intelligence for v1" was written +> against the assumption that the local-mode seam this ADR left unimplemented would stay +> unimplemented through v1. It did not: no CopilotKit account was available to build against, so +> local mode was built out — narrowly, reusing the vendor's own SSE runner rather than ADR-0001's +> full `HistoryProvider` design — and the Mac app now ships with **no path to connect an +> Intelligence key at all**. The two promises this ADR's Consequences section said v1 would not +> keep — "no account" and "nothing leaves your machine except the calls you choose" — are true again +> for the conversation. They are still not true for a message's content, which still goes to +> whichever model was chosen. Read this ADR for *why the engine is wrapped rather than +> re-engineered*, which still holds; read ADR-0008 for what actually shipped instead of Intelligence. + --- ## Context diff --git a/docs/decisions/0008-local-thread-runner.md b/docs/decisions/0008-local-thread-runner.md new file mode 100644 index 0000000..53349e2 --- /dev/null +++ b/docs/decisions/0008-local-thread-runner.md @@ -0,0 +1,128 @@ +# ADR-0008 — A durable local thread runner, narrower than ADR-0001's HistoryProvider + +**Status:** Accepted +**Date:** 2026-09 +**Supersedes:** nothing. **Partially resolves** [ADR-0001](0001-local-history-provider.md) — the +"conversation persists with no account" half of it. Recall/memory (pgvector) is still that ADR's, +unimplemented. +**Related:** [ADR-0007](0007-wrap-openbot-keep-intelligence.md), [03-openbot-fork.md](../03-openbot-fork.md) + +--- + +## Context + +ADR-0007 kept CopilotKit Intelligence for v1 on the reasoning that the engine's local-mode seam was +already built and cheap to leave unimplemented — `history/local-intelligence.ts` is a spike that +records which methods are reached and throws on all of them. Building it out to ADR-0001's full +design (a `HistoryProvider` interface, `LocalHistoryProvider` backed by Postgres and pgvector, an +in-process realtime emitter, ~64 references across 8 files, 3–5 weeks) was deliberately deferred +past v1. + +No CopilotKit account is available in this environment. Shipping "a fully working app" against that +constraint means either the app cannot hold a conversation at all, or local mode has to actually +work — not ADR-0001's full scope, but enough of it that a conversation survives. + +**The measurement ADR-0007 asked for, done:** the native Mac client's only path to the engine is +`HTTPEngineClient.stream()`, a `POST /api/copilotkit/agent/:id/run` with +`Accept: text/event-stream`. It never opens Intelligence's realtime websocket. `@copilotkit/runtime` +already has a second runtime class for exactly this shape — `CopilotSseRuntime`, which needs no +`intelligence` client and defaults to its own `InMemoryAgentRunner` — and that runner already +implements `run`, `connect`, `isRunning`, `listThreads`, `getThreadMessages`, `getThreadEvents`, +`getThreadState`, and `clearThreads` against a process-global map. It is not durable — an in-memory +map does not survive a container restart — but it is otherwise the whole surface the client needs. + +## Decision + +**Wrap the vendor's own SSE runner rather than building ADR-0001's `HistoryProvider` interface.** + +`LocalThreadRunner` (`engine/server/src/history/local-thread-runner.ts`) extends +`InMemoryAgentRunner`: + +- `run()` behaves exactly as the vendor's does, and additionally persists the kept messages to a new + `local_threads` table (`threadId` primary key, `agentId`, `messages` jsonb, timestamps) once a run + completes or errors. Writes are serialized per-runner so a fast second turn cannot land before a + first one's write. +- `hydrate()` reads every row back into the in-memory map at boot, so a restart's `getThreadMessages` + answers as if the map had never been cleared. +- `localRoutineIntelligence(runner)` adapts it to the narrow `IntelligenceLike` structural interface + `routines/run-turn.ts` already defines, so a routine's headless turn reads the same durable history + a person's conversation does, including a lock (`ɵacquireThreadLock`) that throws when the runner + says the thread is already running. + +`copilot.ts` picks this up as a second branch, keyed on whether `mountCopilotRuntime()` was given +an `intelligence` client or a `localRunner`: local mode builds `CopilotRuntime` with `runner: +localRunner` instead of `intelligence:`, answers `history()` from `localRunner.getThreadMessages()`, +and uses a `localThreadLock` (`runner.isRunning()`) in place of Intelligence's own lock object. +`index.ts` wires a `LocalThreadRunner` and hydrates it whenever `runtimeCapabilities()` selects local +mode, and hands it to both the routine runner and Bot-to-Bot handoff delivery, which previously went +"dark" in local mode per the old comment on that branch. + +**Guarded to single-user.** SSE mode has no `identifyUser`, so there is no per-person scoping — +`mountCopilotRuntime()` throws if local mode is selected without `config.singleUser`. This is fine +for xBot: `OPENBOT_SINGLE_USER=true` is already how `EngineEnvironment.compose` configures every +container. + +**The Mac app stops offering to connect Intelligence at all.** There is no UI path left that +collects a CopilotKit key: onboarding's model step names only what still leaves the Mac (the chosen +model), `EngineBootstrap.environmentFactory` never passes `intelligence` to +`EngineEnvironment.Inputs`, and Settings has no "Conversation history" section. The engine's +dual-mode support and `EngineEnvironment.Intelligence` stay — a deployment could still configure +Intelligence by hand — but nothing in the shipped client can produce that configuration. + +## Consequences + +### What this buys + +ADR-0001's "Done when" criterion — a full conversation with no `INTELLIGENCE_*` variables set, +surviving a container restart — is met, without its 3–5 week estimate or its `HistoryProvider` +interface. The vendor's own reducer (`@ag-ui/client`) is what decides which messages are new; xBot's +code only decides what those messages become on disk. That is a much smaller merge surface than +rewriting `copilot.ts` against a hand-rolled interface would have been — consistent with "wrap +OpenBot, do not re-engineer it." + +Two client-side bugs surfaced and were fixed while wiring this: the thread-history GET used the +query-string route form (`?threadId=`), which the vendor's router matches as `threads/list` rather +than `threads/:id/messages`; and `WireMessage.init(row:)` only parsed AG-UI's nested tool-call shape, +silently dropping every historical tool call the flat local-mode row shape actually sends. Both were +pre-existing and would have surfaced against Intelligence too, once anyone had a key to find them +with. + +### What this does not buy + +**No memory or recall.** ADR-0001's pgvector-backed `recall`/`remember` is not part of this — a +`LocalThreadRunner` conversation remembers everything sent in it (the whole thread goes to the model +every run, same as Intelligence mode), but there is no semantic search over past conversations. That +gap is exactly ADR-0001's remaining scope, and it stays open. + +**No cross-user or hosted-realtime story.** `runnerConnection()` throws in local mode — there is no +platform websocket to reach — and `identifyUser` is not supported, both fine only because xBot is +single-user per container. + +**A `local_threads` row is not the audit trail.** It is durable storage for what a run needs to +answer the next one, not the append-only record invariant 3 protects. The two are unrelated and +neither substitutes for the other. + +### When we revisit + +- If xBot ever needs multiple people sharing one engine, this seam does not extend to that — it + would need `identifyUser` support the vendor's SSE mode does not have, at which point Intelligence + or a real `HistoryProvider` is back on the table. +- If recall/memory becomes a feature request, ADR-0001's pgvector design is still the plan for it; + `LocalThreadRunner` and a `HistoryProvider`'s memory half are not mutually exclusive. + +## Alternatives considered + +**Build ADR-0001 in full.** Rejected for now, on the same sequencing grounds ADR-0007 used: the +memory/recall half is real work with no measurement behind it yet, and a working conversation was +the blocking requirement, not tuned recall. + +**Wait for a CopilotKit key and ship Intelligence as designed.** Rejected as the only path: a +"fully working app" should not depend on a credential nobody asked for and this environment does not +have. ADR-0007's own seam — local mode, deferred but built — existed precisely so this would not be +a blocker. + +**Hand-roll a `HistoryProvider` against Postgres directly, skipping the vendor's runner.** Rejected: +the vendor's `InMemoryAgentRunner` already implements the exact surface `CopilotSseRuntime` calls and +already handles run bookkeeping (`isRunning`, thread event queues) correctly; duplicating it to fit a +new interface would be re-engineering the part upstream already does well, for no capability this +project currently needs. diff --git a/engine/server/drizzle.config.ts b/engine/server/drizzle.config.ts index a0fdf02..e67939d 100644 --- a/engine/server/drizzle.config.ts +++ b/engine/server/drizzle.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ "./src/db/schema/components.ts", "./src/db/schema/plugins.ts", "./src/db/schema/work.ts", + "./src/db/schema/history.ts", ], out: "./drizzle", dbCredentials: { diff --git a/engine/server/drizzle/0026_local_threads.sql b/engine/server/drizzle/0026_local_threads.sql new file mode 100644 index 0000000..a7942ba --- /dev/null +++ b/engine/server/drizzle/0026_local_threads.sql @@ -0,0 +1,7 @@ +CREATE TABLE "local_threads" ( + "thread_id" text PRIMARY KEY NOT NULL, + "agent_id" text NOT NULL, + "messages" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/engine/server/drizzle/meta/0026_snapshot.json b/engine/server/drizzle/meta/0026_snapshot.json new file mode 100644 index 0000000..40c67b4 --- /dev/null +++ b/engine/server/drizzle/meta/0026_snapshot.json @@ -0,0 +1,3065 @@ +{ + "id": "709156ca-06e3-403f-95e6-86632341d69e", + "prevId": "d96da430-35aa-475c-a060-f55151269d6d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": [ + "channel_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": [ + "channel_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": [ + "last_message_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tenant_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": [ + "user_id", + "channel_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": [ + "user_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": [ + "computer_id", + "tool_call_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": [ + "user_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "routine_runs_by_routine_idx": { + "name": "routine_runs_by_routine_idx", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_due_idx": { + "name": "routines_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_by_owner_idx": { + "name": "routines_by_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_owner_user_id_users_id_fk": { + "name": "routines_owner_user_id_users_id_fk", + "tableFrom": "routines", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_agent_id_agents_id_fk": { + "name": "routines_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": [ + "component_name", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": [ + "component_name" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": [ + "component_name", + "function_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": [ + "server_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": [ + "server_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": [ + "kind", + "ref", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": [ + "skill_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": [ + "skill_id", + "ref" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": [ + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.local_threads": { + "name": "local_threads", + "schema": "", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": [ + "built_in", + "remote_ag_ui" + ] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": [ + "public", + "private" + ] + }, + "public.routine_run_status": { + "name": "routine_run_status", + "schema": "public", + "values": [ + "succeeded", + "failed", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/engine/server/drizzle/meta/_journal.json b/engine/server/drizzle/meta/_journal.json index 0a0fb46..466177d 100644 --- a/engine/server/drizzle/meta/_journal.json +++ b/engine/server/drizzle/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1787926472382, "tag": "0025_backfill_existing_users_have_onboarded", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1790249856407, + "tag": "0026_local_threads", + "breakpoints": true } ] } \ No newline at end of file diff --git a/engine/server/src/copilot.ts b/engine/server/src/copilot.ts index 68e033e..f75615c 100644 --- a/engine/server/src/copilot.ts +++ b/engine/server/src/copilot.ts @@ -7,7 +7,7 @@ import { CopilotRuntime, } from "@copilotkit/runtime/v2"; import { createCopilotHonoHandler } from "@copilotkit/runtime/v2/hono"; -import { createLocalIntelligence } from "./history/local-intelligence"; +import type { LocalThreadRunner } from "./history/local-thread-runner"; import type { Observable } from "rxjs"; import { defer, from, switchMap } from "rxjs"; import { z } from "zod"; @@ -32,17 +32,18 @@ import type { GrantedTool } from "./plugins/tools"; import { grantedToolGuidance } from "./plugins/tools"; /** - * The CopilotKit runtime, always in Intelligence mode. + * The CopilotKit runtime, in Intelligence mode or in local mode. * * Package-declared built-in Bots run as CopilotKit `BuiltInAgent` instances. External Bots are * reached over AG-UI as `HttpAgent` instances, so anything that speaks the protocol remains a Bot * with no framework adapter here: LangGraph, Pydantic-AI, CrewAI, Mastra, ADK, or a hand-written * server. * - * There is no SSE branch. Intelligence is a requirement of the product, not a tier: it owns - * durable threads, memory and learning, and a deployment without it silently forgets every - * conversation. config.ts refuses to boot without the full contract, so by the time this runs the - * settings are present and this file has one mode. + * Upstream had no SSE branch, and its reason still holds: a deployment without durable threads + * silently forgets every conversation, so an in-memory runner is not a mode. Local mode is the SSE + * branch with durability added — `LocalThreadRunner` keeps threads in this deployment's database — + * and it is what xBot runs, because the native client speaks SSE (ADR-0008). Intelligence mode is + * unchanged. */ /** Resolve the signed-in person for a request. Threads and memory are scoped to whoever this returns. */ @@ -1053,8 +1054,31 @@ export function mountCopilotRuntime( * awaited in the lock path and a failure in it never touches whether the lock was taken. */ onRunBusy?: (input: { threadId: string; busy: boolean }) => void, + /** + * Where conversations are kept when there is no Intelligence (ADR-0008). Required in local mode: + * the vendor's fallback is an in-memory runner, and a conversation that silently forgets itself + * on restart is the degraded mode ADR-0007 rejected outright. + */ + localRunner?: LocalThreadRunner, ) { const capabilities = config.runtime; + if (capabilities.mode === "local") { + if (!localRunner) { + throw new Error( + "Local history needs its thread runner; mountCopilotRuntime was called without one.", + ); + } + /* + * One person or none. SSE mode has no `identifyUser`, so a thread is reachable by anybody who + * holds its id. That is the same boundary as a single-user deployment already has; with more + * than one person it would be a way to read somebody else's conversation. + */ + if (!config.singleUser) { + throw new Error( + "Local history keeps no per-person boundary, so it runs only with OPENBOT_SINGLE_USER on. A deployment for more than one person needs CopilotKit Intelligence.", + ); + } + } /** * The same Bot a person's run would get, built without a request. @@ -1110,26 +1134,9 @@ export function mountCopilotRuntime( wsUrl: capabilities.intelligence.gatewayWsUrl, apiKey: capabilities.intelligence.apiKey, }) - : createLocalIntelligence({ - selfUrl: config.appUrl ?? "http://127.0.0.1:3001", - }); + : undefined; - const runtime = new CopilotRuntime({ - // `mode` is inferred from the presence of `intelligence`; passing it is a type error. - // - // identifyUser is NOT optional in practice. Threads and memory are scoped to the user it - // returns, so omitting it puts every person in the deployment in the same thread space and one - // person's conversations become another's. - identifyUser, - // The subclass, not the base: a thread nobody has run yet reads as empty rather than as a 500. - // See IntelligenceKnowingANewThread. - intelligence: intelligenceClient, - // Telemetry only: the runtime stores it and derives a telemetry id from it, and validates - // nothing. A local deployment is not a licensed user of Intelligence and sends none. - licenseToken: - capabilities.mode === "intelligence" - ? capabilities.intelligence.licenseToken - : undefined, + const options = { // Carried on the events the runtime already sends, so OpenBot's traffic is separable from any // other deployment's. Adds no events of its own. ...(config.accessibility @@ -1174,7 +1181,54 @@ export function mountCopilotRuntime( agentFetch, handoffForActor, ) as never, - }); + }; + + // `mode` is inferred from the presence of `intelligence`; passing it is a type error. + const runtime = intelligenceClient + ? new CopilotRuntime({ + ...options, + // identifyUser is NOT optional in practice. Threads and memory are scoped to the user it + // returns, so omitting it puts every person in the deployment in the same thread space and + // one person's conversations become another's. + identifyUser, + // The subclass, not the base: a thread nobody has run yet reads as empty rather than as a + // 500. See IntelligenceKnowingANewThread. + intelligence: intelligenceClient, + // Telemetry only: the runtime stores it and derives a telemetry id from it, and validates + // nothing. A local deployment is not a licensed user of Intelligence and sends none. + licenseToken: + capabilities.mode === "intelligence" + ? capabilities.intelligence.licenseToken + : undefined, + }) + : // SSE mode: runs stream back on the request, and threads are answered by the runner. There is + // no `identifyUser` to pass — see the single-user guard above. + new CopilotRuntime({ ...options, runner: localRunner }); + + /* + * The conversation's run lock, with no platform to issue it. + * + * The runner already refuses a second run on a busy thread, so asking it is the whole lock, and + * there is no expiry to renew. The busy signal is kept, because a channel showing it is working is + * the same promise in either mode. + */ + const localThreadLock = { + acquire: async (input: { threadId: string; runId: string }) => { + if (await localRunner?.isRunning({ threadId: input.threadId })) { + return null; + } + try { + onRunBusy?.({ threadId: input.threadId, busy: true }); + } catch {} + return { runId: input.runId }; + }, + renew: async () => {}, + release: async (input: { threadId: string }) => { + try { + onRunBusy?.({ threadId: input.threadId, busy: false }); + } catch {} + }, + }; return { handler: createCopilotHonoHandler({ runtime, basePath }), @@ -1187,10 +1241,17 @@ export function mountCopilotRuntime( * the token that holds it is not the API key. The runtime asks the client for both, so anything * else driving a run has to ask the same client the same way. */ - runnerConnection: () => ({ - url: intelligenceClient.ɵgetRunnerWsUrl(), - authToken: intelligenceClient.ɵgetRunnerAuthToken(), - }), + runnerConnection: () => { + if (!intelligenceClient) { + throw new Error( + "A local deployment runs every turn through its own thread runner; there is no platform runner to reach.", + ); + } + return { + url: intelligenceClient.ɵgetRunnerWsUrl(), + authToken: intelligenceClient.ɵgetRunnerAuthToken(), + }; + }, /** * The conversation's run lock, as the platform issues it. * @@ -1201,65 +1262,69 @@ export function mountCopilotRuntime( * * A conversation somebody else is already running in refuses rather than queues, which is right: * the caller waits and tries again rather than two Bots writing over each other. + * + * Locally the runner is the authority instead; see `localThreadLock` above. */ - threadLock: { - acquire: async (input: { - threadId: string; - runId: string; - userId: string; - agentId: string; - }) => { - try { - const held = await intelligenceClient.ɵacquireThreadLock(input); - // A run started on this thread. Side effect only, never awaited: a channel showing it is - // working is worth nothing next to the lock the run depends on. - try { - onRunBusy?.({ threadId: input.threadId, busy: true }); - } catch {} - /* - * The run id only. The lock also hands back a join token, which is what a browser presents - * to watch the conversation; the runner's socket has its own credential and passing this - * one in place of it means a socket that is refused and a run that never starts. See the - * note on `runner.run` in handoff-delivery.ts. - */ - return { runId: held.runId }; - } catch (error) { - /* - * ONLY A CONFLICT MEANS "NOT NOW". Everything else is raised. - * - * A conversation somebody is already running in answers 409, and that is ordinary: the hop - * waits and is tried again. Anything else is not — a platform that cannot be reached, a - * token that stopped working, or one of the underscored APIs below being renamed by a - * routine version bump. Returned as `null` those all read as contention: every hop retries - * to exhaustion, every person is told their question was never answered, and the only - * evidence is a warning line that looks like a busy conversation. - * - * Raised, the runner writes the real reason onto `agent.handoff_failed`, and the sentence - * the person eventually gets names it. - */ - const status = - error instanceof Error && "status" in error - ? (error as { status?: unknown }).status - : undefined; - if (status === 409) return null; - throw error; + threadLock: intelligenceClient + ? { + acquire: async (input: { + threadId: string; + runId: string; + userId: string; + agentId: string; + }) => { + try { + const held = await intelligenceClient.ɵacquireThreadLock(input); + // A run started on this thread. Side effect only, never awaited: a channel showing it is + // working is worth nothing next to the lock the run depends on. + try { + onRunBusy?.({ threadId: input.threadId, busy: true }); + } catch {} + /* + * The run id only. The lock also hands back a join token, which is what a browser presents + * to watch the conversation; the runner's socket has its own credential and passing this + * one in place of it means a socket that is refused and a run that never starts. See the + * note on `runner.run` in handoff-delivery.ts. + */ + return { runId: held.runId }; + } catch (error) { + /* + * ONLY A CONFLICT MEANS "NOT NOW". Everything else is raised. + * + * A conversation somebody is already running in answers 409, and that is ordinary: the hop + * waits and is tried again. Anything else is not — a platform that cannot be reached, a + * token that stopped working, or one of the underscored APIs below being renamed by a + * routine version bump. Returned as `null` those all read as contention: every hop retries + * to exhaustion, every person is told their question was never answered, and the only + * evidence is a warning line that looks like a busy conversation. + * + * Raised, the runner writes the real reason onto `agent.handoff_failed`, and the sentence + * the person eventually gets names it. + */ + const status = + error instanceof Error && "status" in error + ? (error as { status?: unknown }).status + : undefined; + if (status === 409) return null; + throw error; + } + }, + renew: async (input: { threadId: string; runId: string }) => { + await intelligenceClient.ɵrenewThreadLock({ + ...input, + ttlSeconds: THREAD_LOCK_TTL_SECONDS, + }); + }, + release: async (input: { threadId: string; runId: string }) => { + // The run on this thread is over. Cleared here rather than trusting a browser: the run may + // have outlived the tab that started it, and this is where the platform is told it ended. + try { + onRunBusy?.({ threadId: input.threadId, busy: false }); + } catch {} + await intelligenceClient.ɵcleanupThreadLock(input); + }, } - }, - renew: async (input: { threadId: string; runId: string }) => { - await intelligenceClient.ɵrenewThreadLock({ - ...input, - ttlSeconds: THREAD_LOCK_TTL_SECONDS, - }); - }, - release: async (input: { threadId: string; runId: string }) => { - // The run on this thread is over. Cleared here rather than trusting a browser: the run may - // have outlived the tab that started it, and this is where the platform is told it ended. - try { - onRunBusy?.({ threadId: input.threadId, busy: false }); - } catch {} - await intelligenceClient.ɵcleanupThreadLock(input); - }, - }, + : localThreadLock, agentFor, /** * A thread's messages, as the platform holds them. @@ -1268,6 +1333,9 @@ export function mountCopilotRuntime( * than a second view of it that could disagree. */ history: async (input: { threadId: string; actorId: string }) => { + if (!intelligenceClient) { + return localRunner?.getThreadMessages(input.threadId) ?? []; + } /* * The platform's own message type rather than AG-UI's, inferred rather than named: the two are * compatible where it matters and naming the wrong one here would mean converting a history diff --git a/engine/server/src/db/schema/history.ts b/engine/server/src/db/schema/history.ts new file mode 100644 index 0000000..40b46d7 --- /dev/null +++ b/engine/server/src/db/schema/history.ts @@ -0,0 +1,23 @@ +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +// NOT drizzle's `jsonb`; see ./json.ts. +import { jsonb } from "./json"; + +/** + * Conversations kept by this deployment rather than by CopilotKit Intelligence (ADR-0008). + * + * One row per thread, holding its AG-UI messages in order. Written by `LocalThreadRunner` when a run + * ends and read back into memory at boot. No user column: local history runs only in single-user + * mode, where there is one person to scope to. + */ +export const localThreads = pgTable("local_threads", { + threadId: text("thread_id").primaryKey(), + agentId: text("agent_id").notNull(), + /** `{ messages: Message[] }` — an object because the column type is. */ + messages: jsonb("messages").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); diff --git a/engine/server/src/db/schema/index.ts b/engine/server/src/db/schema/index.ts index 68ee5d8..de905ee 100644 --- a/engine/server/src/db/schema/index.ts +++ b/engine/server/src/db/schema/index.ts @@ -4,5 +4,6 @@ export * from "./components"; export * from "./computer"; export * from "./core"; export * from "./coworker"; +export * from "./history"; export * from "./plugins"; export * from "./work"; diff --git a/engine/server/src/history/local-intelligence.ts b/engine/server/src/history/local-intelligence.ts index d8a227b..f52134b 100644 --- a/engine/server/src/history/local-intelligence.ts +++ b/engine/server/src/history/local-intelligence.ts @@ -3,6 +3,11 @@ import type { CopilotKitIntelligence } from "@copilotkit/runtime/v2"; /** * The platform client, answered locally instead of by CopilotKit Intelligence. * + * NO LONGER ON THE CONVERSATION PATH. Conversations in local mode run through the runtime's SSE + * branch and `LocalThreadRunner` (ADR-0008), which answers what this spike was built to enumerate. + * What still reaches this is the `/api/threads` status reader in `app.ts`, which only the upstream + * browser app calls. The rest of this note is the spike as it was written. + * * SPIKE. Nothing here stores anything yet. Every method records that it was reached and then * throws, except the handful that are called at wiring time and whose return value is used * synchronously — those answer with a local placeholder so the process can finish booting. diff --git a/engine/server/src/history/local-thread-runner.ts b/engine/server/src/history/local-thread-runner.ts new file mode 100644 index 0000000..b1bc265 --- /dev/null +++ b/engine/server/src/history/local-thread-runner.ts @@ -0,0 +1,145 @@ +import type { Message } from "@ag-ui/client"; +import { + type AgentRunnerRunRequest, + InMemoryAgentRunner, +} from "@copilotkit/runtime/v2"; +import type { Database } from "../db/client"; +import { localThreads } from "../db/schema"; +import type { IntelligenceLike } from "../routines/run-turn"; + +/** + * Conversations kept in this deployment's own database. ADR-0008. + * + * THE VENDOR'S OWN LOCAL PATH, MADE DURABLE, rather than a reimplementation of the Intelligence + * client. A `CopilotRuntime` built without `intelligence` runs in SSE mode: `/agent/:id/run` streams + * the run back as server-sent events, and `/threads/:id/messages` answers from the runner. Both + * already exist upstream for `InMemoryAgentRunner`; what that runner lacks is surviving a restart. + * This adds exactly that and nothing else — the runs themselves are still the vendor's. + * + * SSE mode is also the only mode the native client can use. In Intelligence mode a run answers with + * a join token for CopilotKit's realtime socket instead of a stream, which the Mac app never spoke. + * + * WHAT IS KEPT is what a transcript should show, not what the model was sent: the run's + * `persistedInputMessages` when the caller names them (a hop, a routine), otherwise the messages the + * run carried, and then whatever the run produced. Kept messages are merged by id, so a caller that + * sends only the newest message and one that resends the whole conversation both leave one copy of + * each message. + * + * ponytail: every thread is loaded into memory at boot, because the runtime reads thread messages + * synchronously. One person's history on one Mac is small; load per thread on first read if that + * stops being true. + */ +export class LocalThreadRunner extends InMemoryAgentRunner { + private readonly kept = new Map(); + /** One write at a time, so two runs that end close together land in the order they ended. */ + private writes: Promise = Promise.resolve(); + + constructor(private readonly database: Database) { + super(); + } + + /** Read every kept conversation back. Call once, before the runtime serves anything. */ + async hydrate(): Promise { + const rows = await this.database.select().from(localThreads); + for (const row of rows) { + this.kept.set( + row.threadId, + (row.messages as { messages?: Message[] }).messages ?? [], + ); + } + } + + override run(request: AgentRunnerRunRequest) { + const carried = request.agent.messages; + const before = new Set(carried.map((message) => message.id)); + const shown = request.persistedInputMessages ?? carried; + const events = super.run(request); + const keep = () => + this.keep(request.threadId, request.agent.agentId ?? "default", [ + ...shown, + ...request.agent.messages.filter((message) => !before.has(message.id)), + ]); + // Both ends, because an observer without `error` turns a failed run into an uncaught exception. + events.subscribe({ complete: keep, error: keep }); + return events; + } + + override getThreadMessages(threadId: string): Message[] { + return [...(this.kept.get(threadId) ?? [])]; + } + + private keep(threadId: string, agentId: string, messages: Message[]) { + const prior = this.kept.get(threadId) ?? []; + const known = new Set(prior.map((message) => message.id)); + const next = [ + ...prior, + ...messages.filter((message) => !known.has(message.id)), + ]; + this.kept.set(threadId, next); + + const row = { threadId, agentId, messages: { messages: next } }; + this.writes = this.writes + .then(() => + this.database + .insert(localThreads) + .values(row) + .onConflictDoUpdate({ + target: localThreads.threadId, + set: { messages: row.messages, updatedAt: new Date() }, + }), + ) + .catch((error: unknown) => { + // Not thrown: the conversation carries on from memory. What is lost is this turn surviving a + // restart, and the log is the only place that can be said without failing a finished run. + console.error( + JSON.stringify({ + type: "local-history-write-failed", + threadId, + error: error instanceof Error ? error.message : String(error), + }), + ); + }); + } +} + +/** + * The routine runner's view of a local conversation, shaped like the Intelligence client it expects. + * + * History comes back in the platform's row shape — the same mapping the runtime's own local + * `/threads/:id/messages` applies — because `run-turn.ts` converts from that shape and nothing else. + * The lock is the runner's own refusal to run twice on one thread; there is no expiry to renew. + */ +export function localRoutineIntelligence( + runner: LocalThreadRunner, +): IntelligenceLike { + return { + getOrCreateThread: async () => ({}), + getThreadMessages: async ({ threadId }) => ({ + messages: runner.getThreadMessages(threadId).map((message) => ({ + id: message.id, + role: message.role, + content: (message as { content?: unknown }).content, + ...(message.role === "assistant" && message.toolCalls?.length + ? { + toolCalls: message.toolCalls.map((call) => ({ + id: call.id, + name: call.function.name, + args: call.function.arguments, + })), + } + : {}), + ...(message.role === "tool" ? { toolCallId: message.toolCallId } : {}), + })), + }), + ɵacquireThreadLock: async ({ threadId }) => { + if (await runner.isRunning({ threadId })) { + throw Object.assign(new Error(`${threadId} is busy with another run`), { + status: 409, + }); + } + return {}; + }, + ɵrenewThreadLock: async () => ({}), + ɵcleanupThreadLock: async () => {}, + }; +} diff --git a/engine/server/src/index.ts b/engine/server/src/index.ts index af8ef5a..d7f31d4 100644 --- a/engine/server/src/index.ts +++ b/engine/server/src/index.ts @@ -61,6 +61,10 @@ import { } from "./credentials"; import { createDatabase } from "./db/client"; import { intelligenceChannelMappings } from "./db/schema"; +import { + LocalThreadRunner, + localRoutineIntelligence, +} from "./history/local-thread-runner"; import { createOnboardingStore } from "./people/onboarding"; import { createPeopleStore } from "./people/store"; import { useRoutineTools } from "./plugins/builtin-routines"; @@ -156,6 +160,13 @@ if ( const port = Number.parseInt(rawPort, 10); const database = createDatabase(config.databaseUrl); await initializeDevActorUser(database, config.singleUser); +/* + * Where conversations live when there is no Intelligence (ADR-0008). Read back before anything can + * ask for one, because the runtime reads a thread's messages synchronously. + */ +const localThreadRunner = + config.runtime.mode === "local" ? new LocalThreadRunner(database) : undefined; +await localThreadRunner?.hydrate(); // The vault, built before the agent store because a customer's agent may sit behind a key and that // key belongs here rather than on the agent row. See agents/auth-header.ts. const credentialStore = createCredentialStore(database); @@ -697,11 +708,10 @@ const buildAgentFor = async ({ * * THE SECOND MODE ARRIVED, and this is the guard the previous version of this comment said would * have to be written. A local deployment has no Intelligence settings to build the pair from and no - * gateway to drive a headless turn through, so it gets no routine runner, and `createApp` leaves - * `/internal/routines/run` unmounted rather than mounted over a runner that cannot run — which is - * what the optional parameter there has always been for. Routines are dark in local mode until a - * local runner exists; a routine that fires and fails is worse than one that was never scheduled, - * so the routes go too. See ADR-0001 and `routines/run-turn.ts`. + * gateway to drive a headless turn through. It drives the turn through `LocalThreadRunner` instead — + * the same runner a person's own messages go through, so a routine's turn lands in the conversation + * the person reads — with `localRoutineIntelligence` standing in for the client. See ADR-0008 and + * `routines/run-turn.ts`. * * One runner for the process, reused across firings: it opens a socket per run and holds no idle * connection, but its `threads` map is per instance, and a runner per turn would fragment the @@ -729,7 +739,16 @@ const routineRunner = }), }); })() - : undefined; + : localThreadRunner && + createRoutineRunner({ + routineStore, + channelStore, + runTurn: createTurnRunner({ + intelligence: localRoutineIntelligence(localThreadRunner), + runner: localThreadRunner, + buildAgentFor, + }), + }); /** * The runtime, and the two things beside it a hop needs. @@ -834,6 +853,7 @@ const copilotRuntime = mountCopilotRuntime( (input) => { void channelStore.signalBusy(input.threadId, input.busy).catch(() => {}); }, + localThreadRunner, ); /** @@ -935,10 +955,12 @@ if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) { newRunId: () => randomUUID(), // The same address and the same token the runtime uses. Assembling either from configuration // produced a runner every join was refused for, because the thread's active run is a lock the - // platform issues rather than something an API key can claim. - runner: new IntelligenceAgentRunner( - copilotRuntime.runnerConnection(), - ) as never, + // platform issues rather than something an API key can claim. Locally, the runtime's own + // runner: a hop's answer is kept exactly where a person's would be. + runner: (localThreadRunner ?? + new IntelligenceAgentRunner( + copilotRuntime.runnerConnection(), + )) as never, }), }); diff --git a/engine/server/tests/local-thread-runner.integration.test.ts b/engine/server/tests/local-thread-runner.integration.test.ts new file mode 100644 index 0000000..1b28105 --- /dev/null +++ b/engine/server/tests/local-thread-runner.integration.test.ts @@ -0,0 +1,191 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import type { BaseEvent, Message, RunAgentInput } from "@ag-ui/client"; +import { AbstractAgent, EventType } from "@ag-ui/client"; +import { eq } from "drizzle-orm"; +import { lastValueFrom, Observable } from "rxjs"; +import { createDatabase } from "../src/db/client"; +import { localThreads } from "../src/db/schema"; +import { + LocalThreadRunner, + localRoutineIntelligence, +} from "../src/history/local-thread-runner"; +import { TEST_POOL } from "./support/database"; + +/** + * Local history, against a real database: what a restart gives back is what the person saw. + * + * The runner's own runs are the vendor's; what this file guards is the part xBot added — that a turn + * is kept, kept once, kept as the transcript rather than the prompt, and read back after a restart. + * The last of those is the one the Mac app depends on and the one an in-memory runner silently fails. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); +const suite = randomUUID().slice(0, 8); +const threads: string[] = []; + +afterAll(async () => { + for (const threadId of threads) { + await database + .delete(localThreads) + .where(eq(localThreads.threadId, threadId)); + } +}); + +/** Answers every run with one assistant message, `reply-`. */ +class Echo extends AbstractAgent { + run(input: RunAgentInput) { + return new Observable((subscriber) => { + const messageId = `reply-${input.runId}`; + subscriber.next({ + type: EventType.RUN_STARTED, + threadId: input.threadId, + runId: input.runId, + } as BaseEvent); + subscriber.next({ + type: EventType.TEXT_MESSAGE_START, + messageId, + role: "assistant", + } as BaseEvent); + subscriber.next({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: "heard you", + } as BaseEvent); + subscriber.next({ + type: EventType.TEXT_MESSAGE_END, + messageId, + } as BaseEvent); + subscriber.next({ + type: EventType.RUN_FINISHED, + threadId: input.threadId, + runId: input.runId, + } as BaseEvent); + subscriber.complete(); + }); + } +} + +function newThread(): string { + const threadId = `thread_local_${suite}_${threads.length}`; + threads.push(threadId); + return threadId; +} + +async function turn( + runner: LocalThreadRunner, + threadId: string, + messages: Message[], + persistedInputMessages?: Message[], +) { + const agent = new Echo(); + agent.agentId = "bot_echo"; + agent.setMessages(messages); + const runId = randomUUID(); + await lastValueFrom( + runner.run({ + threadId, + agent, + input: { + threadId, + runId, + messages, + state: {}, + tools: [], + context: [], + forwardedProps: {}, + }, + ...(persistedInputMessages ? { persistedInputMessages } : {}), + }), + { defaultValue: undefined }, + ); + return runId; +} + +/** The write is queued behind the run's end; wait for the row rather than for a timer. */ +async function stored(threadId: string, count: number) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const [row] = await database + .select() + .from(localThreads) + .where(eq(localThreads.threadId, threadId)); + const messages = (row?.messages as { messages?: Message[] } | undefined) + ?.messages; + if (messages && messages.length >= count) return messages; + await Bun.sleep(20); + } + throw new Error(`${threadId} never reached ${count} stored messages`); +} + +const user = (id: string, content: string): Message => ({ + id, + role: "user", + content, +}); + +describe("a conversation kept locally", () => { + test("survives a restart", async () => { + const threadId = newThread(); + const runId = await turn(new LocalThreadRunner(database), threadId, [ + user("u1", "hello"), + ]); + await stored(threadId, 2); + + const restarted = new LocalThreadRunner(database); + await restarted.hydrate(); + + expect( + restarted.getThreadMessages(threadId).map((message) => message.id), + ).toEqual(["u1", `reply-${runId}`]); + }); + + test("keeps one copy when the whole conversation is sent again", async () => { + const threadId = newThread(); + const runner = new LocalThreadRunner(database); + const first = await turn(runner, threadId, [user("u1", "hello")]); + const history = runner.getThreadMessages(threadId); + const second = await turn(runner, threadId, [ + ...history, + user("u2", "again"), + ]); + + const ids = ["u1", `reply-${first}`, "u2", `reply-${second}`]; + expect(runner.getThreadMessages(threadId).map((m) => m.id)).toEqual(ids); + expect((await stored(threadId, 4)).map((m) => m.id)).toEqual(ids); + }); + + test("keeps what the transcript shows, not the prompt the model was sent", async () => { + const threadId = newThread(); + const runner = new LocalThreadRunner(database); + const runId = await turn( + runner, + threadId, + [user("prompt", "a paragraph of instructions for the model")], + [user("shown", "Handed over by the other Bot")], + ); + + expect(runner.getThreadMessages(threadId).map((m) => m.id)).toEqual([ + "shown", + `reply-${runId}`, + ]); + }); + + test("answers a routine in the platform's row shape", async () => { + const threadId = newThread(); + const runner = new LocalThreadRunner(database); + const runId = await turn(runner, threadId, [user("u1", "hello")]); + + const { messages } = await localRoutineIntelligence( + runner, + ).getThreadMessages({ threadId, userId: "anyone" }); + + expect(messages).toEqual([ + { id: "u1", role: "user", content: "hello" }, + { id: `reply-${runId}`, role: "assistant", content: "heard you" }, + ]); + }); +});