ales27pm's picture
Add autonomous on-device iOS assistant
7b2dfc5 verified
Raw
History Blame Contribute Delete
27.2 kB
import Foundation
enum AgentPolicyDecision: Sendable, Equatable {
case allow
case requireApproval(String)
case deny(String)
}
struct AgentPolicy: Sendable {
static func decision(
for tool: AgentToolDefinition,
settings: AgentSettings
) -> AgentPolicyDecision {
if let requiredCapability = tool.requiredCapability,
!settings.enabledSystemCapabilities.contains(requiredCapability)
{
return .deny(
"Enable this system capability in Dolphin Settings before using it."
)
}
switch tool.risk {
case .readOnly:
return .allow
case .sensitiveRead:
return .requireApproval(
"Allow this one-time read of sensitive data from this iPhone?"
)
case .localWrite:
if settings.requireApprovalForLocalWrites {
return .requireApproval(
"This action changes saved assistant data on this iPhone."
)
}
return .allow
case .networkRead:
guard settings.networkAccessEnabled else {
return .deny("Network access is disabled in Settings.")
}
return .requireApproval(
"This action sends a request to an external HTTPS server."
)
}
}
}
enum AgentPlanMode: String, Sendable, Equatable {
case conversation
case deterministic
case modelAssistedRead
}
/// A bounded, inspectable execution contract. This is a route summary, not
/// model chain-of-thought: it records only which audited actions may run and
/// which deterministic calls must produce receipts before success is possible.
struct AgentRequestPlan: Sendable, Equatable {
let mode: AgentPlanMode
let plannedToolCalls: [AgentToolCall]
let eligibleToolIDs: Set<String>
var initialToolCall: AgentToolCall? { plannedToolCalls.first }
var requiredReceiptCallIDs: Set<UUID> {
Set(plannedToolCalls.map(\.id))
}
var auditSummary: String {
switch mode {
case .conversation:
return "Conversation only · no tools eligible"
case .modelAssistedRead:
return "Model-assisted read · eligible tools: "
+ eligibleToolIDs.sorted().joined(separator: ", ")
case .deterministic:
return "Deterministic sequence · "
+ plannedToolCalls.map(\.name).joined(separator: " → ")
+ " · \(requiredReceiptCallIDs.count) required receipt(s)"
}
}
init(
initialToolCall: AgentToolCall?,
eligibleToolIDs: Set<String>
) {
plannedToolCalls = initialToolCall.map { [$0] } ?? []
self.eligibleToolIDs = eligibleToolIDs
if initialToolCall != nil {
mode = .deterministic
} else if eligibleToolIDs.isEmpty {
mode = .conversation
} else {
mode = .modelAssistedRead
}
}
init(plannedToolCalls: [AgentToolCall]) {
mode = plannedToolCalls.isEmpty ? .conversation : .deterministic
self.plannedToolCalls = plannedToolCalls
eligibleToolIDs = []
}
static let conversation = AgentRequestPlan(
initialToolCall: nil,
eligibleToolIDs: []
)
}
/// A narrow, deterministic front door for the small on-device model. It does
/// not decide answers; it only prevents irrelevant tools from appearing in
/// ordinary conversation and guarantees that explicit local-write commands use
/// the matching audited tool path.
enum AgentRequestRouter: Sendable {
static let canonicalToolIDs: Set<String> = [
"current_datetime",
"calculator",
"memory_search",
"memory_save",
"knowledge_search",
"knowledge_capture",
"knowledge_get",
"project_context",
"project_review",
"handoff_create",
"handoff_list",
"handoff_get",
"handoff_restore",
"handoff_deactivate",
"insight_list",
"code_analyze",
"task_list",
"task_search",
"task_add",
"task_complete",
"task_reopen",
"calendar_events",
]
static func plan(for rawText: String) -> AgentRequestPlan {
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return .conversation }
if let clauses = explicitThenClauses(in: text) {
let plans = clauses.map(singlePlan)
guard plans.allSatisfy({
$0.mode == .deterministic && $0.plannedToolCalls.count == 1
}) else {
return .conversation
}
return AgentRequestPlan(
plannedToolCalls: plans.flatMap(\.plannedToolCalls)
)
}
// A plain "and" is sequencing only when every clause is itself an
// explicit deterministic command. Otherwise it remains payload text, as
// in "remember cats and dogs".
if let clauses = explicitConjunctionClauses(in: text) {
let plans = clauses.map(singlePlan)
if plans.allSatisfy({
$0.mode == .deterministic && $0.plannedToolCalls.count == 1
}) {
return AgentRequestPlan(
plannedToolCalls: plans.flatMap(\.plannedToolCalls)
)
}
}
return singlePlan(text)
}
private static func singlePlan(_ text: String) -> AgentRequestPlan {
let lowercased = text.lowercased()
if let title = explicitTaskTitle(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "task_add",
arguments: ["title": .string(String(title.prefix(300)))]
),
eligibleToolIDs: []
)
}
if let content = explicitMemoryContent(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "memory_save",
arguments: ["content": .string(String(content.prefix(1_500)))]
),
eligibleToolIDs: []
)
}
if let id = explicitTaskCompletionID(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "task_complete",
arguments: ["id": .string(id.uuidString)]
),
eligibleToolIDs: []
)
}
if let id = explicitTaskReopenID(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "task_reopen",
arguments: ["id": .string(id.uuidString)]
),
eligibleToolIDs: []
)
}
if let capture = explicitKnowledgeCapture(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "knowledge_capture",
arguments: [
"kind": .string(capture.kind),
"title": .string(capture.title),
"content": .string(capture.content),
"tags": .array([]),
"related_ids": .array([]),
]
),
eligibleToolIDs: []
)
}
if let query = explicitKnowledgeQuery(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "knowledge_search",
arguments: [
"query": .string(
String(query.prefix(KnowledgeEngine.Limits.maxQueryCharacters))
),
"limit": .number(5),
]
),
eligibleToolIDs: []
)
}
if let id = explicitKnowledgeGetID(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "knowledge_get",
arguments: ["id": .string(id.uuidString)]
),
eligibleToolIDs: []
)
}
if let focus = explicitHandoffFocus(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "handoff_create",
arguments: [
"title": .string("Dolphin session handoff"),
"focus": .string(focus),
]
),
eligibleToolIDs: []
)
}
if let id = explicitHandoffRestoreID(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "handoff_restore",
arguments: ["id": .string(id.uuidString)]
),
eligibleToolIDs: []
)
}
if let id = explicitHandoffDeactivateID(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "handoff_deactivate",
arguments: ["id": .string(id.uuidString)]
),
eligibleToolIDs: []
)
}
if let id = explicitHandoffGetID(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "handoff_get",
arguments: ["id": .string(id.uuidString)]
),
eligibleToolIDs: []
)
}
if let code = explicitCodeForAnalysis(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "code_analyze",
arguments: ["code": .string(String(code.prefix(5_000)))]
),
eligibleToolIDs: []
)
}
if isProjectContextRequest(text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(name: "project_context", arguments: [:]),
eligibleToolIDs: []
)
}
if isProjectReviewRequest(text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "project_review",
arguments: ["limit": .number(5)]
),
eligibleToolIDs: []
)
}
if isHandoffListRequest(text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "handoff_list",
arguments: ["limit": .number(5)]
),
eligibleToolIDs: []
)
}
if isInsightRequest(text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "insight_list",
arguments: ["limit": .number(5)]
),
eligibleToolIDs: []
)
}
if isExplicitTaskListRequest(text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "task_list",
arguments: [:]
),
eligibleToolIDs: []
)
}
if let query = explicitTaskQuery(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "task_search",
arguments: [
"query": .string(String(query.prefix(200))),
"include_completed": .bool(true),
"limit": .number(10),
]
),
eligibleToolIDs: []
)
}
if let query = explicitMemoryQuery(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "memory_search",
arguments: [
"query": .string(String(query.prefix(200))),
"limit": .number(5),
]
),
eligibleToolIDs: []
)
}
if let window = explicitCalendarWindow(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "calendar_events",
arguments: [
"window": .string(window.rawValue),
"limit": .number(10),
]
),
eligibleToolIDs: []
)
}
if let expression = explicitCalculation(in: text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "calculator",
arguments: ["expression": .string(expression)]
),
eligibleToolIDs: []
)
}
if isExplicitDateTimeRequest(text) {
return AgentRequestPlan(
initialToolCall: AgentToolCall(
name: "current_datetime",
arguments: [:]
),
eligibleToolIDs: []
)
}
// Explicit commands above are anchored or independently negation-safe, so
// their payload may truthfully contain words such as "don't" or "avoid".
// Remaining heuristic routing stays disabled for negated prose.
if containsCommandNegation(lowercased) { return .conversation }
let words = wordSet(in: lowercased)
var eligible: Set<String> = []
if !words.isDisjoint(with: ["memory", "remember", "recall"]) {
eligible.insert("memory_search")
}
if !words.isDisjoint(
with: [
"capture", "context", "decision", "goal", "knowledge", "preference",
"project", "synthesize",
]
) {
eligible.formUnion([
"knowledge_search", "knowledge_get", "project_context",
"project_review",
])
}
if !words.isDisjoint(with: ["handoff", "handoffs", "resume", "restore"]) {
eligible.formUnion(["handoff_list", "handoff_get"])
}
if !words.isDisjoint(with: ["insight", "insights", "suggestion", "suggestions"]) {
eligible.insert("insight_list")
}
if words.contains("code")
&& !words.isDisjoint(with: ["analyze", "analyse", "review", "pattern"])
{
eligible.insert("code_analyze")
}
if !words.isDisjoint(with: ["task", "tasks", "todo", "todos"]) {
eligible.formUnion(["task_list", "task_search"])
}
if !words.isDisjoint(
with: ["calculate", "calculator", "arithmetic", "multiply", "divide"]
) || lowercased.range(
of: #"[0-9]\s*[+\-*/%]\s*[0-9]"#,
options: .regularExpression
) != nil {
eligible.insert("calculator")
}
if !words.isDisjoint(
with: ["time", "date", "day", "today", "tonight", "timezone"]
) {
eligible.insert("current_datetime")
}
if !words.isDisjoint(
with: ["agenda", "calendar", "event", "events", "meeting", "meetings"]
) {
eligible.insert("calendar_events")
}
return AgentRequestPlan(
initialToolCall: nil,
eligibleToolIDs: eligible
)
}
private static func explicitMemoryContent(in text: String) -> String? {
let prefixes = [
"please remember that ",
"please remember ",
"remember that ",
"remember ",
"please don't forget that ",
"please don’t forget that ",
"don't forget that ",
"don’t forget that ",
"save this to memory: ",
"save to memory: ",
"store this in memory: ",
"store in memory: ",
]
for prefix in prefixes {
if let value = value(afterAnchoredPrefix: prefix, in: text) {
return cleanedPayload(value)
}
}
return nil
}
private struct KnowledgeCaptureRequest {
let kind: String
let title: String
let content: String
}
private static func explicitKnowledgeCapture(
in text: String
) -> KnowledgeCaptureRequest? {
let prefixes: [(String, String)] = [
("capture a decision: ", "decision"),
("capture decision: ", "decision"),
("record decision: ", "decision"),
("capture a goal: ", "goal"),
("capture goal: ", "goal"),
("capture a preference: ", "preference"),
("capture preference: ", "preference"),
("capture code context: ", "code"),
("capture code: ", "code"),
("capture an insight: ", "insight"),
("capture insight: ", "insight"),
("capture a fact: ", "fact"),
("capture fact: ", "fact"),
("capture context: ", "context"),
("capture knowledge: ", "context"),
]
for (prefix, kind) in prefixes {
guard let rawValue = value(afterAnchoredPrefix: prefix, in: text),
let content = cleanedPayload(rawValue)
else { continue }
let compact = content.split(whereSeparator: \Character.isWhitespace)
.joined(separator: " ")
let title = String(compact.prefix(80))
return KnowledgeCaptureRequest(
kind: kind,
title: title,
content: String(content.prefix(4_000))
)
}
return nil
}
private static func explicitKnowledgeQuery(in text: String) -> String? {
let prefixes = [
"search project knowledge for ",
"search knowledge for ",
"find knowledge about ",
"what do you know about ",
]
for prefix in prefixes {
if let value = value(afterAnchoredPrefix: prefix, in: text) {
return cleanedPayload(value)
}
}
return nil
}
private static func explicitKnowledgeGetID(in text: String) -> UUID? {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return nil }
guard lowercased.contains("knowledge") else { return nil }
guard ["show", "get", "open", "details", "inspect"].contains(where: {
wordSet(in: lowercased).contains($0)
}) else { return nil }
return firstUUID(in: text)
}
private static func isProjectContextRequest(_ text: String) -> Bool {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return false }
return [
"synthesize project context",
"synthesise project context",
"summarize project context",
"summarise project context",
"show project context",
"what are we working on",
"where did we leave off",
].contains { lowercased.contains($0) }
}
private static func isProjectReviewRequest(_ text: String) -> Bool {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return false }
return [
"review project state",
"review the project state",
"project review",
"review my project",
"review this project",
"what needs attention",
].contains { lowercased.contains($0) }
}
private static func explicitHandoffFocus(in text: String) -> String? {
let lowercased = text.lowercased()
if [
"create a handoff", "create handoff", "save a handoff", "save handoff",
].contains(lowercased.trimmingCharacters(in: .whitespacesAndNewlines)) {
return "Continue the current work"
}
let prefixes = [
"create a handoff for ",
"create handoff for ",
"save a handoff for ",
"save handoff for ",
]
for prefix in prefixes {
if let value = value(afterAnchoredPrefix: prefix, in: text) {
return cleanedPayload(value).map { String($0.prefix(500)) }
}
}
return nil
}
private static func explicitHandoffRestoreID(in text: String) -> UUID? {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return nil }
guard lowercased.contains("handoff"),
lowercased.contains("restore") || lowercased.contains("resume")
else { return nil }
guard let range = text.range(
of: #"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"#,
options: .regularExpression
) else { return nil }
return UUID(uuidString: String(text[range]))
}
private static func explicitHandoffDeactivateID(in text: String) -> UUID? {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return nil }
guard lowercased.contains("handoff"), lowercased.contains("deactivate") else {
return nil
}
return firstUUID(in: text)
}
private static func explicitHandoffGetID(in text: String) -> UUID? {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return nil }
guard lowercased.contains("handoff") else { return nil }
let words = wordSet(in: lowercased)
guard !words.isDisjoint(with: ["show", "get", "open", "details", "inspect"]) else {
return nil
}
return firstUUID(in: text)
}
private static func isHandoffListRequest(_ text: String) -> Bool {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return false }
return [
"list handoffs", "list my handoffs", "show handoffs", "show my handoffs",
].contains { lowercased.contains($0) }
}
private static func isInsightRequest(_ text: String) -> Bool {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return false }
return [
"show insights", "show proactive insights", "list insights",
"what should i do next", "what should we do next",
].contains { lowercased.contains($0) }
}
private static func explicitCodeForAnalysis(in text: String) -> String? {
let prefixes = [
"analyze this code: ", "analyse this code: ", "analyze code: ",
"analyse code: ", "review this code: ",
]
for prefix in prefixes {
if let value = value(afterAnchoredPrefix: prefix, in: text) {
return cleanedPayload(value)
}
}
return nil
}
private static func containsCommandNegation(_ lowercased: String) -> Bool {
[
"don't ", "don’t ", "dont ", "do not ", "never ", "stop ", "avoid ",
].contains { lowercased.contains($0) }
}
private static func explicitCalendarWindow(
in text: String
) -> CalendarQueryWindow? {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return nil }
let words = wordSet(in: lowercased)
guard !words.isDisjoint(
with: ["agenda", "calendar", "event", "events", "meeting", "meetings"]
) else { return nil }
if lowercased.contains("next 7 days")
|| lowercased.contains("next seven days")
{
return .nextSevenDays
}
if words.contains("tomorrow") { return .tomorrow }
if words.contains("today") || words.contains("agenda") { return .today }
return nil
}
private static func explicitTaskTitle(in text: String) -> String? {
let prefixes = [
"please add a task to ",
"add a task to ",
"please create a task to ",
"create a task to ",
"add a task: ",
"create a task: ",
"remember to ",
]
for prefix in prefixes {
if let value = value(afterAnchoredPrefix: prefix, in: text) {
return cleanedPayload(value)
}
}
guard let remainder = value(afterAnchoredPrefix: "add ", in: text) else {
return nil
}
let suffixes = [
" to my tasks",
" to my task list",
" to the task list",
" to tasks",
" to my todo list",
" to my to-do list",
]
for suffix in suffixes {
if let range = remainder.range(
of: suffix,
options: [.caseInsensitive, .backwards]
), range.upperBound == remainder.endIndex {
return cleanedPayload(String(remainder[..<range.lowerBound]))
}
}
return nil
}
private static func explicitTaskCompletionID(in text: String) -> UUID? {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return nil }
guard
lowercased.contains("complete")
|| lowercased.contains("finish")
|| lowercased.contains("mark")
else { return nil }
guard let range = text.range(
of: #"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"#,
options: .regularExpression
) else { return nil }
return UUID(uuidString: String(text[range]))
}
private static func explicitTaskReopenID(in text: String) -> UUID? {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return nil }
guard lowercased.contains("task"), lowercased.contains("reopen") else {
return nil
}
return firstUUID(in: text)
}
private static func isExplicitTaskListRequest(_ text: String) -> Bool {
let lowercased = text.lowercased()
guard !containsCommandNegation(lowercased) else { return false }
return [
"list tasks",
"list my tasks",
"show my tasks",
"show tasks",
"what are my tasks",
"what's on my task list",
"whats on my task list",
].contains { lowercased.contains($0) }
}
private static func explicitTaskQuery(in text: String) -> String? {
for prefix in [
"search tasks for ",
"search my tasks for ",
"find task about ",
"find tasks about ",
"find my task about ",
] {
if let value = value(afterAnchoredPrefix: prefix, in: text) {
return cleanedPayload(value)
}
}
return nil
}
private static func explicitMemoryQuery(in text: String) -> String? {
let lowercased = text.lowercased()
if !containsCommandNegation(lowercased), [
"what is my name",
"what's my name",
"whats my name",
"do you remember my name",
"who am i",
].contains(where: lowercased.contains) {
return "name"
}
let prefixes = [
"what do you remember about ",
"search your memory for ",
"search memory for ",
"recall ",
]
for prefix in prefixes {
if let value = value(afterAnchoredPrefix: prefix, in: text) {
return cleanedPayload(value)
}
}
return nil
}
private static func explicitCalculation(in text: String) -> String? {
for prefix in ["calculate: ", "calculate ", "calculator: "] {
if let value = value(afterAnchoredPrefix: prefix, in: text),
let cleaned = cleanedPayload(value)
{
return String(cleaned.prefix(256))
}
}
return nil
}
private static func isExplicitDateTimeRequest(_ text: String) -> Bool {
let value = text.lowercased().trimmingCharacters(
in: .whitespacesAndNewlines
)
return [
"what time is it",
"what time is it?",
"current time",
"current date",
"what is today's date",
"what is today’s date",
"what's today's date",
"what’s today’s date",
].contains(value)
}
private static func explicitThenClauses(in text: String) -> [String]? {
var separated = text.replacingOccurrences(
of: " and then ",
with: "\n",
options: .caseInsensitive
)
separated = separated.replacingOccurrences(
of: " then ",
with: "\n",
options: .caseInsensitive
)
let clauses = separated.split(separator: "\n").map {
String($0).trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty }
return clauses.count > 1 ? clauses : nil
}
private static func explicitConjunctionClauses(
in text: String
) -> [String]? {
let separated = text.replacingOccurrences(
of: #"(?i)\s+and\s+"#,
with: "\n",
options: .regularExpression
)
let clauses = separated.split(separator: "\n").map {
String($0).trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty }
return clauses.count > 1 ? clauses : nil
}
private static func firstUUID(in text: String) -> UUID? {
guard let range = text.range(
of: #"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"#,
options: .regularExpression
) else { return nil }
return UUID(uuidString: String(text[range]))
}
private static func value(
afterAnchoredPrefix prefix: String,
in text: String
) -> String? {
guard let range = text.range(
of: prefix,
options: [.anchored, .caseInsensitive]
) else { return nil }
return String(text[range.upperBound...])
}
private static func cleanedPayload(_ rawValue: String) -> String? {
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
for prefix in ["that ", "this: ", "this "] {
if let range = value.range(
of: prefix,
options: [.anchored, .caseInsensitive]
) {
value = String(value[range.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
break
}
}
guard !value.isEmpty else { return nil }
return value
}
private static func wordSet(in text: String) -> Set<String> {
Set(
text.split { character in
!character.isLetter && !character.isNumber
}.map(String.init)
)
}
}