import Foundation enum AgentRunCheckpointUpdate: Sendable, Equatable { case phase(AgentRunPhase) case activeCall(AgentToolCall, phase: AgentRunPhase) case counters(toolCallsUsed: Int, modelTurns: Int) case completedReceipt( AgentToolReceipt, toolCallsUsed: Int, modelTurns: Int ) } struct AgentLoopCallbacks: Sendable { let normalizeTool: @Sendable (AgentToolCall) async throws -> AgentToolCall let executeTool: @Sendable (AgentToolCall) async throws -> AgentToolResult let requestApproval: @MainActor @Sendable (ToolApprovalRequest) async -> Bool let emit: @MainActor @Sendable (AgentEvent) -> Void let modelProgress: @MainActor @Sendable (DolphinGenerationProgress) -> Void let checkpoint: @MainActor @Sendable (AgentRunCheckpointUpdate) async throws -> Void init( normalizeTool: @escaping @Sendable (AgentToolCall) async throws -> AgentToolCall, executeTool: @escaping @Sendable (AgentToolCall) async throws -> AgentToolResult, requestApproval: @escaping @MainActor @Sendable ( ToolApprovalRequest ) async -> Bool, emit: @escaping @MainActor @Sendable (AgentEvent) -> Void, modelProgress: @escaping @MainActor @Sendable ( DolphinGenerationProgress ) -> Void, checkpoint: @escaping @MainActor @Sendable ( AgentRunCheckpointUpdate ) async throws -> Void = { _ in } ) { self.normalizeTool = normalizeTool self.executeTool = executeTool self.requestApproval = requestApproval self.emit = emit self.modelProgress = modelProgress self.checkpoint = checkpoint } } enum AgentRunOutcome: Sendable, Equatable { case succeeded case failed } struct AgentRunResult: Sendable, Equatable { let finalText: String let outcome: AgentRunOutcome let modelTurns: Int let toolCalls: Int let trustedToolResults: Int let failedToolCalls: Int } private struct AgentFinalPresentation: Sendable { let text: String let outcome: AgentRunOutcome let status: AgentEventStatus let title: String let detail: String } enum AgentLoopError: LocalizedError, Sendable, Equatable { case timeLimitReached case emptyFinalResponse var errorDescription: String? { switch self { case .timeLimitReached: "The assistant reached its time limit." case .emptyFinalResponse: "The model returned an empty final response." } } } actor AgentLoop { private let runtime: any DolphinModelRuntimeProtocol private let dateProvider: any DateProviding init( runtime: any DolphinModelRuntimeProtocol, dateProvider: any DateProviding = SystemDateProvider() ) { self.runtime = runtime self.dateProvider = dateProvider } func run( runID: UUID, conversation: [AssistantMessage], memoryContext: [MemoryItem] = [], settings: AgentSettings, toolDefinitions: [AgentToolDefinition], callbacks: AgentLoopCallbacks ) async throws -> AgentRunResult { try Task.checkCancellation() let limits = sanitized(settings) let clock = ContinuousClock() let deadline = clock.now.advanced( by: .seconds(limits.maxRunSeconds) ) let networkEligibleTools = toolDefinitions.filter { $0.risk != .networkRead || limits.networkAccessEnabled } let latestUserText = conversation.last(where: { $0.role == .user })?.content ?? "" var requestPlan = AgentRequestRouter.plan(for: latestUserText) let availableToolIDs = Set(networkEligibleTools.map(\.id)) let canonicalAvailableIDs = availableToolIDs.intersection( AgentRequestRouter.canonicalToolIDs ) // Protocol-test and embedding clients may supply a wholly custom registry. // The production registry always has canonical IDs, so only the custom-only // surface bypasses production deterministic routing. if canonicalAvailableIDs.isEmpty, !availableToolIDs.isEmpty { requestPlan = AgentRequestPlan( initialToolCall: nil, eligibleToolIDs: availableToolIDs ) } var selectedToolIDs = requestPlan.eligibleToolIDs selectedToolIDs.formUnion(requestPlan.plannedToolCalls.map(\.name)) // Custom definitions used by embedders and protocol tests remain available. // The production canonical surface is intent-scoped. if canonicalAvailableIDs.isEmpty { selectedToolIDs = availableToolIDs } else { selectedToolIDs.formUnion( availableToolIDs.subtracting(AgentRequestRouter.canonicalToolIDs) ) } let tools = networkEligibleTools.filter { selectedToolIDs.contains($0.id) } let definitionsByID = Dictionary( uniqueKeysWithValues: tools.map { ($0.id, $0) } ) var plannedToolCalls = requestPlan.plannedToolCalls let plannedToolIDs = Set(plannedToolCalls.map(\.name)) let modelTools = tools.filter { definition in !plannedToolIDs.contains(definition.id) } let emitter = AgentEventEmitter(runID: runID, sink: callbacks.emit) var scratchpad: [DolphinPromptMessage] = [] var signatures: Set = [] var modelTurns = 0 var toolCalls = 0 var trustedToolResults = 0 var failedToolCalls = 0 var receipts: [AgentToolReceipt] = [] var attemptedToolAction = false await emitter.emit( kind: .run, status: .running, title: "Run started", detail: "Bounded to \(limits.maxToolSteps) tool steps and \(limits.maxNewTokens) output tokens per turn. Cancellation is requested at the \(limits.maxRunSeconds)-second deadline." ) try await callbacks.checkpoint(.phase(.routing)) try Task.checkCancellation() await emitter.emit( kind: .run, status: .information, title: "Plan prepared", detail: requestPlan.auditSummary ) if requestPlan.mode == .deterministic, plannedToolCalls.count > limits.maxToolSteps { try await callbacks.checkpoint(.phase(.finalizing)) await emitter.emit( kind: .run, status: .failed, title: "Plan exceeds action limit", detail: "The deterministic plan requires \(plannedToolCalls.count) steps, but this run allows \(limits.maxToolSteps). No action was executed." ) return AgentRunResult( finalText: "This request needs \(plannedToolCalls.count) verified actions, but the current limit is \(limits.maxToolSteps). Increase the tool-step limit or split the request.", outcome: .failed, modelTurns: 0, toolCalls: 0, trustedToolResults: 0, failedToolCalls: 0 ) } for _ in 0.. 0 { try await callbacks.checkpoint(.phase(.finalizing)) let safeFailure = partialFailureText( "I couldn’t complete every requested action safely. Review Activity for the denied or invalid step, then adjust permissions or try again.", receipts: receipts ) await emitter.emit( kind: .run, status: .failed, title: "Run stopped safely", detail: "At least one requested action did not produce a trusted result." ) return AgentRunResult( finalText: safeFailure, outcome: .failed, modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls ) } let finalText = try presentationModelText( from: turn.text, receipts: receipts ) let presentation = finalPresentation( modelText: finalText, receipts: receipts ) try await callbacks.checkpoint(.phase(.finalizing)) await emitter.emit( kind: .run, status: presentation.status, title: presentation.title, detail: presentation.detail + " · " + completionDetail( modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls ) ) return AgentRunResult( finalText: presentation.text, outcome: presentation.outcome, modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls ) } call = parsedCall } attemptedToolAction = true toolCalls += 1 try await callbacks.checkpoint( .counters(toolCallsUsed: toolCalls, modelTurns: modelTurns) ) try Task.checkCancellation() guard let definition = definitionsByID[call.name] else { failedToolCalls += 1 scratchpad.append(.assistantToolCall(call)) await emitter.emit( kind: .tool, status: .denied, title: "Unknown tool blocked", detail: call.name ) scratchpad.append( .toolResponse(errorObservation("Unknown tool: \(call.name)")) ) continue } let normalizedCall: AgentToolCall do { normalizedCall = try await callbacks.normalizeTool(call) } catch { failedToolCalls += 1 scratchpad.append(.assistantToolCall(call)) await emitter.emit( kind: .tool, status: .failed, title: "Invalid arguments", detail: "\(definition.displayName): \(error.localizedDescription)" ) scratchpad.append( .toolResponse(errorObservation(error.localizedDescription)) ) continue } scratchpad.append(.assistantToolCall(normalizedCall)) try await callbacks.checkpoint( .activeCall(normalizedCall, phase: .routing) ) try Task.checkCancellation() guard signatures.insert(normalizedCall.signature).inserted else { failedToolCalls += 1 await emitter.emit( kind: .tool, status: .denied, title: "Duplicate action blocked", detail: definition.displayName ) scratchpad.append( .toolResponse(errorObservation("Duplicate tool call blocked.")) ) continue } switch AgentPolicy.decision(for: definition, settings: limits) { case .deny(let reason): failedToolCalls += 1 await emitter.emit( kind: .tool, status: .denied, title: "Action denied by policy", detail: "\(definition.displayName): \(reason)" ) scratchpad.append(.toolResponse(errorObservation(reason))) continue case .requireApproval(let reason): let createdAt = dateProvider.now() let request = ToolApprovalRequest( runID: runID, call: normalizedCall, toolName: definition.displayName, reason: reason, createdAt: createdAt, expiresAt: min( createdAt.addingTimeInterval(10 * 60), createdAt.addingTimeInterval( remainingSeconds(until: deadline, clock: clock) ) ) ) await emitter.emit( kind: .approval, status: .awaitingApproval, title: "Approval requested", detail: definition.displayName ) try await callbacks.checkpoint( .activeCall(normalizedCall, phase: .awaitingApproval) ) try Task.checkCancellation() let approved = await callbacks.requestApproval(request) try Task.checkCancellation() guard approved, !request.isExpired(at: dateProvider.now()) else { failedToolCalls += 1 await emitter.emit( kind: .approval, status: .denied, title: approved ? "Approval expired" : "Action denied", detail: definition.displayName ) scratchpad.append( .toolResponse( errorObservation( approved ? "Approval expired." : "The user denied this action." ) ) ) continue } await emitter.emit( kind: .approval, status: .succeeded, title: "Approved once", detail: definition.displayName ) case .allow: break } try checkDeadline(deadline) try await callbacks.checkpoint( .activeCall(normalizedCall, phase: .toolExecution) ) try Task.checkCancellation() await emitter.emit( kind: .tool, status: .running, title: "Running tool", detail: definition.displayName ) do { let rawResult = try await callbacks.executeTool(normalizedCall) // Reads do not create side effects, so a background/stop cancellation // must be observed before their result becomes trusted evidence. Local // writes retain the existing atomic mutation-plus-receipt boundary. switch definition.risk { case .readOnly, .sensitiveRead, .networkRead: try Task.checkCancellation() case .localWrite: break } let result = rawResult.bounded( toMaximumCharacters: definition.maxOutputCharacters ) if result.succeeded { guard let receipt = AgentToolReceipt( runID: runID, call: normalizedCall, definition: definition, result: result, completedAt: dateProvider.now() ) else { failedToolCalls += 1 await emitter.emit( kind: .tool, status: .failed, title: "Tool result rejected", detail: "The successful result could not be recorded safely." ) scratchpad.append( .toolResponse( errorObservation( "The tool result could not be recorded safely." ) ) ) try Task.checkCancellation() continue } guard AgentVerifiedAnswerRenderer.acceptsAsTrustedReceipt(receipt) else { failedToolCalls += 1 await emitter.emit( kind: .tool, status: .failed, title: "Tool result rejected", detail: "The canonical observation did not match the normalized call and expected schema." ) scratchpad.append( .toolResponse( errorObservation( "The tool observation failed canonical schema or call-correlation validation." ) ) ) try Task.checkCancellation() continue } trustedToolResults += 1 receipts.append(receipt) await emitter.emit( kind: .tool, status: .succeeded, title: "Tool completed", detail: receipt.evidenceJSON, receipt: receipt ) try await callbacks.checkpoint( .completedReceipt( receipt, toolCallsUsed: toolCalls, modelTurns: modelTurns ) ) } else { failedToolCalls += 1 await emitter.emit( kind: .tool, status: .failed, title: "Tool failed", detail: result.displayText ) } scratchpad.append(.toolResponse(result.modelText)) // A completed call is receipted before cancellation is observed. Local // writes commit this same receipt atomically with their mutation. try Task.checkCancellation() } catch is CancellationError { throw CancellationError() } catch { failedToolCalls += 1 await emitter.emit( kind: .tool, status: .failed, title: "Tool failed", detail: error.localizedDescription ) scratchpad.append( .toolResponse(errorObservation(error.localizedDescription)) ) } } if requestPlan.mode == .deterministic { return try await finishDeterministicPlan( requestPlan, receipts: receipts, modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls, emitter: emitter, callbacks: callbacks ) } try checkDeadline(deadline) await emitter.emit( kind: .run, status: .information, title: "Action limit reached", detail: "Generating a final answer without additional tools." ) try await callbacks.checkpoint(.phase(.modelGeneration)) try Task.checkCancellation() let finalTurn = try await generateTurn( conversation: conversation, memoryContext: memoryContext, scratchpad: scratchpad, systemPrompt: limits.systemPrompt, tools: [], maxNewTokens: limits.maxNewTokens, emitter: emitter, progress: callbacks.modelProgress ) modelTurns += 1 try await callbacks.checkpoint( .counters(toolCallsUsed: toolCalls, modelTurns: modelTurns) ) try Task.checkCancellation() try checkDeadline(deadline) let finalRequestedAnotherTool: Bool do { finalRequestedAnotherTool = try ToolCallParser.parse(finalTurn.text) != nil } catch { finalRequestedAnotherTool = true } if finalRequestedAnotherTool { failedToolCalls += 1 try await callbacks.checkpoint(.phase(.finalizing)) let safeFailure = partialFailureText( "I reached the action limit before I could finish. Review Activity and try a narrower request.", receipts: receipts ) await emitter.emit( kind: .run, status: .failed, title: "Run limit reached", detail: "A further tool request was blocked." ) return AgentRunResult( finalText: safeFailure, outcome: .failed, modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls ) } if attemptedToolAction && failedToolCalls > 0 { try await callbacks.checkpoint(.phase(.finalizing)) let safeFailure = partialFailureText( "I couldn’t complete every requested action safely. Review Activity for the denied or invalid step, then adjust permissions or try again.", receipts: receipts ) await emitter.emit( kind: .run, status: .failed, title: "Run stopped safely", detail: "At least one requested action did not produce a trusted result." ) return AgentRunResult( finalText: safeFailure, outcome: .failed, modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls ) } let finalText = try presentationModelText( from: finalTurn.text, receipts: receipts ) let presentation = finalPresentation(modelText: finalText, receipts: receipts) try await callbacks.checkpoint(.phase(.finalizing)) await emitter.emit( kind: .run, status: presentation.status, title: presentation.title, detail: presentation.detail + " · " + completionDetail( modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls ) ) return AgentRunResult( finalText: presentation.text, outcome: presentation.outcome, modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls ) } private func finishDeterministicPlan( _ plan: AgentRequestPlan, receipts: [AgentToolReceipt], modelTurns: Int, toolCalls: Int, trustedToolResults: Int, failedToolCalls: Int, emitter: AgentEventEmitter, callbacks: AgentLoopCallbacks ) async throws -> AgentRunResult { let receivedCallIDs = Set(receipts.map { $0.call.id }) let missingCalls = plan.plannedToolCalls.filter { !receivedCallIDs.contains($0.id) } guard failedToolCalls == 0, missingCalls.isEmpty else { try await callbacks.checkpoint(.phase(.finalizing)) let missingNames = missingCalls.map(\.name).joined(separator: ", ") let reason = missingNames.isEmpty ? "At least one deterministic action failed or was denied." : "Missing verified receipt(s) for: \(missingNames)." let text = partialFailureText( "I couldn’t complete the full verified plan. \(reason) Review Activity for the failed or denied step.", receipts: receipts ) await emitter.emit( kind: .run, status: .failed, title: "Verified plan incomplete", detail: reason ) return AgentRunResult( finalText: text, outcome: .failed, modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: max(failedToolCalls, missingCalls.count) ) } let presentation = finalPresentation(modelText: "", receipts: receipts) try await callbacks.checkpoint(.phase(.finalizing)) await emitter.emit( kind: .run, status: presentation.status, title: presentation.title, detail: presentation.detail + " · " + completionDetail( modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls ) ) return AgentRunResult( finalText: presentation.text, outcome: presentation.outcome, modelTurns: modelTurns, toolCalls: toolCalls, trustedToolResults: trustedToolResults, failedToolCalls: failedToolCalls ) } private func generateTurn( conversation: [AssistantMessage], memoryContext: [MemoryItem], scratchpad: [DolphinPromptMessage], systemPrompt: String, tools: [AgentToolDefinition], maxNewTokens: Int, emitter: AgentEventEmitter, progress: @MainActor @Sendable (DolphinGenerationProgress) -> Void ) async throws -> DolphinGenerationResult { let prompt = try await AgentContextWindowBuilder.build( systemPrompt: systemPrompt, conversation: conversation, memoryContext: memoryContext, scratchpad: scratchpad, tools: tools, maxNewTokens: maxNewTokens, runtime: runtime ) var detail = "Prompt \(prompt.promptTokens) tokens · output budget \(maxNewTokens) tokens" if prompt.droppedMessages > 0 { detail += " · dropped \(prompt.droppedMessages) older messages" } if prompt.droppedScratchpadMessages > 0 { detail += " · dropped \(prompt.droppedScratchpadMessages) older action messages" } if prompt.droppedMemoryItems > 0 { detail += " · dropped \(prompt.droppedMemoryItems) older memories" } await emitter.emit( kind: .model, status: .running, title: "Model turn", detail: detail ) let result = try await runtime.generate( renderedPrompt: prompt.renderedPrompt, maxNewTokens: maxNewTokens, progress: progress ) let information = await runtime.information() await emitter.emit( kind: .model, status: .succeeded, title: "Model response", detail: "\(result.generatedTokens) tokens · first token \(format(result.timeToFirstTokenSeconds)) s · \(format(result.tokensPerSecond)) tok/s · \(information.modelIdentifier)" ) return result } private func checkDeadline(_ deadline: ContinuousClock.Instant) throws { try Task.checkCancellation() guard ContinuousClock.now < deadline else { throw AgentLoopError.timeLimitReached } } private func remainingSeconds( until deadline: ContinuousClock.Instant, clock: ContinuousClock ) -> TimeInterval { let duration = clock.now.duration(to: deadline) let seconds = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1_000_000_000_000_000_000 return min(max(seconds, 0), 10 * 60) } private func sanitized(_ settings: AgentSettings) -> AgentSettings { settings.sanitized() } /// Canonical receipts can produce a complete verified presentation even if /// the small model emits an empty or copied-receipt follow-up. Custom tool /// results still require nonempty model prose because there is no trusted /// renderer for their schema. private func presentationModelText( from rawText: String, receipts: [AgentToolReceipt] ) throws -> String { let value = AssistantChatText.cleaned(rawText) if !value.isEmpty { return value } if AgentVerifiedAnswerRenderer.requiresDeterministicAnswer(for: receipts) || verifiedWriteText(receipts) != nil { return "" } throw AgentLoopError.emptyFinalResponse } private func errorObservation(_ message: String) -> String { JSONValue.object([ "error": .string(String(message.prefix(500))), "ok": .bool(false), ]).canonicalJSON } private func finalPresentation( modelText: String, receipts: [AgentToolReceipt] ) -> AgentFinalPresentation { guard !receipts.isEmpty else { return AgentFinalPresentation( text: modelText, outcome: .succeeded, status: .succeeded, title: "Answer completed", detail: "No tool was needed" ) } let localWriteCount = receipts.count { $0.risk == .localWrite } let canonicalReadRequired = AgentVerifiedAnswerRenderer.requiresDeterministicAnswer(for: receipts) let canonicalReadText = canonicalReadRequired ? AgentVerifiedAnswerRenderer.readAnswer(for: receipts) : nil if canonicalReadRequired, canonicalReadText == nil { return AgentFinalPresentation( text: partialFailureText( "A verified result couldn’t be displayed safely.", receipts: receipts ), outcome: .failed, status: .failed, title: "Verified result rejected", detail: "A canonical tool observation did not match its trusted schema" ) } let writeText = verifiedWriteText(receipts) if localWriteCount > 0, writeText == nil { return AgentFinalPresentation( text: partialFailureText( "A completed local change couldn’t be summarized safely.", receipts: receipts ), outcome: .failed, status: .failed, title: "Verified action summary unavailable", detail: "The local change remains receipted in Activity" ) } let authoritativeText = groundedReceiptText(receipts) ?? modelText let readOnlyCount = receipts.count - localWriteCount return AgentFinalPresentation( text: authoritativeText, outcome: .succeeded, status: .succeeded, title: localWriteCount > 0 ? "Verified actions completed" : "Verified read completed", detail: "\(receipts.count) trusted result(s): \(localWriteCount) local change(s), \(readOnlyCount) read-only" ) } private func partialFailureText( _ message: String, receipts: [AgentToolReceipt] ) -> String { guard !receipts.isEmpty else { return message } if let groundedText = groundedReceiptText(receipts) { return "\(message)\n\nVerified before the stop:\n\(groundedText)" } let hasLocalWrites = receipts.contains { $0.risk == .localWrite } let completed = hasLocalWrites ? "Some local changes completed before the stop." : "Some read-only steps completed before the stop." return "\(message)\n\n\(completed) Review Activity for the verified details." } private func groundedReceiptText( _ receipts: [AgentToolReceipt] ) -> String? { var parts: [String] = [] if let writeText = verifiedWriteText(receipts) { parts.append(writeText) } if let readText = AgentVerifiedAnswerRenderer.readAnswer(for: receipts) { parts.append(readText) } return parts.isEmpty ? nil : parts.joined(separator: "\n") } private func verifiedWriteText( _ receipts: [AgentToolReceipt] ) -> String? { let lines = receipts.compactMap { receipt -> String? in guard receipt.risk == .localWrite else { return nil } switch receipt.call.name { case "memory_save": guard let content = receipt.call.arguments["content"]?.stringValue else { return "Memory saved." } return "Got it — I’ll remember: “\(content)”." case "task_add": guard let title = receipt.call.arguments["title"]?.stringValue else { return "Task added." } return "Added “\(title)” to your tasks." case "task_complete": return "Task marked complete." default: let text = receipt.displayText.trimmingCharacters( in: .whitespacesAndNewlines ) return text.isEmpty ? nil : text + (text.hasSuffix(".") ? "" : ".") } } return lines.isEmpty ? nil : lines.joined(separator: "\n") } private func completionDetail( modelTurns: Int, toolCalls: Int, trustedToolResults: Int, failedToolCalls: Int ) -> String { "\(modelTurns) model turns · \(toolCalls) tool calls · \(trustedToolResults) trusted results · \(failedToolCalls) failed or denied" } private func format(_ value: Double) -> String { String(format: "%.2f", value) } } private actor AgentEventEmitter { private let runID: UUID private let sink: @MainActor @Sendable (AgentEvent) -> Void private var sequence = 0 init( runID: UUID, sink: @escaping @MainActor @Sendable (AgentEvent) -> Void ) { self.runID = runID self.sink = sink } func emit( kind: AgentEventKind, status: AgentEventStatus, title: String, detail: String, receipt: AgentToolReceipt? = nil ) async { sequence += 1 let event = AgentEvent( runID: runID, sequence: sequence, kind: kind, status: status, title: title, detail: detail, receipt: receipt ) await sink(event) } }