| @preconcurrency import Foundation |
|
|
| enum AssistantWorkspaceMutation: Sendable { |
| case saveMemory(MemoryItem) |
| case captureKnowledge(KnowledgeItem) |
| case createHandoff(SessionHandoff) |
| case restoreHandoff(id: UUID, restoredAt: Date) |
| case deactivateHandoff(id: UUID) |
| case addTask(AssistantTaskItem) |
| case completeTask(id: UUID, completedAt: Date) |
| case reopenTask(id: UUID) |
| } |
|
|
| enum AssistantWorkspaceMutationResult: Sendable { |
| case memory(MemoryItem) |
| case knowledge(KnowledgeItem) |
| case handoff(SessionHandoff) |
| case task(AssistantTaskItem) |
| } |
|
|
| typealias AssistantWorkspaceResultBuilder = |
| @Sendable (AssistantWorkspaceMutationResult) throws -> AgentToolResult |
|
|
| |
| |
| struct AgentToolExecutionContext: Sendable { |
| let snapshot: @Sendable () async -> AssistantWorkspace |
| let isApplicationActive: @Sendable () async -> Bool |
| let commitLocalWrite: @Sendable ( |
| AssistantWorkspaceMutation, |
| AgentToolCall, |
| AgentToolDefinition, |
| AssistantWorkspaceResultBuilder |
| ) async throws -> AgentToolResult |
|
|
| init( |
| snapshot: @escaping @Sendable () async -> AssistantWorkspace, |
| isApplicationActive: @escaping @Sendable () async -> Bool = { true }, |
| commitLocalWrite: @escaping @Sendable ( |
| AssistantWorkspaceMutation, |
| AgentToolCall, |
| AgentToolDefinition, |
| AssistantWorkspaceResultBuilder |
| ) async throws -> AgentToolResult |
| ) { |
| self.snapshot = snapshot |
| self.isApplicationActive = isApplicationActive |
| self.commitLocalWrite = commitLocalWrite |
| } |
| } |
|
|
| enum AgentToolRegistryError: LocalizedError, Sendable { |
| case unknownTool(String) |
| case missingArgument(String) |
| case unexpectedArguments([String]) |
| case invalidArgument(name: String, reason: String) |
| case contextRejected(String) |
|
|
| var errorDescription: String? { |
| switch self { |
| case .unknownTool(let name): |
| "Unknown tool: \(name)." |
| case .missingArgument(let name): |
| "Missing required argument: \(name)." |
| case .unexpectedArguments(let names): |
| "Unexpected argument\(names.count == 1 ? "" : "s"): \(names.joined(separator: ", "))." |
| case .invalidArgument(let name, let reason): |
| "Invalid \(name): \(reason)." |
| case .contextRejected(let reason): |
| "The workspace rejected the tool operation: \(reason)." |
| } |
| } |
| } |
|
|
| struct AgentToolRegistry: Sendable { |
| static let canonicalDefinitions: [AgentToolDefinition] = [ |
| AgentToolDefinition( |
| id: "current_datetime", |
| displayName: "Current Date & Time", |
| summary: "Read the current local date, time, and time zone. Takes no arguments.", |
| parameters: schema(properties: [:], required: []), |
| risk: .readOnly, |
| maxOutputCharacters: 512 |
| ), |
| AgentToolDefinition( |
| id: "calculator", |
| displayName: "Calculator", |
| summary: "Evaluate arithmetic using numbers, parentheses, and +, -, *, /, or %.", |
| parameters: schema( |
| properties: [ |
| "expression": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(256), |
| ]) |
| ], |
| required: ["expression"] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 512 |
| ), |
| AgentToolDefinition( |
| id: "memory_search", |
| displayName: "Search Memory", |
| summary: "Search durable local memories only when the user asks to recall or find saved information. Do not use for greetings or to save a new fact.", |
| parameters: schema( |
| properties: [ |
| "query": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(200), |
| ]), |
| "limit": .object([ |
| "type": .string("integer"), |
| "minimum": .number(1), |
| "maximum": .number(5), |
| ]), |
| ], |
| required: ["query"] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 1_200 |
| ), |
| AgentToolDefinition( |
| id: "memory_save", |
| displayName: "Save Memory", |
| summary: "Save one durable local memory when the user explicitly asks to remember, save, store, or not forget something.", |
| parameters: schema( |
| properties: [ |
| "content": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(1_500), |
| ]) |
| ], |
| required: ["content"] |
| ), |
| risk: .localWrite, |
| maxOutputCharacters: 512 |
| ), |
| AgentToolDefinition( |
| id: "knowledge_search", |
| displayName: "Search Project Knowledge", |
| summary: "Search the private local world model with deterministic hybrid lexical ranking. Includes captured knowledge and legacy memories; this is not embedding search.", |
| parameters: schema( |
| properties: [ |
| "query": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(Double(KnowledgeEngine.Limits.maxQueryCharacters)), |
| ]), |
| "limit": .object([ |
| "type": .string("integer"), |
| "minimum": .number(1), |
| "maximum": .number(8), |
| ]), |
| ], |
| required: ["query"] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 2_800 |
| ), |
| AgentToolDefinition( |
| id: "knowledge_get", |
| displayName: "Get Knowledge Details", |
| summary: "Read one private local knowledge item by its exact UUID, including provenance, revision, tags, and relationships.", |
| parameters: schema( |
| properties: [ |
| "id": .object([ |
| "type": .string("string"), |
| "format": .string("uuid"), |
| ]) |
| ], |
| required: ["id"] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 3_200 |
| ), |
| AgentToolDefinition( |
| id: "knowledge_capture", |
| displayName: "Capture Project Knowledge", |
| summary: "Capture one typed fact, preference, decision, goal, code note, context note, or insight in the private local world model.", |
| parameters: schema( |
| properties: [ |
| "kind": .object([ |
| "type": .string("string"), |
| "enum": .array([ |
| .string("fact"), .string("preference"), .string("decision"), |
| .string("goal"), .string("code"), .string("context"), |
| .string("insight"), |
| ]), |
| ]), |
| "title": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(Double(KnowledgeEngine.Limits.maxItemTitleCharacters)), |
| ]), |
| "content": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(4_000), |
| ]), |
| "tags": stringArraySchema( |
| maximumItems: 8, |
| maximumLength: KnowledgeEngine.Limits.maxTagCharacters |
| ), |
| "related_ids": uuidArraySchema(maximumItems: 8), |
| ], |
| required: ["kind", "title", "content"] |
| ), |
| risk: .localWrite, |
| maxOutputCharacters: 700 |
| ), |
| AgentToolDefinition( |
| id: "project_context", |
| displayName: "Synthesize Project Context", |
| summary: "Build a bounded, deterministic context digest from captured knowledge, open tasks, and the active handoff. Takes no arguments and does not save model-generated text.", |
| parameters: schema(properties: [:], required: []), |
| risk: .readOnly, |
| maxOutputCharacters: 3_200 |
| ), |
| AgentToolDefinition( |
| id: "project_review", |
| displayName: "Review Project State", |
| summary: "Produce a deterministic, evidence-backed review from local knowledge, open tasks, the active handoff, and proactive insight rules. This does not run a model or save data.", |
| parameters: schema( |
| properties: [ |
| "limit": .object([ |
| "type": .string("integer"), |
| "minimum": .number(1), |
| "maximum": .number(8), |
| ]) |
| ], |
| required: [] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 4_000 |
| ), |
| AgentToolDefinition( |
| id: "handoff_create", |
| displayName: "Create Session Handoff", |
| summary: "Create a typed local continuation package with focus, synthesized context, next steps, evidence links, and open tasks.", |
| parameters: schema( |
| properties: [ |
| "title": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(120), |
| ]), |
| "focus": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(500), |
| ]), |
| ], |
| required: ["title", "focus"] |
| ), |
| risk: .localWrite, |
| maxOutputCharacters: 900 |
| ), |
| AgentToolDefinition( |
| id: "handoff_list", |
| displayName: "List Session Handoffs", |
| summary: "List recent typed continuation packages and indicate which handoff is active.", |
| parameters: schema( |
| properties: [ |
| "limit": .object([ |
| "type": .string("integer"), |
| "minimum": .number(1), |
| "maximum": .number(10), |
| ]) |
| ], |
| required: [] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 2_400 |
| ), |
| AgentToolDefinition( |
| id: "handoff_get", |
| displayName: "Get Session Handoff", |
| summary: "Read one saved continuation package by UUID, including its focus, summary, next steps, and evidence links. Reading it never replays an action.", |
| parameters: schema( |
| properties: [ |
| "id": .object([ |
| "type": .string("string"), |
| "format": .string("uuid"), |
| ]) |
| ], |
| required: ["id"] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 3_600 |
| ), |
| AgentToolDefinition( |
| id: "handoff_restore", |
| displayName: "Restore Session Handoff", |
| summary: "Select a saved handoff as active local continuity context. This never replays old actions.", |
| parameters: schema( |
| properties: [ |
| "id": .object([ |
| "type": .string("string"), |
| "format": .string("uuid"), |
| ]) |
| ], |
| required: ["id"] |
| ), |
| risk: .localWrite, |
| maxOutputCharacters: 700 |
| ), |
| AgentToolDefinition( |
| id: "handoff_deactivate", |
| displayName: "Deactivate Session Handoff", |
| summary: "Deactivate the exact currently active handoff by UUID. The saved checkpoint is preserved and no prior action is replayed.", |
| parameters: schema( |
| properties: [ |
| "id": .object([ |
| "type": .string("string"), |
| "format": .string("uuid"), |
| ]) |
| ], |
| required: ["id"] |
| ), |
| risk: .localWrite, |
| maxOutputCharacters: 512 |
| ), |
| AgentToolDefinition( |
| id: "insight_list", |
| displayName: "Proactive Insights", |
| summary: "Derive bounded local follow-up suggestions from gaps, unfinished tasks, decisions, and handoff freshness. Takes no background model action.", |
| parameters: schema( |
| properties: [ |
| "limit": .object([ |
| "type": .string("integer"), |
| "minimum": .number(1), |
| "maximum": .number(8), |
| ]) |
| ], |
| required: [] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 2_400 |
| ), |
| AgentToolDefinition( |
| id: "code_analyze", |
| displayName: "Analyze Code Snippet", |
| summary: "Perform bounded, deterministic static heuristics on supplied source text without executing or saving it.", |
| parameters: schema( |
| properties: [ |
| "code": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(5_000), |
| ]), |
| "language": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(Double(KnowledgeEngine.Limits.maxTagCharacters)), |
| ]), |
| ], |
| required: ["code"] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 2_800 |
| ), |
| AgentToolDefinition( |
| id: "task_list", |
| displayName: "List Tasks", |
| summary: "Read local tasks, optionally including completed tasks.", |
| parameters: schema( |
| properties: [ |
| "include_completed": .object(["type": .string("boolean")]), |
| "limit": .object([ |
| "type": .string("integer"), |
| "minimum": .number(1), |
| "maximum": .number(10), |
| ]), |
| ], |
| required: [] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 1_200 |
| ), |
| AgentToolDefinition( |
| id: "task_search", |
| displayName: "Search Tasks", |
| summary: "Search local task titles with deterministic lexical ranking.", |
| parameters: schema( |
| properties: [ |
| "query": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(200), |
| ]), |
| "include_completed": .object(["type": .string("boolean")]), |
| "limit": .object([ |
| "type": .string("integer"), |
| "minimum": .number(1), |
| "maximum": .number(10), |
| ]), |
| ], |
| required: ["query"] |
| ), |
| risk: .readOnly, |
| maxOutputCharacters: 1_800 |
| ), |
| AgentToolDefinition( |
| id: "task_add", |
| displayName: "Add Task", |
| summary: "Add one task to the durable local task list.", |
| parameters: schema( |
| properties: [ |
| "title": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(300), |
| ]) |
| ], |
| required: ["title"] |
| ), |
| risk: .localWrite, |
| maxOutputCharacters: 512 |
| ), |
| AgentToolDefinition( |
| id: "task_complete", |
| displayName: "Complete Task", |
| summary: "Mark a local task complete using its UUID from task_list.", |
| parameters: schema( |
| properties: [ |
| "id": .object([ |
| "type": .string("string"), |
| "format": .string("uuid"), |
| ]) |
| ], |
| required: ["id"] |
| ), |
| risk: .localWrite, |
| maxOutputCharacters: 512 |
| ), |
| AgentToolDefinition( |
| id: "task_reopen", |
| displayName: "Reopen Task", |
| summary: "Reopen one completed local task using its exact UUID.", |
| parameters: schema( |
| properties: [ |
| "id": .object([ |
| "type": .string("string"), |
| "format": .string("uuid"), |
| ]) |
| ], |
| required: ["id"] |
| ), |
| risk: .localWrite, |
| maxOutputCharacters: 512 |
| ), |
| AgentToolDefinition( |
| id: "calendar_events", |
| displayName: "Calendar Events", |
| summary: "Read calendar event titles and times for today, tomorrow, or the next 7 days. Requires the user-enabled Calendar capability and one-time approval.", |
| parameters: schema( |
| properties: [ |
| "window": .object([ |
| "type": .string("string"), |
| "enum": .array([ |
| .string(CalendarQueryWindow.today.rawValue), |
| .string(CalendarQueryWindow.tomorrow.rawValue), |
| .string(CalendarQueryWindow.nextSevenDays.rawValue), |
| ]), |
| ]), |
| "limit": .object([ |
| "type": .string("integer"), |
| "minimum": .number(1), |
| "maximum": .number(10), |
| ]), |
| ], |
| required: ["window"] |
| ), |
| risk: .sensitiveRead, |
| requiredCapability: .calendarRead, |
| maxOutputCharacters: 2_400 |
| ), |
| ] |
|
|
| private static let definitionsByID = Dictionary( |
| uniqueKeysWithValues: canonicalDefinitions.map { ($0.id, $0) } |
| ) |
|
|
| let definitions: [AgentToolDefinition] |
| private let dateProvider: any DateProviding |
| private let calendarReader: any SystemCalendarReading |
| private let calendar: Calendar |
|
|
| init( |
| dateProvider: any DateProviding = SystemDateProvider(), |
| calendarReader: any SystemCalendarReading = UnavailableSystemCalendarReader(), |
| calendar: Calendar = .autoupdatingCurrent |
| ) { |
| definitions = Self.canonicalDefinitions |
| self.dateProvider = dateProvider |
| self.calendarReader = calendarReader |
| self.calendar = calendar |
| } |
|
|
| func definition(named id: String) -> AgentToolDefinition? { |
| Self.definitionsByID[id] |
| } |
|
|
| |
| |
| @discardableResult |
| func validate(_ call: AgentToolCall) throws -> AgentToolDefinition { |
| _ = try normalize(call) |
| guard let definition = definition(named: call.name) else { |
| throw AgentToolRegistryError.unknownTool(call.name) |
| } |
| return definition |
| } |
|
|
| |
| |
| func normalize(_ call: AgentToolCall) throws -> AgentToolCall { |
| guard definition(named: call.name) != nil else { |
| throw AgentToolRegistryError.unknownTool(call.name) |
| } |
| let normalized: [String: JSONValue] |
| switch call.name { |
| case "current_datetime": |
| _ = try ExactArguments(call.arguments, allowed: [], required: []) |
| normalized = [:] |
| case "calculator": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["expression"], |
| required: ["expression"] |
| ) |
| normalized = [ |
| "expression": .string( |
| try arguments.string("expression", maximumLength: 256) |
| ) |
| ] |
| case "memory_search": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["query", "limit"], |
| required: ["query"] |
| ) |
| normalized = [ |
| "query": .string( |
| try arguments.string("query", maximumLength: 200) |
| ), |
| "limit": .number( |
| Double(try arguments.integer("limit", default: 5, range: 1...5)) |
| ), |
| ] |
| case "memory_save": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["content"], |
| required: ["content"] |
| ) |
| let content = KnowledgeEngine.canonicalMemoryContent( |
| try arguments.string( |
| "content", |
| maximumLength: KnowledgeEngine.Limits.maxLegacyMemoryCharacters |
| ) |
| ) |
| guard !content.isEmpty else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: "content", |
| reason: "must contain visible text after safety normalization" |
| ) |
| } |
| normalized = ["content": .string(content)] |
| case "knowledge_search": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["query", "limit"], |
| required: ["query"] |
| ) |
| normalized = [ |
| "query": .string( |
| try arguments.string( |
| "query", |
| maximumLength: KnowledgeEngine.Limits.maxQueryCharacters |
| ) |
| ), |
| "limit": .number( |
| Double(try arguments.integer("limit", default: 5, range: 1...8)) |
| ), |
| ] |
| case "knowledge_get": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| normalized = ["id": .string(try arguments.uuid("id").uuidString)] |
| case "knowledge_capture": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["kind", "title", "content", "tags", "related_ids"], |
| required: ["kind", "title", "content"] |
| ) |
| let kind = try arguments.string("kind", maximumLength: 20) |
| guard [ |
| "fact", "preference", "decision", "goal", "code", "context", |
| "insight", |
| ].contains(kind) else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: "kind", |
| reason: "expected fact, preference, decision, goal, code, context, or insight" |
| ) |
| } |
| let fields = KnowledgeEngine.canonicalCaptureFields( |
| title: try arguments.string( |
| "title", |
| maximumLength: KnowledgeEngine.Limits.maxItemTitleCharacters |
| ), |
| content: try arguments.string( |
| "content", |
| maximumLength: KnowledgeEngine.Limits.maxItemContentCharacters |
| ), |
| tags: try arguments.stringArray( |
| "tags", |
| default: [], |
| maximumCount: 8, |
| maximumElementLength: KnowledgeEngine.Limits.maxTagCharacters |
| ), |
| relatedItemIDs: try arguments.uuidArray( |
| "related_ids", |
| default: [], |
| maximumCount: 8 |
| ) |
| ) |
| normalized = [ |
| "kind": .string(kind), |
| "title": .string(fields.title), |
| "content": .string(fields.content), |
| "tags": .array(fields.tags.map(JSONValue.string)), |
| "related_ids": .array( |
| fields.relatedItemIDs.map { .string($0.uuidString) } |
| ), |
| ] |
| case "project_context": |
| _ = try ExactArguments(call.arguments, allowed: [], required: []) |
| normalized = [:] |
| case "project_review": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["limit"], |
| required: [] |
| ) |
| normalized = [ |
| "limit": .number( |
| Double(try arguments.integer("limit", default: 5, range: 1...8)) |
| ) |
| ] |
| case "handoff_create": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["title", "focus"], |
| required: ["title", "focus"] |
| ) |
| let fields = KnowledgeEngine.canonicalHandoffFields( |
| title: try arguments.string( |
| "title", |
| maximumLength: KnowledgeEngine.Limits.maxItemTitleCharacters |
| ), |
| focus: try arguments.string( |
| "focus", |
| maximumLength: KnowledgeEngine.Limits.maxHandoffFocusCharacters |
| ) |
| ) |
| guard !fields.focus.isEmpty else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: "focus", |
| reason: "must contain visible text after safety normalization" |
| ) |
| } |
| normalized = [ |
| "title": .string(fields.title), |
| "focus": .string(fields.focus), |
| ] |
| case "handoff_list": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["limit"], |
| required: [] |
| ) |
| normalized = [ |
| "limit": .number( |
| Double(try arguments.integer("limit", default: 5, range: 1...10)) |
| ) |
| ] |
| case "handoff_get": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| normalized = ["id": .string(try arguments.uuid("id").uuidString)] |
| case "handoff_restore": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| normalized = ["id": .string(try arguments.uuid("id").uuidString)] |
| case "handoff_deactivate": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| normalized = ["id": .string(try arguments.uuid("id").uuidString)] |
| case "insight_list": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["limit"], |
| required: [] |
| ) |
| normalized = [ |
| "limit": .number( |
| Double(try arguments.integer("limit", default: 5, range: 1...8)) |
| ) |
| ] |
| case "code_analyze": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["code", "language"], |
| required: ["code"] |
| ) |
| normalized = [ |
| "code": .string(try arguments.string("code", maximumLength: 5_000)), |
| "language": .string( |
| try arguments.optionalString( |
| "language", |
| default: "unknown", |
| maximumLength: KnowledgeEngine.Limits.maxTagCharacters |
| ) |
| ), |
| ] |
| case "task_list": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["include_completed", "limit"], |
| required: [] |
| ) |
| normalized = [ |
| "include_completed": .bool( |
| try arguments.boolean("include_completed", default: false) |
| ), |
| "limit": .number( |
| Double( |
| try arguments.integer("limit", default: 10, range: 1...10) |
| ) |
| ), |
| ] |
| case "task_search": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["query", "include_completed", "limit"], |
| required: ["query"] |
| ) |
| normalized = [ |
| "query": .string( |
| try arguments.string("query", maximumLength: 200) |
| ), |
| "include_completed": .bool( |
| try arguments.boolean("include_completed", default: false) |
| ), |
| "limit": .number( |
| Double(try arguments.integer("limit", default: 10, range: 1...10)) |
| ), |
| ] |
| case "task_add": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["title"], |
| required: ["title"] |
| ) |
| let title = KnowledgeEngine.canonicalTaskTitle( |
| try arguments.string( |
| "title", |
| maximumLength: KnowledgeEngine.Limits.maxTaskTitleCharacters |
| ) |
| ) |
| guard !title.isEmpty else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: "title", |
| reason: "must contain visible text after safety normalization" |
| ) |
| } |
| normalized = ["title": .string(title)] |
| case "task_complete": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| normalized = ["id": .string(try arguments.uuid("id").uuidString)] |
| case "task_reopen": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| normalized = ["id": .string(try arguments.uuid("id").uuidString)] |
| case "calendar_events": |
| let arguments = try ExactArguments( |
| call.arguments, |
| allowed: ["window", "limit"], |
| required: ["window"] |
| ) |
| let rawWindow = try arguments.string("window", maximumLength: 32) |
| guard CalendarQueryWindow(rawValue: rawWindow) != nil else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: "window", |
| reason: "expected today, tomorrow, or next_7_days" |
| ) |
| } |
| normalized = [ |
| "window": .string(rawWindow), |
| "limit": .number( |
| Double(try arguments.integer("limit", default: 10, range: 1...10)) |
| ), |
| ] |
| default: |
| throw AgentToolRegistryError.unknownTool(call.name) |
| } |
| return AgentToolCall(id: call.id, name: call.name, arguments: normalized) |
| } |
|
|
| |
| |
| func execute( |
| _ call: AgentToolCall, |
| context: AgentToolExecutionContext |
| ) async -> AgentToolResult { |
| let outputLimit = definition(named: call.name)?.maxOutputCharacters ?? 512 |
| do { |
| let normalizedCall = try normalize(call) |
| guard let definition = definition(named: normalizedCall.name) else { |
| throw AgentToolRegistryError.unknownTool(normalizedCall.name) |
| } |
| let result = try await executeValidated( |
| normalizedCall, |
| definition: definition, |
| context: context |
| ) |
| return result.bounded( |
| toMaximumCharacters: definition.maxOutputCharacters |
| ) |
| } catch is CancellationError { |
| return AgentToolResult( |
| modelText: "Tool execution was cancelled.", |
| displayText: "Cancelled", |
| succeeded: false |
| ).bounded(toMaximumCharacters: outputLimit) |
| } catch { |
| let message = error.localizedDescription |
| return AgentToolResult( |
| modelText: "Tool error: \(message)", |
| displayText: message, |
| succeeded: false |
| ).bounded(toMaximumCharacters: outputLimit) |
| } |
| } |
|
|
| private func executeValidated( |
| _ call: AgentToolCall, |
| definition: AgentToolDefinition, |
| context: AgentToolExecutionContext |
| ) async throws -> AgentToolResult { |
| let arguments = call.arguments |
| switch call.name { |
| case "current_datetime": |
| let now = dateProvider.now() |
| let iso8601 = Self.iso8601(now) |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "iso8601": .string(iso8601), |
| "time_zone": .string(TimeZone.current.identifier), |
| "unix_time": .number(now.timeIntervalSince1970), |
| ]).canonicalJSON, |
| displayText: iso8601, |
| succeeded: true |
| ) |
|
|
| case "knowledge_get": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| let id = try values.uuid("id") |
| let workspace = await context.snapshot() |
| guard let item = workspace.knowledgeItems.first(where: { $0.id == id }) else { |
| throw AgentToolRegistryError.contextRejected( |
| "No local knowledge item matches that UUID." |
| ) |
| } |
| return AgentToolResult( |
| modelText: Self.boundedKnowledgeDetailPayload( |
| item, |
| maximumCharacters: definition.maxOutputCharacters |
| ), |
| displayText: "Knowledge details: \(item.title)", |
| succeeded: true |
| ) |
|
|
| case "calculator": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["expression"], |
| required: ["expression"] |
| ) |
| let expression = try values.string("expression", maximumLength: 256) |
| let result = try SafeCalculator.evaluate(expression) |
| let formatted = Self.formatNumber(result) |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "expression": .string(expression), |
| "result": .string(formatted), |
| ]).canonicalJSON, |
| displayText: formatted, |
| succeeded: true |
| ) |
|
|
| case "memory_search": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["query", "limit"], |
| required: ["query"] |
| ) |
| let query = try values.string("query", maximumLength: 200) |
| let limit = try values.integer("limit", default: 5, range: 1...5) |
| let workspace = await context.snapshot() |
| let matches = Self.memoryMatches( |
| query: query, |
| memories: workspace.memories, |
| limit: limit + 1 |
| ) |
| let visibleMatches = Array(matches.prefix(limit)) |
| let payload = visibleMatches.map { item in |
| JSONValue.object([ |
| "id": .string(item.id.uuidString), |
| "content": .string(Self.clipped(item.content, maximumCharacters: 300)), |
| "created_at": .string(Self.iso8601(item.createdAt)), |
| ]) |
| } |
| return AgentToolResult( |
| modelText: Self.boundedMemorySearchPayload( |
| query: query, |
| values: Array(payload), |
| maximumCharacters: 1_200, |
| alreadyTruncated: matches.count > limit |
| ), |
| displayText: payload.isEmpty |
| ? "No matching memories" |
| : "Found \(payload.count) memor\(payload.count == 1 ? "y" : "ies")", |
| succeeded: true |
| ) |
|
|
| case "memory_save": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["content"], |
| required: ["content"] |
| ) |
| let content = try values.string("content", maximumLength: 1_500) |
| let proposed = MemoryItem(content: content, createdAt: dateProvider.now()) |
| return try await context.commitLocalWrite( |
| .saveMemory(proposed), |
| call, |
| definition |
| ) { mutationResult in |
| guard case .memory(let saved) = mutationResult else { |
| throw AgentToolRegistryError.contextRejected( |
| "memory_save returned an unexpected result" |
| ) |
| } |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "saved": .bool(true), |
| "id": .string(saved.id.uuidString), |
| ]).canonicalJSON, |
| displayText: "Memory saved", |
| succeeded: true |
| ) |
| } |
|
|
| case "knowledge_search": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["query", "limit"], |
| required: ["query"] |
| ) |
| let query = try values.string( |
| "query", |
| maximumLength: KnowledgeEngine.Limits.maxQueryCharacters |
| ) |
| let limit = try values.integer("limit", default: 5, range: 1...8) |
| let workspace = await context.snapshot() |
| let matches = KnowledgeEngine().search( |
| query: query, |
| in: KnowledgeEngine.searchableKnowledge( |
| typedItems: workspace.knowledgeItems, |
| legacyMemories: workspace.memories |
| ), |
| limit: limit + 1 |
| ) |
| let visible = Array(matches.prefix(limit)) |
| let payload = visible.map { item in |
| JSONValue.object([ |
| "content": .string(Self.clipped(item.content, maximumCharacters: 420)), |
| "created_at": .string(Self.iso8601(item.createdAt)), |
| "id": .string(item.id.uuidString), |
| "kind": .string(item.kind.rawValue), |
| "revision": .number(Double(item.revision)), |
| "source": .string(item.source.rawValue), |
| "tags": .array(item.tags.map(JSONValue.string)), |
| "title": .string(item.title), |
| ]) |
| } |
| return AgentToolResult( |
| modelText: Self.boundedKnowledgeSearchPayload( |
| query: query, |
| values: payload, |
| maximumCharacters: definition.maxOutputCharacters, |
| alreadyTruncated: matches.count > limit |
| ), |
| displayText: visible.isEmpty |
| ? "No matching project knowledge" |
| : "Found \(visible.count) knowledge item\(visible.count == 1 ? "" : "s")", |
| succeeded: true |
| ) |
|
|
| case "knowledge_capture": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["kind", "title", "content", "tags", "related_ids"], |
| required: ["kind", "title", "content"] |
| ) |
| let rawKind = try values.string("kind", maximumLength: 20) |
| guard let kind = KnowledgeKind(rawValue: rawKind) else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: "kind", |
| reason: "unsupported knowledge kind" |
| ) |
| } |
| let item = KnowledgeItem( |
| kind: kind, |
| title: try values.string( |
| "title", |
| maximumLength: KnowledgeEngine.Limits.maxItemTitleCharacters |
| ), |
| content: try values.string( |
| "content", |
| maximumLength: KnowledgeEngine.Limits.maxItemContentCharacters |
| ), |
| tags: try values.stringArray( |
| "tags", |
| default: [], |
| maximumCount: 8, |
| maximumElementLength: KnowledgeEngine.Limits.maxTagCharacters |
| ), |
| relatedItemIDs: try values.uuidArray( |
| "related_ids", |
| default: [], |
| maximumCount: 8 |
| ), |
| source: .user, |
| createdAt: dateProvider.now() |
| ) |
| let workspace = await context.snapshot() |
| let knownIDs = Set(workspace.knowledgeItems.map(\.id)) |
| guard item.relatedItemIDs.allSatisfy(knownIDs.contains) else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: "related_ids", |
| reason: "every related item must already exist in local knowledge" |
| ) |
| } |
| return try await context.commitLocalWrite( |
| .captureKnowledge(item), |
| call, |
| definition |
| ) { mutationResult in |
| guard case .knowledge(let saved) = mutationResult else { |
| throw AgentToolRegistryError.contextRejected( |
| "knowledge_capture returned an unexpected result" |
| ) |
| } |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "captured": .bool(true), |
| "id": .string(saved.id.uuidString), |
| "kind": .string(saved.kind.rawValue), |
| "revision": .number(Double(saved.revision)), |
| ]).canonicalJSON, |
| displayText: "Captured \(saved.kind.rawValue): \(saved.title)", |
| succeeded: true |
| ) |
| } |
|
|
| case "project_context": |
| _ = try ExactArguments(arguments, allowed: [], required: []) |
| let workspace = await context.snapshot() |
| let activeHandoff = workspace.activeHandoffID.flatMap { id in |
| workspace.handoffs.first { $0.id == id } |
| } |
| let digest = KnowledgeEngine().synthesizeContext( |
| knowledge: workspace.knowledgeItems, |
| tasks: workspace.tasks, |
| handoff: activeHandoff, |
| trigger: "tool request", |
| createdAt: dateProvider.now(), |
| maxCharacters: 1_600 |
| ) |
| return AgentToolResult( |
| modelText: Self.boundedProjectContextPayload( |
| digest: digest, |
| activeHandoffID: activeHandoff?.id, |
| maximumCharacters: definition.maxOutputCharacters |
| ), |
| displayText: "Project context synthesized from \(digest.evidenceItemIDs.count) evidence item\(digest.evidenceItemIDs.count == 1 ? "" : "s")", |
| succeeded: true |
| ) |
|
|
| case "project_review": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["limit"], |
| required: [] |
| ) |
| let limit = try values.integer("limit", default: 5, range: 1...8) |
| let workspace = await context.snapshot() |
| let activeHandoff = workspace.activeHandoffID.flatMap { id in |
| workspace.handoffs.first { $0.id == id } |
| } |
| let engine = KnowledgeEngine() |
| let digest = engine.synthesizeContext( |
| knowledge: workspace.knowledgeItems, |
| tasks: workspace.tasks, |
| handoff: activeHandoff, |
| trigger: "project review", |
| createdAt: dateProvider.now(), |
| maxCharacters: 1_200 |
| ) |
| let insights = Array(engine.deriveProactiveInsights( |
| knowledge: workspace.knowledgeItems, |
| tasks: workspace.tasks, |
| handoff: activeHandoff, |
| now: dateProvider.now() |
| ).prefix(limit)) |
| return AgentToolResult( |
| modelText: Self.boundedProjectReviewPayload( |
| digest: digest, |
| insights: insights, |
| workspace: workspace, |
| activeHandoffID: activeHandoff?.id, |
| maximumCharacters: definition.maxOutputCharacters |
| ), |
| displayText: "Project review derived from \(digest.evidenceItemIDs.count) evidence item\(digest.evidenceItemIDs.count == 1 ? "" : "s")", |
| succeeded: true |
| ) |
|
|
| case "handoff_create": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["title", "focus"], |
| required: ["title", "focus"] |
| ) |
| let workspace = await context.snapshot() |
| let handoff = KnowledgeEngine().makeHandoff( |
| title: try values.string( |
| "title", |
| maximumLength: KnowledgeEngine.Limits.maxItemTitleCharacters |
| ), |
| focus: try values.string( |
| "focus", |
| maximumLength: KnowledgeEngine.Limits.maxHandoffFocusCharacters |
| ), |
| knowledge: workspace.knowledgeItems, |
| tasks: workspace.tasks, |
| recentMessages: workspace.messages.suffix(6).map(\.content), |
| createdAt: dateProvider.now() |
| ) |
| return try await context.commitLocalWrite( |
| .createHandoff(handoff), |
| call, |
| definition |
| ) { mutationResult in |
| guard case .handoff(let saved) = mutationResult else { |
| throw AgentToolRegistryError.contextRejected( |
| "handoff_create returned an unexpected result" |
| ) |
| } |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "active": .bool(true), |
| "created": .bool(true), |
| "id": .string(saved.id.uuidString), |
| "knowledge_count": .number(Double(saved.knowledgeItemIDs.count)), |
| "next_step_count": .number(Double(saved.nextSteps.count)), |
| "task_count": .number(Double(saved.taskIDs.count)), |
| ]).canonicalJSON, |
| displayText: "Session handoff created: \(saved.title)", |
| succeeded: true |
| ) |
| } |
|
|
| case "handoff_list": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["limit"], |
| required: [] |
| ) |
| let limit = try values.integer("limit", default: 5, range: 1...10) |
| let workspace = await context.snapshot() |
| let ordered = workspace.handoffs.sorted { first, second in |
| (first.restoredAt ?? first.createdAt) > (second.restoredAt ?? second.createdAt) |
| } |
| let visible = Array(ordered.prefix(limit)) |
| let payload = visible.map { handoff in |
| JSONValue.object([ |
| "active": .bool(workspace.activeHandoffID == handoff.id), |
| "created_at": .string(Self.iso8601(handoff.createdAt)), |
| "focus": .string(Self.clipped(handoff.focus, maximumCharacters: 300)), |
| "id": .string(handoff.id.uuidString), |
| "restored_at": handoff.restoredAt.map { |
| .string(Self.iso8601($0)) |
| } ?? .null, |
| "title": .string(handoff.title), |
| ]) |
| } |
| return AgentToolResult( |
| modelText: Self.boundedCollectionPayload( |
| key: "handoffs", |
| values: payload, |
| maximumCharacters: definition.maxOutputCharacters, |
| alreadyTruncated: ordered.count > limit |
| ), |
| displayText: visible.isEmpty |
| ? "No session handoffs" |
| : "Found \(visible.count) session handoff\(visible.count == 1 ? "" : "s")", |
| succeeded: true |
| ) |
|
|
| case "handoff_get": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| let id = try values.uuid("id") |
| let workspace = await context.snapshot() |
| guard let handoff = workspace.handoffs.first(where: { $0.id == id }) else { |
| throw AgentToolRegistryError.contextRejected( |
| "No saved session handoff matches that UUID." |
| ) |
| } |
| return AgentToolResult( |
| modelText: Self.boundedHandoffDetailPayload( |
| handoff, |
| isActive: workspace.activeHandoffID == id, |
| maximumCharacters: definition.maxOutputCharacters |
| ), |
| displayText: "Saved handoff details: \(handoff.title)", |
| succeeded: true |
| ) |
|
|
| case "handoff_restore": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| let id = try values.uuid("id") |
| return try await context.commitLocalWrite( |
| .restoreHandoff(id: id, restoredAt: dateProvider.now()), |
| call, |
| definition |
| ) { mutationResult in |
| guard case .handoff(let restored) = mutationResult else { |
| throw AgentToolRegistryError.contextRejected( |
| "handoff_restore returned an unexpected result" |
| ) |
| } |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "active": .bool(true), |
| "id": .string(restored.id.uuidString), |
| "restored": .bool(true), |
| ]).canonicalJSON, |
| displayText: "Restored handoff: \(restored.title). No action was replayed", |
| succeeded: true |
| ) |
| } |
|
|
| case "handoff_deactivate": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| let id = try values.uuid("id") |
| return try await context.commitLocalWrite( |
| .deactivateHandoff(id: id), |
| call, |
| definition |
| ) { mutationResult in |
| guard case .handoff(let handoff) = mutationResult else { |
| throw AgentToolRegistryError.contextRejected( |
| "handoff_deactivate returned an unexpected result" |
| ) |
| } |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "deactivated": .bool(true), |
| "id": .string(handoff.id.uuidString), |
| "title": .string(handoff.title), |
| ]).canonicalJSON, |
| displayText: "Handoff deactivated: \(handoff.title). The checkpoint remains saved", |
| succeeded: true |
| ) |
| } |
|
|
| case "insight_list": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["limit"], |
| required: [] |
| ) |
| let limit = try values.integer("limit", default: 5, range: 1...8) |
| let workspace = await context.snapshot() |
| let activeHandoff = workspace.activeHandoffID.flatMap { id in |
| workspace.handoffs.first { $0.id == id } |
| } |
| let insights = KnowledgeEngine().deriveProactiveInsights( |
| knowledge: workspace.knowledgeItems, |
| tasks: workspace.tasks, |
| handoff: activeHandoff, |
| now: dateProvider.now() |
| ) |
| let visible = Array(insights.prefix(limit)) |
| let payload = visible.map { insight in |
| JSONValue.object([ |
| "detail": .string(insight.detail), |
| "evidence_ids": .array( |
| insight.evidenceItemIDs.map { .string($0.uuidString) } |
| ), |
| "id": .string(insight.id.uuidString), |
| "priority": .string(insight.priority.rawValue), |
| "title": .string(insight.title), |
| ]) |
| } |
| return AgentToolResult( |
| modelText: Self.boundedCollectionPayload( |
| key: "insights", |
| values: payload, |
| maximumCharacters: definition.maxOutputCharacters, |
| alreadyTruncated: insights.count > limit |
| ), |
| displayText: visible.isEmpty |
| ? "No proactive insights" |
| : "Derived \(visible.count) proactive insight\(visible.count == 1 ? "" : "s")", |
| succeeded: true |
| ) |
|
|
| case "code_analyze": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["code", "language"], |
| required: ["code"] |
| ) |
| let code = try values.string("code", maximumLength: 5_000) |
| let language = try values.optionalString( |
| "language", |
| default: "unknown", |
| maximumLength: KnowledgeEngine.Limits.maxTagCharacters |
| ) |
| let analysis = KnowledgeEngine().analyzeCode( |
| code, |
| language: language |
| ) |
| let findings = analysis.findings.map { finding in |
| JSONValue.object([ |
| "kind": .string(finding.kind.rawValue), |
| "line": finding.line.map { .number(Double($0)) } ?? .null, |
| "message": .string(finding.message), |
| "severity": .string(finding.severity.rawValue), |
| ]) |
| } |
| return AgentToolResult( |
| modelText: Self.boundedCodeAnalysisPayload( |
| analysis: analysis, |
| findings: findings, |
| inputSHA256: AgentInputDigest.sha256(code), |
| maximumCharacters: definition.maxOutputCharacters |
| ), |
| displayText: analysis.findings.isEmpty |
| ? "No deterministic code findings" |
| : "Found \(analysis.findings.count) deterministic code observation\(analysis.findings.count == 1 ? "" : "s")", |
| succeeded: true |
| ) |
|
|
| case "task_list": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["include_completed", "limit"], |
| required: [] |
| ) |
| let includeCompleted = try values.boolean( |
| "include_completed", |
| default: false |
| ) |
| let limit = try values.integer("limit", default: 10, range: 1...10) |
| let workspace = await context.snapshot() |
| let matchingTasks = workspace.tasks |
| .filter { includeCompleted || !$0.isCompleted } |
| .sorted { $0.createdAt > $1.createdAt } |
| let tasks = matchingTasks.prefix(limit) |
| let payload = tasks.map { item in |
| JSONValue.object([ |
| "id": .string(item.id.uuidString), |
| "title": .string(Self.clipped(item.title, maximumCharacters: 200)), |
| "completed": .bool(item.isCompleted), |
| ]) |
| } |
| return AgentToolResult( |
| modelText: Self.boundedCollectionPayload( |
| key: "tasks", |
| values: Array(payload), |
| maximumCharacters: 1_200, |
| alreadyTruncated: matchingTasks.count > limit |
| ), |
| displayText: payload.isEmpty |
| ? "No tasks" |
| : "Listed \(payload.count) task\(payload.count == 1 ? "" : "s")", |
| succeeded: true |
| ) |
|
|
| case "task_search": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["query", "include_completed", "limit"], |
| required: ["query"] |
| ) |
| let query = try values.string("query", maximumLength: 200) |
| let includeCompleted = try values.boolean( |
| "include_completed", |
| default: false |
| ) |
| let limit = try values.integer("limit", default: 10, range: 1...10) |
| let workspace = await context.snapshot() |
| let matches = KnowledgeEngine().searchTasks( |
| query: query, |
| in: workspace.tasks, |
| includeCompleted: includeCompleted, |
| limit: limit + 1 |
| ) |
| let visible = Array(matches.prefix(limit)) |
| let payload = visible.map { item in |
| JSONValue.object([ |
| "completed": .bool(item.isCompleted), |
| "completed_at": item.completedAt.map { |
| .string(Self.iso8601($0)) |
| } ?? .null, |
| "created_at": .string(Self.iso8601(item.createdAt)), |
| "id": .string(item.id.uuidString), |
| "title": .string(Self.clipped(item.title, maximumCharacters: 300)), |
| ]) |
| } |
| return AgentToolResult( |
| modelText: Self.boundedTaskSearchPayload( |
| query: query, |
| values: payload, |
| maximumCharacters: definition.maxOutputCharacters, |
| alreadyTruncated: matches.count > limit |
| ), |
| displayText: visible.isEmpty |
| ? "No matching tasks" |
| : "Found \(visible.count) matching task\(visible.count == 1 ? "" : "s")", |
| succeeded: true |
| ) |
|
|
| case "task_add": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["title"], |
| required: ["title"] |
| ) |
| let title = try values.string("title", maximumLength: 300) |
| let proposed = AssistantTaskItem( |
| title: title, |
| createdAt: dateProvider.now() |
| ) |
| return try await context.commitLocalWrite( |
| .addTask(proposed), |
| call, |
| definition |
| ) { mutationResult in |
| guard case .task(let saved) = mutationResult else { |
| throw AgentToolRegistryError.contextRejected( |
| "task_add returned an unexpected result" |
| ) |
| } |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "added": .bool(true), |
| "id": .string(saved.id.uuidString), |
| "title": .string( |
| Self.clipped(saved.title, maximumCharacters: 300) |
| ), |
| ]).canonicalJSON, |
| displayText: "Task added", |
| succeeded: true |
| ) |
| } |
|
|
| case "task_complete": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| let id = try values.uuid("id") |
| return try await context.commitLocalWrite( |
| .completeTask(id: id, completedAt: dateProvider.now()), |
| call, |
| definition |
| ) { mutationResult in |
| guard case .task(let saved) = mutationResult else { |
| throw AgentToolRegistryError.contextRejected( |
| "task_complete returned an unexpected result" |
| ) |
| } |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "completed": .bool(true), |
| "id": .string(saved.id.uuidString), |
| "title": .string( |
| Self.clipped(saved.title, maximumCharacters: 300) |
| ), |
| ]).canonicalJSON, |
| displayText: "Task completed", |
| succeeded: true |
| ) |
| } |
|
|
| case "task_reopen": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["id"], |
| required: ["id"] |
| ) |
| let id = try values.uuid("id") |
| return try await context.commitLocalWrite( |
| .reopenTask(id: id), |
| call, |
| definition |
| ) { mutationResult in |
| guard case .task(let task) = mutationResult else { |
| throw AgentToolRegistryError.contextRejected( |
| "task_reopen returned an unexpected result" |
| ) |
| } |
| return AgentToolResult( |
| modelText: JSONValue.object([ |
| "id": .string(task.id.uuidString), |
| "reopened": .bool(true), |
| "title": .string( |
| Self.clipped(task.title, maximumCharacters: 300) |
| ), |
| ]).canonicalJSON, |
| displayText: "Task reopened: \(task.title)", |
| succeeded: true |
| ) |
| } |
|
|
| case "calendar_events": |
| let values = try ExactArguments( |
| arguments, |
| allowed: ["window", "limit"], |
| required: ["window"] |
| ) |
| let rawWindow = try values.string("window", maximumLength: 32) |
| guard let window = CalendarQueryWindow(rawValue: rawWindow) else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: "window", |
| reason: "expected today, tomorrow, or next_7_days" |
| ) |
| } |
| let limit = try values.integer("limit", default: 10, range: 1...10) |
| guard await context.isApplicationActive() else { |
| throw AgentToolRegistryError.contextRejected( |
| "Calendar can be read only while Dolphin is in the foreground." |
| ) |
| } |
| let authorization = await calendarReader.authorizationStatus() |
| guard authorization == .fullAccess else { |
| throw SystemCalendarReaderError.fullAccessRequired(authorization) |
| } |
| let interval = window.dateInterval( |
| containing: dateProvider.now(), |
| calendar: calendar |
| ) |
| let page = try await calendarReader.readEvents( |
| in: interval, |
| limit: limit + 1 |
| ) |
| try Task.checkCancellation() |
| guard await context.isApplicationActive() else { |
| throw AgentToolRegistryError.contextRejected( |
| "Dolphin left the foreground before the Calendar read completed." |
| ) |
| } |
| let matchingEvents = page.events |
| .filter { event in |
| event.startsAt < interval.end && event.endsAt > interval.start |
| } |
| .sorted { first, second in |
| if first.startsAt != second.startsAt { |
| return first.startsAt < second.startsAt |
| } |
| if first.endsAt != second.endsAt { |
| return first.endsAt < second.endsAt |
| } |
| return first.title < second.title |
| } |
| let visibleEvents = Array(matchingEvents.prefix(limit)) |
| let payloadEvents = visibleEvents.map { event in |
| JSONValue.object([ |
| "all_day": .bool(event.isAllDay), |
| "ends_at": .string(Self.iso8601(event.endsAt)), |
| "starts_at": .string(Self.iso8601(event.startsAt)), |
| "title": .string( |
| Self.calendarTitle(event.title, maximumCharacters: 160) |
| ), |
| ]) |
| } |
| let payload = Self.boundedCalendarPayload( |
| window: window, |
| timeZoneID: calendar.timeZone.identifier, |
| events: payloadEvents, |
| maximumCharacters: definition.maxOutputCharacters, |
| alreadyTruncated: page.hasMore || matchingEvents.count > limit |
| ) |
| return AgentToolResult( |
| modelText: payload, |
| displayText: visibleEvents.isEmpty |
| ? "No calendar events \(window.displayLabel)" |
| : "Found \(visibleEvents.count) calendar event\(visibleEvents.count == 1 ? "" : "s")", |
| succeeded: true |
| ) |
|
|
| default: |
| throw AgentToolRegistryError.unknownTool(call.name) |
| } |
| } |
|
|
| private static func schema( |
| properties: [String: JSONValue], |
| required: [String] |
| ) -> JSONValue { |
| .object([ |
| "type": .string("object"), |
| "properties": .object(properties), |
| "required": .array(required.map(JSONValue.string)), |
| "additionalProperties": .bool(false), |
| ]) |
| } |
|
|
| private static func stringArraySchema( |
| maximumItems: Int, |
| maximumLength: Int |
| ) -> JSONValue { |
| .object([ |
| "type": .string("array"), |
| "items": .object([ |
| "type": .string("string"), |
| "minLength": .number(1), |
| "maxLength": .number(Double(maximumLength)), |
| ]), |
| "maxItems": .number(Double(maximumItems)), |
| ]) |
| } |
|
|
| private static func uuidArraySchema(maximumItems: Int) -> JSONValue { |
| .object([ |
| "type": .string("array"), |
| "items": .object([ |
| "type": .string("string"), |
| "format": .string("uuid"), |
| ]), |
| "maxItems": .number(Double(maximumItems)), |
| ]) |
| } |
|
|
| private static func iso8601(_ date: Date) -> String { |
| ISO8601DateFormatter().string(from: date) |
| } |
|
|
| private static func formatNumber(_ value: Double) -> String { |
| if value == 0 { return "0" } |
| if value.rounded() == value, abs(value) <= 9_007_199_254_740_991 { |
| return String(Int64(value)) |
| } |
| return String( |
| format: "%.15g", |
| locale: Locale(identifier: "en_US_POSIX"), |
| value |
| ) |
| } |
|
|
| private static func boundedCollectionPayload( |
| key: String, |
| values: [JSONValue], |
| maximumCharacters: Int, |
| alreadyTruncated: Bool = false |
| ) -> String { |
| var included: [JSONValue] = [] |
| for value in values { |
| let candidate = included + [value] |
| let encoded = JSONValue.object([ |
| "count": .number(Double(candidate.count)), |
| key: .array(candidate), |
| "truncated": .bool( |
| alreadyTruncated || candidate.count < values.count |
| ), |
| ]).canonicalJSON |
| guard encoded.count <= maximumCharacters else { break } |
| included = candidate |
| } |
| return JSONValue.object([ |
| "count": .number(Double(included.count)), |
| key: .array(included), |
| "truncated": .bool( |
| alreadyTruncated || included.count < values.count |
| ), |
| ]).canonicalJSON |
| } |
|
|
| private static func boundedKnowledgeSearchPayload( |
| query: String, |
| values: [JSONValue], |
| maximumCharacters: Int, |
| alreadyTruncated: Bool |
| ) -> String { |
| var included: [JSONValue] = [] |
| for value in values { |
| let candidate = included + [value] |
| let encoded = knowledgeSearchPayload( |
| query: query, |
| values: candidate, |
| truncated: alreadyTruncated || candidate.count < values.count |
| ) |
| guard encoded.count <= maximumCharacters else { break } |
| included = candidate |
| } |
| return knowledgeSearchPayload( |
| query: query, |
| values: included, |
| truncated: alreadyTruncated || included.count < values.count |
| ) |
| } |
|
|
| private static func boundedMemorySearchPayload( |
| query: String, |
| values: [JSONValue], |
| maximumCharacters: Int, |
| alreadyTruncated: Bool |
| ) -> String { |
| var included: [JSONValue] = [] |
| for value in values { |
| let candidate = included + [value] |
| let encoded = memorySearchPayload( |
| query: query, |
| values: candidate, |
| truncated: alreadyTruncated || candidate.count < values.count |
| ) |
| guard encoded.count <= maximumCharacters else { break } |
| included = candidate |
| } |
| return memorySearchPayload( |
| query: query, |
| values: included, |
| truncated: alreadyTruncated || included.count < values.count |
| ) |
| } |
|
|
| private static func memorySearchPayload( |
| query: String, |
| values: [JSONValue], |
| truncated: Bool |
| ) -> String { |
| JSONValue.object([ |
| "count": .number(Double(values.count)), |
| "memories": .array(values), |
| "query": .string(query), |
| "truncated": .bool(truncated), |
| ]).canonicalJSON |
| } |
|
|
| private static func knowledgeSearchPayload( |
| query: String, |
| values: [JSONValue], |
| truncated: Bool |
| ) -> String { |
| JSONValue.object([ |
| "count": .number(Double(values.count)), |
| "items": .array(values), |
| "query": .string(query), |
| "retrieval": .string("local_hybrid_lexical"), |
| "truncated": .bool(truncated), |
| ]).canonicalJSON |
| } |
|
|
| private static func boundedKnowledgeDetailPayload( |
| _ item: KnowledgeItem, |
| maximumCharacters: Int |
| ) -> String { |
| var contentLimit = min(item.content.count, 1_800) |
| while true { |
| let content = clipped( |
| item.content, |
| maximumCharacters: max(contentLimit, 64) |
| ) |
| let payload = JSONValue.object([ |
| "content": .string(content), |
| "content_truncated": .bool(content != item.content), |
| "created_at": .string(iso8601(item.createdAt)), |
| "id": .string(item.id.uuidString), |
| "kind": .string(item.kind.rawValue), |
| "related_ids": .array( |
| item.relatedItemIDs.map { .string($0.uuidString) } |
| ), |
| "revision": .number(Double(item.revision)), |
| "source": .string(item.source.rawValue), |
| "tags": .array(item.tags.map(JSONValue.string)), |
| "title": .string(item.title), |
| "updated_at": .string(iso8601(item.updatedAt)), |
| ]).canonicalJSON |
| if payload.count <= maximumCharacters || contentLimit <= 64 { |
| return payload |
| } |
| contentLimit = max(64, contentLimit - 192) |
| } |
| } |
|
|
| private static func boundedProjectReviewPayload( |
| digest: KnowledgeDigest, |
| insights: [ProactiveKnowledgeInsight], |
| workspace: AssistantWorkspace, |
| activeHandoffID: UUID?, |
| maximumCharacters: Int |
| ) -> String { |
| let summary = clipped(digest.summary, maximumCharacters: 900) |
| let insightValues = insights.map { insight in |
| JSONValue.object([ |
| "detail": .string(clipped(insight.detail, maximumCharacters: 300)), |
| "evidence_ids": .array( |
| insight.evidenceItemIDs.prefix(6).map { .string($0.uuidString) } |
| ), |
| "id": .string(insight.id.uuidString), |
| "priority": .string(insight.priority.rawValue), |
| "title": .string(insight.title), |
| ]) |
| } |
| var priorities: [String] = [] |
| var questions: [String] = [] |
| var evidenceIDs: [UUID] = [] |
| var includedInsights: [JSONValue] = [] |
|
|
| func payload(truncated: Bool) -> String { |
| JSONValue.object([ |
| "active_handoff_id": activeHandoffID.map { |
| .string($0.uuidString) |
| } ?? .null, |
| "evidence_ids": .array( |
| evidenceIDs.map { .string($0.uuidString) } |
| ), |
| "generated_at": .string(iso8601(digest.createdAt)), |
| "handoff_count": .number(Double(workspace.handoffs.count)), |
| "insights": .array(includedInsights), |
| "knowledge_count": .number(Double(workspace.knowledgeItems.count)), |
| "open_questions": .array(questions.map(JSONValue.string)), |
| "open_task_count": .number( |
| Double(workspace.tasks.filter { !$0.isCompleted }.count) |
| ), |
| "priorities": .array(priorities.map(JSONValue.string)), |
| "summary": .string(summary), |
| "truncated": .bool(truncated), |
| ]).canonicalJSON |
| } |
|
|
| for priority in digest.priorities.prefix(6) { |
| let previous = priorities |
| priorities.append(clipped(priority, maximumCharacters: 180)) |
| if payload(truncated: true).count > maximumCharacters { |
| priorities = previous |
| break |
| } |
| } |
| for question in digest.openQuestions.prefix(4) { |
| let previous = questions |
| questions.append(clipped(question, maximumCharacters: 220)) |
| if payload(truncated: true).count > maximumCharacters { |
| questions = previous |
| break |
| } |
| } |
| for id in digest.evidenceItemIDs.prefix(20) { |
| let previous = evidenceIDs |
| evidenceIDs.append(id) |
| if payload(truncated: true).count > maximumCharacters { |
| evidenceIDs = previous |
| break |
| } |
| } |
| for insight in insightValues { |
| let previous = includedInsights |
| includedInsights.append(insight) |
| if payload(truncated: true).count > maximumCharacters { |
| includedInsights = previous |
| break |
| } |
| } |
|
|
| let truncated = summary != digest.summary |
| || priorities.count != min(digest.priorities.count, 6) |
| || questions.count != min(digest.openQuestions.count, 4) |
| || evidenceIDs.count != min(digest.evidenceItemIDs.count, 20) |
| || includedInsights.count != insightValues.count |
| return payload(truncated: truncated) |
| } |
|
|
| private static func boundedHandoffDetailPayload( |
| _ handoff: SessionHandoff, |
| isActive: Bool, |
| maximumCharacters: Int |
| ) -> String { |
| let summary = clipped(handoff.summary, maximumCharacters: 900) |
| var nextSteps: [String] = [] |
| var knowledgeIDs: [UUID] = [] |
| var taskIDs: [UUID] = [] |
|
|
| func payload(truncated: Bool) -> String { |
| JSONValue.object([ |
| "active": .bool(isActive), |
| "created_at": .string(iso8601(handoff.createdAt)), |
| "focus": .string(handoff.focus), |
| "id": .string(handoff.id.uuidString), |
| "knowledge_ids": .array( |
| knowledgeIDs.map { .string($0.uuidString) } |
| ), |
| "next_steps": .array(nextSteps.map(JSONValue.string)), |
| "restored_at": handoff.restoredAt.map { |
| .string(iso8601($0)) |
| } ?? .null, |
| "summary": .string(summary), |
| "summary_truncated": .bool(summary != handoff.summary), |
| "task_ids": .array(taskIDs.map { .string($0.uuidString) }), |
| "title": .string(handoff.title), |
| "truncated": .bool(truncated), |
| ]).canonicalJSON |
| } |
|
|
| for step in handoff.nextSteps { |
| let previous = nextSteps |
| nextSteps.append(step) |
| if payload(truncated: true).count > maximumCharacters { |
| nextSteps = previous |
| break |
| } |
| } |
| for id in handoff.knowledgeItemIDs { |
| let previous = knowledgeIDs |
| knowledgeIDs.append(id) |
| if payload(truncated: true).count > maximumCharacters { |
| knowledgeIDs = previous |
| break |
| } |
| } |
| for id in handoff.taskIDs { |
| let previous = taskIDs |
| taskIDs.append(id) |
| if payload(truncated: true).count > maximumCharacters { |
| taskIDs = previous |
| break |
| } |
| } |
| return payload( |
| truncated: summary != handoff.summary |
| || nextSteps.count != handoff.nextSteps.count |
| || knowledgeIDs.count != handoff.knowledgeItemIDs.count |
| || taskIDs.count != handoff.taskIDs.count |
| ) |
| } |
|
|
| private static func boundedTaskSearchPayload( |
| query: String, |
| values: [JSONValue], |
| maximumCharacters: Int, |
| alreadyTruncated: Bool |
| ) -> String { |
| var included: [JSONValue] = [] |
| for value in values { |
| let candidate = included + [value] |
| let encoded = taskSearchPayload( |
| query: query, |
| values: candidate, |
| truncated: alreadyTruncated || candidate.count < values.count |
| ) |
| guard encoded.count <= maximumCharacters else { break } |
| included = candidate |
| } |
| return taskSearchPayload( |
| query: query, |
| values: included, |
| truncated: alreadyTruncated || included.count < values.count |
| ) |
| } |
|
|
| private static func taskSearchPayload( |
| query: String, |
| values: [JSONValue], |
| truncated: Bool |
| ) -> String { |
| JSONValue.object([ |
| "count": .number(Double(values.count)), |
| "query": .string(query), |
| "tasks": .array(values), |
| "truncated": .bool(truncated), |
| ]).canonicalJSON |
| } |
|
|
| private static func boundedProjectContextPayload( |
| digest: KnowledgeDigest, |
| activeHandoffID: UUID?, |
| maximumCharacters: Int |
| ) -> String { |
| let summary = clipped( |
| digest.summary, |
| maximumCharacters: min(1_200, max(256, maximumCharacters / 2)) |
| ) |
| var priorities: [String] = [] |
| var questions: [String] = [] |
|
|
| func payload( |
| priorities: [String], |
| questions: [String], |
| truncated: Bool |
| ) -> String { |
| JSONValue.object([ |
| "active_handoff_id": activeHandoffID.map { |
| .string($0.uuidString) |
| } ?? .null, |
| "evidence_count": .number(Double(digest.evidenceItemIDs.count)), |
| "generated_at": .string(iso8601(digest.createdAt)), |
| "open_questions": .array(questions.map(JSONValue.string)), |
| "priorities": .array(priorities.map(JSONValue.string)), |
| "summary": .string(summary), |
| "truncated": .bool(truncated), |
| ]).canonicalJSON |
| } |
|
|
| for priority in digest.priorities { |
| let candidate = priorities + [priority] |
| guard payload( |
| priorities: candidate, |
| questions: questions, |
| truncated: true |
| ).count <= maximumCharacters else { break } |
| priorities = candidate |
| } |
| for question in digest.openQuestions { |
| let candidate = questions + [question] |
| guard payload( |
| priorities: priorities, |
| questions: candidate, |
| truncated: true |
| ).count <= maximumCharacters else { break } |
| questions = candidate |
| } |
|
|
| return payload( |
| priorities: priorities, |
| questions: questions, |
| truncated: summary != digest.summary |
| || priorities.count != digest.priorities.count |
| || questions.count != digest.openQuestions.count |
| ) |
| } |
|
|
| private static func boundedCodeAnalysisPayload( |
| analysis: CodeAnalysis, |
| findings: [JSONValue], |
| inputSHA256: String, |
| maximumCharacters: Int |
| ) -> String { |
| var included: [JSONValue] = [] |
| for finding in findings { |
| let candidate = included + [finding] |
| let encoded = codeAnalysisPayload( |
| analysis: analysis, |
| findings: candidate, |
| inputSHA256: inputSHA256, |
| truncated: analysis.wasTruncated || candidate.count < findings.count |
| ) |
| guard encoded.count <= maximumCharacters else { break } |
| included = candidate |
| } |
| return codeAnalysisPayload( |
| analysis: analysis, |
| findings: included, |
| inputSHA256: inputSHA256, |
| truncated: analysis.wasTruncated || included.count < findings.count |
| ) |
| } |
|
|
| private static func codeAnalysisPayload( |
| analysis: CodeAnalysis, |
| findings: [JSONValue], |
| inputSHA256: String, |
| truncated: Bool |
| ) -> String { |
| JSONValue.object([ |
| "analyzed_characters": .number(Double(analysis.analyzedCharacterCount)), |
| "findings": .array(findings), |
| "input_sha256": .string(inputSHA256), |
| "language": .string(analysis.language), |
| "line_count": .number(Double(analysis.lineCount)), |
| "max_nesting": .number(Double(analysis.maxNestingApproximation)), |
| "nonempty_line_count": .number(Double(analysis.nonemptyLineCount)), |
| "truncated": .bool(truncated), |
| ]).canonicalJSON |
| } |
|
|
| private static func boundedCalendarPayload( |
| window: CalendarQueryWindow, |
| timeZoneID: String, |
| events: [JSONValue], |
| maximumCharacters: Int, |
| alreadyTruncated: Bool |
| ) -> String { |
| var included: [JSONValue] = [] |
| for event in events { |
| let candidate = included + [event] |
| let encoded = calendarPayload( |
| window: window, |
| timeZoneID: timeZoneID, |
| events: candidate, |
| truncated: alreadyTruncated || candidate.count < events.count |
| ) |
| guard encoded.count <= maximumCharacters else { break } |
| included = candidate |
| } |
| return calendarPayload( |
| window: window, |
| timeZoneID: timeZoneID, |
| events: included, |
| truncated: alreadyTruncated || included.count < events.count |
| ) |
| } |
|
|
| private static func calendarPayload( |
| window: CalendarQueryWindow, |
| timeZoneID: String, |
| events: [JSONValue], |
| truncated: Bool |
| ) -> String { |
| JSONValue.object([ |
| "count": .number(Double(events.count)), |
| "events": .array(events), |
| "time_zone": .string(timeZoneID), |
| "truncated": .bool(truncated), |
| "window": .string(window.rawValue), |
| ]).canonicalJSON |
| } |
|
|
| private static func calendarTitle( |
| _ title: String, |
| maximumCharacters: Int |
| ) -> String { |
| let sanitized = sanitizeUntrusted(title) |
| .split(whereSeparator: { $0.isWhitespace }) |
| .joined(separator: " ") |
| .trimmingCharacters(in: .whitespacesAndNewlines) |
| let fallback = sanitized.isEmpty ? "Untitled event" : sanitized |
| return clipped(fallback, maximumCharacters: maximumCharacters) |
| } |
|
|
| private struct RankedMemory { |
| let item: MemoryItem |
| let score: Int |
| } |
|
|
| private static let memorySearchStopWords: Set<String> = [ |
| "a", "about", "am", "an", "and", "are", "did", "do", "does", |
| "for", "i", "is", "it", "me", "my", "of", "please", "recall", |
| "remember", "s", "search", "tell", "the", "to", "was", "were", |
| "what", "whats", "who", "you", "your", |
| ] |
|
|
| private static func memoryMatches( |
| query: String, |
| memories: [MemoryItem], |
| limit: Int |
| ) -> [MemoryItem] { |
| let normalizedQuery = normalizedSearchText(query) |
| let queryTerms = searchTerms(in: normalizedQuery) |
| let ranked = memories.compactMap { item -> RankedMemory? in |
| let normalizedContent = normalizedSearchText(item.content) |
| let contentTerms = searchTerms(in: normalizedContent) |
| let overlap = queryTerms.intersection(contentTerms).count |
| let exactMatch = !normalizedQuery.isEmpty |
| && normalizedContent.contains(normalizedQuery) |
| guard exactMatch || overlap > 0 else { return nil } |
| return RankedMemory( |
| item: item, |
| score: (exactMatch ? 100 : 0) + overlap * 10 |
| ) |
| }.sorted { first, second in |
| if first.score != second.score { return first.score > second.score } |
| return first.item.createdAt > second.item.createdAt |
| } |
| return Array(ranked.prefix(limit).map(\.item)) |
| } |
|
|
| private static func normalizedSearchText(_ text: String) -> String { |
| text.folding( |
| options: [.caseInsensitive, .diacriticInsensitive], |
| locale: Locale(identifier: "en_US_POSIX") |
| ).split { character in |
| !character.isLetter && !character.isNumber |
| }.joined(separator: " ") |
| } |
|
|
| private static func searchTerms(in text: String) -> Set<String> { |
| Set( |
| text.split(separator: " ").map(String.init).filter { token in |
| token.count > 1 && !memorySearchStopWords.contains(token) |
| } |
| ) |
| } |
|
|
| private static func clipped( |
| _ text: String, |
| maximumCharacters: Int |
| ) -> String { |
| guard text.count > maximumCharacters else { return text } |
| let suffix = "… [truncated]" |
| let prefixCount = max(0, maximumCharacters - suffix.count) |
| return String(text.prefix(prefixCount)) + suffix |
| } |
|
|
| private static func sanitizeUntrusted(_ text: String) -> String { |
| let allowedControls = CharacterSet(charactersIn: "\n\r\t") |
| let scalars = text.unicodeScalars.filter { scalar in |
| !CharacterSet.controlCharacters.contains(scalar) |
| || allowedControls.contains(scalar) |
| } |
| return String(String.UnicodeScalarView(scalars)) |
| .replacingOccurrences(of: "<", with: "‹") |
| .replacingOccurrences(of: ">", with: "›") |
| } |
| } |
|
|
| private struct ExactArguments { |
| private let values: [String: JSONValue] |
|
|
| init( |
| _ values: [String: JSONValue], |
| allowed: Set<String>, |
| required: Set<String> |
| ) throws { |
| let unexpected = Set(values.keys).subtracting(allowed).sorted() |
| guard unexpected.isEmpty else { |
| throw AgentToolRegistryError.unexpectedArguments(unexpected) |
| } |
| for key in required.sorted() where values[key] == nil { |
| throw AgentToolRegistryError.missingArgument(key) |
| } |
| self.values = values |
| } |
|
|
| func string(_ name: String, maximumLength: Int) throws -> String { |
| guard let value = values[name] else { |
| throw AgentToolRegistryError.missingArgument(name) |
| } |
| guard case .string(let raw) = value else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "expected a string" |
| ) |
| } |
| let result = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| guard !result.isEmpty else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "must not be empty" |
| ) |
| } |
| guard result.count <= maximumLength else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "must be at most \(maximumLength) characters" |
| ) |
| } |
| return result |
| } |
|
|
| func optionalString( |
| _ name: String, |
| default defaultValue: String, |
| maximumLength: Int |
| ) throws -> String { |
| guard values[name] != nil else { return defaultValue } |
| return try string(name, maximumLength: maximumLength) |
| } |
|
|
| func stringArray( |
| _ name: String, |
| default defaultValue: [String], |
| maximumCount: Int, |
| maximumElementLength: Int |
| ) throws -> [String] { |
| guard let value = values[name] else { return defaultValue } |
| guard case .array(let rawValues) = value else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "expected an array of strings" |
| ) |
| } |
| guard rawValues.count <= maximumCount else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "must contain at most \(maximumCount) values" |
| ) |
| } |
| var result: [String] = [] |
| for rawValue in rawValues { |
| guard case .string(let raw) = rawValue else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "expected an array of strings" |
| ) |
| } |
| let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) |
| guard !cleaned.isEmpty, cleaned.count <= maximumElementLength else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "each value must contain 1...\(maximumElementLength) characters" |
| ) |
| } |
| if !result.contains(cleaned) { result.append(cleaned) } |
| } |
| return result |
| } |
|
|
| func uuidArray( |
| _ name: String, |
| default defaultValue: [UUID], |
| maximumCount: Int |
| ) throws -> [UUID] { |
| let rawValues = try stringArray( |
| name, |
| default: defaultValue.map(\.uuidString), |
| maximumCount: maximumCount, |
| maximumElementLength: 36 |
| ) |
| return try rawValues.map { raw in |
| guard let value = UUID(uuidString: raw) else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "expected UUID strings" |
| ) |
| } |
| return value |
| } |
| } |
|
|
| func integer( |
| _ name: String, |
| default defaultValue: Int, |
| range: ClosedRange<Int> |
| ) throws -> Int { |
| guard let value = values[name] else { return defaultValue } |
| guard case .number(let number) = value, |
| number.isFinite, |
| number.rounded(.towardZero) == number, |
| number >= Double(Int.min), |
| number <= Double(Int.max) |
| else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "expected an integer" |
| ) |
| } |
| let result = Int(number) |
| guard range.contains(result) else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "expected \(range.lowerBound)...\(range.upperBound)" |
| ) |
| } |
| return result |
| } |
|
|
| func boolean(_ name: String, default defaultValue: Bool) throws -> Bool { |
| guard let value = values[name] else { return defaultValue } |
| guard case .bool(let result) = value else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "expected a boolean" |
| ) |
| } |
| return result |
| } |
|
|
| func uuid(_ name: String) throws -> UUID { |
| let raw = try string(name, maximumLength: 36) |
| guard let result = UUID(uuidString: raw) else { |
| throw AgentToolRegistryError.invalidArgument( |
| name: name, |
| reason: "expected a UUID" |
| ) |
| } |
| return result |
| } |
| } |
|
|
| private enum SafeCalculatorError: LocalizedError, Sendable { |
| case invalidExpression |
| case tooComplex |
| case divisionByZero |
| case nonFiniteResult |
|
|
| var errorDescription: String? { |
| switch self { |
| case .invalidExpression: |
| "The arithmetic expression is invalid." |
| case .tooComplex: |
| "The arithmetic expression is too complex." |
| case .divisionByZero: |
| "Division by zero is not allowed." |
| case .nonFiniteResult: |
| "The arithmetic result is outside the supported range." |
| } |
| } |
| } |
|
|
| private enum SafeCalculator { |
| static func evaluate(_ expression: String) throws -> Double { |
| var parser = Parser(characters: Array(expression)) |
| let value = try parser.parseExpression() |
| parser.skipWhitespace() |
| guard parser.isAtEnd else { throw SafeCalculatorError.invalidExpression } |
| guard value.isFinite else { throw SafeCalculatorError.nonFiniteResult } |
| return value |
| } |
|
|
| private struct Parser { |
| let characters: [Character] |
| var index = 0 |
| var operationCount = 0 |
| var nestingDepth = 0 |
|
|
| var isAtEnd: Bool { index >= characters.count } |
|
|
| mutating func parseExpression() throws -> Double { |
| var value = try parseTerm() |
| while true { |
| skipWhitespace() |
| if consume("+") { |
| value = try checked(value + parseTerm()) |
| } else if consume("-") { |
| value = try checked(value - parseTerm()) |
| } else { |
| return value |
| } |
| try countOperation() |
| } |
| } |
|
|
| mutating func parseTerm() throws -> Double { |
| var value = try parseUnary() |
| while true { |
| skipWhitespace() |
| if consume("*") { |
| value = try checked(value * parseUnary()) |
| } else if consume("/") { |
| let divisor = try parseUnary() |
| guard divisor != 0 else { throw SafeCalculatorError.divisionByZero } |
| value = try checked(value / divisor) |
| } else if consume("%") { |
| let divisor = try parseUnary() |
| guard divisor != 0 else { throw SafeCalculatorError.divisionByZero } |
| value = try checked(value.truncatingRemainder(dividingBy: divisor)) |
| } else { |
| return value |
| } |
| try countOperation() |
| } |
| } |
|
|
| mutating func parseUnary() throws -> Double { |
| skipWhitespace() |
| if consume("+") { |
| try countOperation() |
| return try parseUnary() |
| } |
| if consume("-") { |
| try countOperation() |
| return try checked(-(parseUnary())) |
| } |
| return try parsePrimary() |
| } |
|
|
| mutating func parsePrimary() throws -> Double { |
| skipWhitespace() |
| if consume("(") { |
| nestingDepth += 1 |
| guard nestingDepth <= 20 else { throw SafeCalculatorError.tooComplex } |
| let result = try parseExpression() |
| skipWhitespace() |
| guard consume(")") else { throw SafeCalculatorError.invalidExpression } |
| nestingDepth -= 1 |
| return result |
| } |
| return try parseNumber() |
| } |
|
|
| mutating func parseNumber() throws -> Double { |
| skipWhitespace() |
| let start = index |
| var digitCount = 0 |
|
|
| while let character = peek(), Self.isDigit(character) { |
| digitCount += 1 |
| index += 1 |
| } |
| if consume(".") { |
| while let character = peek(), Self.isDigit(character) { |
| digitCount += 1 |
| index += 1 |
| } |
| } |
| guard digitCount > 0 else { throw SafeCalculatorError.invalidExpression } |
|
|
| if let character = peek(), character == "e" || character == "E" { |
| index += 1 |
| if let sign = peek(), sign == "+" || sign == "-" { index += 1 } |
| let exponentStart = index |
| while let digit = peek(), Self.isDigit(digit) { index += 1 } |
| guard index > exponentStart else { |
| throw SafeCalculatorError.invalidExpression |
| } |
| } |
|
|
| let raw = String(characters[start..<index]) |
| guard let result = Double(raw), result.isFinite else { |
| throw SafeCalculatorError.nonFiniteResult |
| } |
| return result |
| } |
|
|
| mutating func skipWhitespace() { |
| while let character = peek(), character.isWhitespace { index += 1 } |
| } |
|
|
| mutating func consume(_ expected: Character) -> Bool { |
| guard peek() == expected else { return false } |
| index += 1 |
| return true |
| } |
|
|
| func peek() -> Character? { |
| isAtEnd ? nil : characters[index] |
| } |
|
|
| mutating func countOperation() throws { |
| operationCount += 1 |
| guard operationCount <= 100 else { throw SafeCalculatorError.tooComplex } |
| } |
|
|
| func checked(_ value: Double) throws -> Double { |
| guard value.isFinite else { throw SafeCalculatorError.nonFiniteResult } |
| return value |
| } |
|
|
| static func isDigit(_ character: Character) -> Bool { |
| character >= "0" && character <= "9" |
| } |
| } |
| } |
|
|