Add personal data tools, 2nd Brain trust toggle, and research agents
Personal Data Tools: native Calendar, Reminders, Contacts (hidden pending Apple TCC fix in beta), and Location & Maps access via EventKit, Contacts framework, and MapKit. Write actions (create event/reminder, complete reminder) gate through an approval sheet. Four hardened-runtime entitlements added to oAI.entitlements; Info.plist usage strings added for all services. Personal Data section shows a β badge while Contacts is hidden. 2nd Brain always-trust: inline toggle on the Agent Skills row for the skill named "2nd Brain" skips the bash approval dialog when the command contains .brain_helper.py, gated by three runtime checks in MCPService. Research agents: spawn_research_agents tool runs up to 5 concurrent read-only sub-agents (read_file, list_directory, search_files, web_search — no write, no bash, no nesting). Bounded by maxConcurrentAgents setting (default 3) and a hard ceiling of 8 tasks. Added items field to Tool.Function.Parameters.Property for JSON Schema array support; wired into AnthropicProvider.convertParametersToDict. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,9 @@ class MCPService {
|
||||
|
||||
private let anytypeService = AnytypeMCPService.shared
|
||||
private let paperlessService = PaperlessService.shared
|
||||
private let eventKitService = EventKitService.shared
|
||||
private let contactsService = ContactsService.shared
|
||||
private let locationMapsService = LocationMapsService.shared
|
||||
|
||||
// MARK: - Bash Approval State
|
||||
|
||||
@@ -124,6 +127,19 @@ class MCPService {
|
||||
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||
private(set) var bashSessionApproved: Bool = false
|
||||
|
||||
// MARK: - Personal Data (Calendar/Reminders) Approval State
|
||||
|
||||
struct PendingPersonalDataAction: Identifiable {
|
||||
let id = UUID()
|
||||
let toolName: String
|
||||
let argumentsJSON: String
|
||||
let summary: String
|
||||
}
|
||||
|
||||
private(set) var pendingPersonalDataAction: PendingPersonalDataAction? = nil
|
||||
private var pendingPersonalDataContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||
private(set) var personalDataSessionApproved: Bool = false
|
||||
|
||||
// MARK: - Tool Schema Generation
|
||||
|
||||
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
|
||||
@@ -232,6 +248,24 @@ class MCPService {
|
||||
tools.append(contentsOf: paperlessService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add Calendar/Reminders tools if enabled
|
||||
if settings.calendarEnabled || settings.remindersEnabled {
|
||||
tools.append(contentsOf: eventKitService.getToolSchemas(
|
||||
calendarEnabled: settings.calendarEnabled,
|
||||
remindersEnabled: settings.remindersEnabled
|
||||
))
|
||||
}
|
||||
|
||||
// Add Contacts tools if enabled
|
||||
if settings.contactsEnabled {
|
||||
tools.append(contentsOf: contactsService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add Location/Maps tools if enabled
|
||||
if settings.locationMapsEnabled {
|
||||
tools.append(contentsOf: locationMapsService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add bash_execute tool when bash is enabled
|
||||
if settings.bashEnabled {
|
||||
let workDir = settings.bashWorkingDirectory
|
||||
@@ -260,6 +294,22 @@ class MCPService {
|
||||
))
|
||||
}
|
||||
|
||||
// Add spawn_research_agents when Research Agents are enabled
|
||||
if settings.agentsEnabled {
|
||||
tools.append(makeTool(
|
||||
name: "spawn_research_agents",
|
||||
description: "Spawn multiple READ-ONLY research sub-agents that investigate independent questions IN PARALLEL. Each sub-agent gets its own read_file/list_directory/search_files/web_search loop — no write, no bash, no nesting (sub-agents cannot call this tool). ONLY use this when you have 2 or more genuinely independent research questions that benefit from running at the same time (e.g. comparing several unrelated files, topics, or sources). Do NOT use this for a single lookup, a sequential task, or anything answerable with one direct tool call — call read_file/search_files/web_search yourself instead. Each sub-agent is its own full chain of model calls and meaningfully increases cost and latency; using this for trivial tasks is wasteful. Prefer the smallest number of tasks that actually need to run in parallel.",
|
||||
properties: [
|
||||
"tasks": Tool.Function.Parameters.Property(
|
||||
type: "array",
|
||||
description: "List of independent, self-contained research questions — one per sub-agent. Keep this list as short as the task genuinely requires.",
|
||||
items: .init(type: "string")
|
||||
)
|
||||
],
|
||||
required: ["tasks"]
|
||||
))
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -284,7 +334,10 @@ class MCPService {
|
||||
|
||||
// MARK: - Tool Execution
|
||||
|
||||
func executeTool(name: String, arguments: String) async -> [String: Any] {
|
||||
/// `agentProvider`/`agentModelId` are only needed for `spawn_research_agents`, which drives
|
||||
/// its own model calls — every other tool ignores them. Passed in by the caller's active
|
||||
/// chat session rather than stored on MCPService, since this service has no provider state.
|
||||
func executeTool(name: String, arguments: String, agentProvider: AIProvider? = nil, agentModelId: String? = nil) async -> [String: Any] {
|
||||
Log.mcp.info("Executing tool: \(name)")
|
||||
guard let argData = arguments.data(using: .utf8),
|
||||
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
|
||||
@@ -399,6 +452,25 @@ class MCPService {
|
||||
let mapped = results.map { ["title": $0.title, "url": $0.url, "snippet": $0.snippet] }
|
||||
return ["results": mapped]
|
||||
|
||||
case "spawn_research_agents":
|
||||
guard settings.agentsEnabled else {
|
||||
return ["error": "Research agents are disabled. Enable 'Research Agents' in Settings > MCP."]
|
||||
}
|
||||
guard let agentProvider, let agentModelId else {
|
||||
return ["error": "Internal error: missing model context for spawn_research_agents"]
|
||||
}
|
||||
guard let tasks = args["tasks"] as? [String], !tasks.isEmpty else {
|
||||
return ["error": "Missing required parameter: tasks (non-empty array of strings)"]
|
||||
}
|
||||
return await runResearchAgents(tasks: tasks, provider: agentProvider, modelId: agentModelId)
|
||||
|
||||
case "calendar_create_event", "reminders_create", "reminders_complete":
|
||||
guard settings.calendarEnabled || settings.remindersEnabled else {
|
||||
return ["error": "Calendar/Reminders access is disabled. Enable it in Settings > MCP."]
|
||||
}
|
||||
let summary = eventKitService.approvalSummary(forTool: name, arguments: arguments)
|
||||
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
|
||||
|
||||
default:
|
||||
// Route anytype_* tools to AnytypeMCPService
|
||||
if name.hasPrefix("anytype_") {
|
||||
@@ -408,6 +480,18 @@ class MCPService {
|
||||
if name.hasPrefix("paperless_") {
|
||||
return await paperlessService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
// Route calendar_*/reminders_* read tools to EventKitService
|
||||
if name.hasPrefix("calendar_") || name.hasPrefix("reminders_") {
|
||||
return await eventKitService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
// Route contacts_* tools to ContactsService
|
||||
if name.hasPrefix("contacts_") {
|
||||
return await contactsService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
// Route location_*/maps_* tools to LocationMapsService
|
||||
if name.hasPrefix("location_") || name.hasPrefix("maps_") {
|
||||
return await locationMapsService.executeTool(name: name, arguments: arguments)
|
||||
}
|
||||
return ["error": "Unknown tool: \(name)"]
|
||||
}
|
||||
}
|
||||
@@ -754,6 +838,11 @@ class MCPService {
|
||||
if bashSessionApproved {
|
||||
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||
}
|
||||
// 2nd Brain calls its helper script via bash_execute — let the user mark that
|
||||
// specific traffic as always-trusted instead of approving it every time.
|
||||
if isTrustedSecondBrainCommand(command) {
|
||||
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
|
||||
@@ -786,6 +875,190 @@ class MCPService {
|
||||
bashSessionApproved = false
|
||||
}
|
||||
|
||||
private func isTrustedSecondBrainCommand(_ command: String) -> Bool {
|
||||
guard settings.trustSecondBrainSkill, command.contains(".brain_helper.py") else { return false }
|
||||
return settings.agentSkills.contains { $0.isActive && $0.isSecondBrainSkill }
|
||||
}
|
||||
|
||||
// MARK: - Research Agents (read-only, parallel)
|
||||
|
||||
/// Runs `tasks.count` sub-agents (capped) with bounded concurrency, each in its own
|
||||
/// read-only tool loop, and returns their findings concatenated for the orchestrator.
|
||||
private func runResearchAgents(tasks: [String], provider: AIProvider, modelId: String) async -> [String: Any] {
|
||||
let maxConcurrent = max(1, min(5, settings.maxConcurrentAgents))
|
||||
// Hard cap on total sub-agents regardless of concurrency setting, so a model
|
||||
// requesting an unreasonably long task list can't run away with cost.
|
||||
let cappedTasks = Array(tasks.prefix(8))
|
||||
|
||||
var results: [(Int, String)] = []
|
||||
await withTaskGroup(of: (Int, String).self) { group in
|
||||
var nextIndex = 0
|
||||
func launchNext() {
|
||||
guard nextIndex < cappedTasks.count else { return }
|
||||
let idx = nextIndex
|
||||
let task = cappedTasks[idx]
|
||||
nextIndex += 1
|
||||
group.addTask {
|
||||
let answer = await self.runSingleResearchAgent(task: task, provider: provider, modelId: modelId)
|
||||
return (idx, answer)
|
||||
}
|
||||
}
|
||||
for _ in 0..<min(maxConcurrent, cappedTasks.count) { launchNext() }
|
||||
for await result in group {
|
||||
results.append(result)
|
||||
launchNext()
|
||||
}
|
||||
}
|
||||
|
||||
let sorted = results.sorted { $0.0 < $1.0 }
|
||||
let formatted = sorted.map { idx, answer in
|
||||
"### Agent \(idx + 1): \(cappedTasks[idx])\n\(answer)"
|
||||
}.joined(separator: "\n\n")
|
||||
|
||||
var response: [String: Any] = ["agent_count": sorted.count, "results": formatted]
|
||||
if tasks.count > cappedTasks.count {
|
||||
response["note"] = "Only the first \(cappedTasks.count) of \(tasks.count) requested tasks were run (per-call cap)."
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/// A single sub-agent's self-contained tool loop. Read-only tools only; cannot write,
|
||||
/// run bash, or spawn further sub-agents (spawn_research_agents is not in its tool list).
|
||||
private func runSingleResearchAgent(task: String, provider: AIProvider, modelId: String) async -> String {
|
||||
let readOnlyTools: [Tool] = [
|
||||
makeTool(
|
||||
name: "read_file",
|
||||
description: "Read the contents of a file. Maximum file size is 10MB.",
|
||||
properties: ["file_path": prop("string", "The absolute path to the file to read")],
|
||||
required: ["file_path"]
|
||||
),
|
||||
makeTool(
|
||||
name: "list_directory",
|
||||
description: "List the contents of a directory. Skips hidden/build directories like .git, node_modules, etc.",
|
||||
properties: [
|
||||
"dir_path": prop("string", "The absolute path to the directory to list"),
|
||||
"recursive": prop("boolean", "Whether to list recursively (default: false)")
|
||||
],
|
||||
required: ["dir_path"]
|
||||
),
|
||||
makeTool(
|
||||
name: "search_files",
|
||||
description: "Search for files by name pattern or content.",
|
||||
properties: [
|
||||
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
|
||||
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
|
||||
"content_search": prop("string", "Optional text to search for inside files")
|
||||
],
|
||||
required: ["pattern"]
|
||||
),
|
||||
makeTool(
|
||||
name: "web_search",
|
||||
description: "Search the web for current information using DuckDuckGo.",
|
||||
properties: ["query": prop("string", "The search query to look up")],
|
||||
required: ["query"]
|
||||
)
|
||||
]
|
||||
let allowedNames = Set(readOnlyTools.map { $0.function.name })
|
||||
|
||||
var apiMessages: [[String: Any]] = [
|
||||
["role": "system", "content": "You are a read-only research sub-agent. Investigate the assigned task using the available tools and report concise findings as plain text. You cannot write, delete, or execute anything, and cannot spawn further sub-agents. Once you have enough information, respond with your final answer and stop calling tools."],
|
||||
["role": "user", "content": task]
|
||||
]
|
||||
|
||||
let maxIterations = 6
|
||||
for iteration in 0..<maxIterations {
|
||||
if Task.isCancelled { return "(cancelled)" }
|
||||
|
||||
guard let response = try? await provider.chatWithToolMessages(
|
||||
model: modelId, messages: apiMessages, tools: readOnlyTools, maxTokens: nil, temperature: nil
|
||||
) else {
|
||||
return "(error: sub-agent request failed)"
|
||||
}
|
||||
|
||||
let toolCalls = response.toolCalls ?? []
|
||||
guard !toolCalls.isEmpty else {
|
||||
return response.content.isEmpty ? "(no findings)" : response.content
|
||||
}
|
||||
|
||||
var assistantMsg: [String: Any] = ["role": "assistant"]
|
||||
if !response.content.isEmpty { assistantMsg["content"] = response.content }
|
||||
assistantMsg["tool_calls"] = toolCalls.map { tc in
|
||||
["id": tc.id, "type": tc.type, "function": ["name": tc.functionName, "arguments": tc.arguments]]
|
||||
}
|
||||
apiMessages.append(assistantMsg)
|
||||
|
||||
for tc in toolCalls {
|
||||
let resultJSON: String
|
||||
if allowedNames.contains(tc.functionName) {
|
||||
let result = await executeTool(name: tc.functionName, arguments: tc.arguments)
|
||||
resultJSON = serializeToolResult(result)
|
||||
} else {
|
||||
resultJSON = "{\"error\": \"Tool not available to research sub-agents\"}"
|
||||
}
|
||||
apiMessages.append([
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.functionName,
|
||||
"content": resultJSON
|
||||
])
|
||||
}
|
||||
|
||||
if iteration == maxIterations - 1 {
|
||||
return "(research incomplete: sub-agent reached its iteration limit)"
|
||||
}
|
||||
}
|
||||
return "(no findings)"
|
||||
}
|
||||
|
||||
private func serializeToolResult(_ result: [String: Any], maxBytes: Int = 20_000) -> String {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: result),
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return "{\"error\": \"Failed to serialize result\"}"
|
||||
}
|
||||
guard str.utf8.count > maxBytes, let truncated = String(str.utf8.prefix(maxBytes)) else {
|
||||
return str
|
||||
}
|
||||
return truncated + "\n... (result truncated)"
|
||||
}
|
||||
|
||||
// MARK: - Personal Data (Calendar/Reminders) Approval
|
||||
|
||||
private func executePersonalDataAction(toolName: String, argumentsJSON: String, summary: String) async -> [String: Any] {
|
||||
guard settings.personalDataRequireApproval, !personalDataSessionApproved else {
|
||||
return await eventKitService.executeWriteTool(name: toolName, arguments: argumentsJSON)
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
self.pendingPersonalDataAction = PendingPersonalDataAction(toolName: toolName, argumentsJSON: argumentsJSON, summary: summary)
|
||||
self.pendingPersonalDataContinuation = continuation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func approvePendingPersonalDataAction(forSession: Bool = false) {
|
||||
guard let pending = pendingPersonalDataAction, let cont = pendingPersonalDataContinuation else { return }
|
||||
pendingPersonalDataAction = nil
|
||||
pendingPersonalDataContinuation = nil
|
||||
if forSession {
|
||||
personalDataSessionApproved = true
|
||||
}
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let result = await self.eventKitService.executeWriteTool(name: pending.toolName, arguments: pending.argumentsJSON)
|
||||
cont.resume(returning: result)
|
||||
}
|
||||
}
|
||||
|
||||
func denyPendingPersonalDataAction() {
|
||||
guard pendingPersonalDataAction != nil else { return }
|
||||
pendingPersonalDataAction = nil
|
||||
pendingPersonalDataContinuation?.resume(returning: ["error": "User denied this action"])
|
||||
pendingPersonalDataContinuation = nil
|
||||
}
|
||||
|
||||
func resetPersonalDataSessionApproval() {
|
||||
personalDataSessionApproved = false
|
||||
}
|
||||
|
||||
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
|
||||
let timeoutSeconds = settings.bashTimeout
|
||||
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
|
||||
Reference in New Issue
Block a user