ales27pm's picture
Add autonomous on-device iOS assistant
7b2dfc5 verified
Raw
History Blame Contribute Delete
33.1 kB
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<String> = []
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..<limits.maxToolSteps {
try Task.checkCancellation()
try checkDeadline(deadline)
if requestPlan.mode == .deterministic, plannedToolCalls.isEmpty {
return try await finishDeterministicPlan(
requestPlan,
receipts: receipts,
modelTurns: modelTurns,
toolCalls: toolCalls,
trustedToolResults: trustedToolResults,
failedToolCalls: failedToolCalls,
emitter: emitter,
callbacks: callbacks
)
}
let call: AgentToolCall
if !plannedToolCalls.isEmpty {
let routedCall = plannedToolCalls.removeFirst()
call = routedCall
await emitter.emit(
kind: .run,
status: .information,
title: "Request routed",
detail: definitionsByID[routedCall.name]?.displayName
?? "Unavailable required tool: \(routedCall.name)"
)
try await callbacks.checkpoint(
.activeCall(routedCall, phase: .routing)
)
try Task.checkCancellation()
} else {
try await callbacks.checkpoint(.phase(.modelGeneration))
try Task.checkCancellation()
let turn = try await generateTurn(
conversation: conversation,
memoryContext: memoryContext,
scratchpad: scratchpad,
systemPrompt: limits.systemPrompt,
tools: modelTools,
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 parsedCall: AgentToolCall?
do {
parsedCall = try ToolCallParser.parse(turn.text)
} catch {
attemptedToolAction = true
toolCalls += 1
failedToolCalls += 1
try await callbacks.checkpoint(
.counters(toolCallsUsed: toolCalls, modelTurns: modelTurns)
)
await emitter.emit(
kind: .tool,
status: .failed,
title: "Invalid tool request",
detail: error.localizedDescription
)
scratchpad.append(.assistant(turn.text))
scratchpad.append(
.user(
"Tool protocol error: \(error.localizedDescription) Return either one valid tool call or a final answer."
)
)
continue
}
guard let parsedCall else {
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: 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)
}
}