Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ enum AgentDemoRuntimeFactory {
approvalInbox: ApprovalInbox,
deviceCodePromptCoordinator: DeviceCodePromptCoordinator
) -> AgentRuntime {
let diagnostics = DemoDiagnostics()
let sdkLogging = diagnostics.sdkLoggingConfiguration()
let authProvider: any ChatGPTAuthProviding

switch authenticationMethod {
Expand All @@ -98,13 +100,21 @@ enum AgentDemoRuntimeFactory {
configuration: CodexResponsesBackendConfiguration(
model: model,
reasoningEffort: reasoningEffort,
enableWebSearch: enableWebSearch
enableWebSearch: enableWebSearch,
logging: sdkLogging
)
),
approvalPresenter: approvalInbox,
stateStore: try! GRDBRuntimeStateStore(url: stateURL ?? defaultStateURL()),
stateStore: try! GRDBRuntimeStateStore(
url: stateURL ?? defaultStateURL(),
logging: sdkLogging
),
logging: sdkLogging,
memory: .init(
store: try! SQLiteMemoryStore(url: defaultMemoryURL()),
store: try! SQLiteMemoryStore(
url: defaultMemoryURL(),
logging: sdkLogging
),
automaticCapturePolicy: .init(
source: .lastTurn,
options: .init(
Expand Down Expand Up @@ -137,6 +147,8 @@ enum AgentDemoRuntimeFactory {
reasoningEffort: ReasoningEffort = .medium,
keychainAccount: String = defaultKeychainAccount
) -> AgentRuntime {
let diagnostics = DemoDiagnostics()
let sdkLogging = diagnostics.sdkLoggingConfiguration()
let authProvider = try! ChatGPTAuthProvider(method: .oauth)

return try! AgentRuntime(configuration: .init(
Expand All @@ -149,13 +161,21 @@ enum AgentDemoRuntimeFactory {
configuration: CodexResponsesBackendConfiguration(
model: model,
reasoningEffort: reasoningEffort,
enableWebSearch: enableWebSearch
enableWebSearch: enableWebSearch,
logging: sdkLogging
)
),
approvalPresenter: NonInteractiveApprovalPresenter(),
stateStore: try! GRDBRuntimeStateStore(url: defaultStateURL()),
stateStore: try! GRDBRuntimeStateStore(
url: defaultStateURL(),
logging: sdkLogging
),
logging: sdkLogging,
memory: .init(
store: try! SQLiteMemoryStore(url: defaultMemoryURL()),
store: try! SQLiteMemoryStore(
url: defaultMemoryURL(),
logging: sdkLogging
),
automaticCapturePolicy: .init(
source: .lastTurn,
options: .init(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ extension AgentDemoView {
)
.toggleStyle(.switch)

Text("Debug builds start with logging enabled. Logs print to the Xcode console for restore, sign-in, thread lifecycle, turn events, and tool activity.")
Text("Debug builds start with logging enabled. Logs print to the Xcode console for both demo actions and SDK internals like history writes, thread creation, network calls, retries, compaction, and tool activity.")
.font(.caption)
.foregroundStyle(.secondary)

Expand Down
44 changes: 44 additions & 0 deletions DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ struct DemoDiagnostics {
#endif
}

func developerLoggingEnabled(userDefaults: UserDefaults = .standard) -> Bool {
initialDeveloperLoggingEnabled(userDefaults: userDefaults)
}

func persistDeveloperLoggingEnabled(
_ enabled: Bool,
userDefaults: UserDefaults = .standard
Expand All @@ -176,6 +180,46 @@ struct DemoDiagnostics {
logger.error("\(message, privacy: .public)")
print("[CodexKit Demo][Error] \(message)")
}

func sdkLoggingConfiguration() -> AgentLoggingConfiguration {
AgentLoggingConfiguration(
minimumLevel: .debug,
sink: DemoSDKLogSink(diagnostics: self)
)
}
}

struct DemoSDKLogSink: AgentLogSink {
let diagnostics: DemoDiagnostics

func log(_ entry: AgentLogEntry) {
guard diagnostics.developerLoggingEnabled() else {
return
}

let levelLabel: String
switch entry.level {
case .debug:
levelLabel = "DEBUG"
case .info:
levelLabel = "INFO"
case .warning:
levelLabel = "WARN"
case .error:
levelLabel = "ERROR"
}

var message = "[SDK][\(levelLabel)][\(entry.category.rawValue)] \(entry.message)"
if !entry.metadata.isEmpty {
let renderedMetadata = entry.metadata
.sorted { $0.key < $1.key }
.map { "\($0.key)=\($0.value)" }
.joined(separator: " ")
message += " | \(renderedMetadata)"
}

diagnostics.log(message)
}
}

@MainActor
Expand Down
91 changes: 86 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Use `CodexKit` if you are building a SwiftUI app for iOS or macOS and want:
- host-defined tools with approval gates
- persona- and skill-aware agent behavior
- hidden runtime context compaction with preserved user-visible history
- opt-in developer logging across runtime, auth, backend, and bundled stores
- share/import-friendly message construction

The SDK stays tool-agnostic. Your app defines the tool surface and runtime UX.
Expand Down Expand Up @@ -227,12 +228,92 @@ let backend = CodexResponsesBackend(
)
```

Available values:
## Developer Logging

- `.low`
- `.medium`
- `.high`
- `.extraHigh`
`CodexKit` includes opt-in developer logging for the SDK itself. Logging is disabled by default and can be enabled independently on the runtime, built-in backend, and bundled stores.

```swift
let logging = AgentLoggingConfiguration.console(
minimumLevel: .debug
)

let backend = CodexResponsesBackend(
configuration: .init(
model: "gpt-5.4",
logging: logging
)
)

let stateStore = try GRDBRuntimeStateStore(
url: stateURL,
logging: logging
)

let runtime = try AgentRuntime(configuration: .init(
authProvider: authProvider,
secureStore: secureStore,
backend: backend,
approvalPresenter: approvalInbox,
stateStore: stateStore,
logging: logging
))
```

You can also filter by category:

```swift
let logging = AgentLoggingConfiguration.osLog(
minimumLevel: .debug,
categories: [.runtime, .persistence, .network, .tools],
subsystem: "com.example.myapp"
)
```

Available logging categories include:

- `auth`
- `runtime`
- `persistence`
- `network`
- `retry`
- `compaction`
- `tools`
- `approvals`
- `structuredOutput`
- `memory`

Use `AgentConsoleLogSink` for stderr-style console logs, `AgentOSLogSink` for unified Apple logging, or provide your own `AgentLogSink`.

Custom sinks make it possible to bridge `CodexKit` logs into your own telemetry or logging pipeline:

```swift
struct RemoteTelemetrySink: AgentLogSink {
func log(_ entry: AgentLogEntry) {
Telemetry.shared.enqueue(
level: entry.level,
category: entry.category.rawValue,
message: entry.message,
metadata: entry.metadata,
timestamp: entry.timestamp
)
}
}

let logging = AgentLoggingConfiguration(
minimumLevel: .info,
sink: RemoteTelemetrySink()
)
```

`AgentLogEntry` includes:

- timestamp
- level
- category
- message
- metadata

For remote telemetry or file-backed logging, prefer a sink that buffers or enqueues work quickly. `AgentLogSink.log(_:)` is called inline, so it should avoid blocking network I/O on the caller's execution path.

## Persistent State And Queries

Expand Down
42 changes: 41 additions & 1 deletion Sources/CodexKit/Auth/ChatGPTSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@ import Foundation
public actor ChatGPTSessionManager {
private let authProvider: any ChatGPTAuthProviding
private let secureStore: any SessionSecureStoring
private let logger: AgentLogger
private var session: ChatGPTSession?

public init(
authProvider: any ChatGPTAuthProviding,
secureStore: any SessionSecureStoring
secureStore: any SessionSecureStoring,
logging: AgentLoggingConfiguration = .disabled
) {
self.authProvider = authProvider
self.secureStore = secureStore
self.logger = AgentLogger(configuration: logging)
}

@discardableResult
public func restore() throws -> ChatGPTSession? {
let restored = try secureStore.loadSession()
session = restored
logger.debug(
.auth,
"Restored session from secure store.",
metadata: ["restored": "\(restored != nil)"]
)
return restored
}

Expand All @@ -29,15 +37,33 @@ public actor ChatGPTSessionManager {
let signedInSession = try await authProvider.signInInteractively()
try secureStore.saveSession(signedInSession)
session = signedInSession
logger.info(
.auth,
"Persisted signed-in session.",
metadata: ["account_id": signedInSession.account.id]
)
return signedInSession
}

@discardableResult
public func refresh(reason: ChatGPTAuthRefreshReason) async throws -> ChatGPTSession {
let current = try requireStoredSession()
logger.info(
.auth,
"Refreshing session.",
metadata: [
"reason": String(describing: reason),
"account_id": current.account.id
]
)
let refreshed = try await authProvider.refresh(session: current, reason: reason)
try secureStore.saveSession(refreshed)
session = refreshed
logger.info(
.auth,
"Session refresh completed.",
metadata: ["account_id": refreshed.account.id]
)
return refreshed
}

Expand All @@ -46,6 +72,11 @@ public actor ChatGPTSessionManager {
session = nil
try secureStore.deleteSession()
await authProvider.signOut(session: current)
logger.info(
.auth,
"Session signed out.",
metadata: ["had_session": "\(current != nil)"]
)
}

public func requireSession() async throws -> ChatGPTSession {
Expand All @@ -61,11 +92,20 @@ public actor ChatGPTSessionManager {
public func recoverUnauthorizedSession(
previousAccessToken: String?
) async throws -> ChatGPTSession {
logger.warning(
.auth,
"Attempting unauthorized-session recovery."
)
if let restored = try secureStore.loadSession() {
session = restored
if let previousAccessToken,
restored.accessToken != previousAccessToken,
!restored.requiresRefresh() {
logger.info(
.auth,
"Recovered session from secure store after unauthorized response.",
metadata: ["account_id": restored.account.id]
)
return restored
}
}
Expand Down
Loading
Loading