-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. --- Tool service facade: constructs registry, executor, and registers built-ins. local json = require("cjson.safe") local registry_mod = require("agent_smith.tool.registry") local executor_mod = require("agent_smith.tool.executor") local builtin = require("agent_smith.tool.builtin") local repository = require("agent_smith.persistence.repository") local M = {} local function shallow_copy(source) local out = {} for key, value in pairs(source or {}) do out[key] = value end return out end local function resolve_repositories(options, context, get_cached) if type(context) == "table" and type(context.repos) == "table" then return context.repos end local services = type(context) == "table" and (context.services or context.service_handles) or nil if type(services) == "table" and type(services.repos) == "table" then return services.repos end local persistence = nil if type(services) == "table" and type(services.persistence) == "table" then persistence = services.persistence elseif type(context) == "table" and type(context.persistence) == "table" then persistence = context.persistence elseif type(options.persistence) == "table" then persistence = options.persistence end if type(persistence) == "table" and type(persistence.repositories) == "function" then local ok, repos = pcall(persistence.repositories) if ok and type(repos) == "table" then return repos end end if type(options.repos) == "table" then return options.repos end if type(options.repositories) == "function" then local ok, repos = pcall(options.repositories) if ok and type(repos) == "table" then return repos end end return get_cached() end local function resolve_conversation_id(options, context) if type(context) == "table" and type(context.conversation_id) == "string" and context.conversation_id ~= "" then return context.conversation_id end if type(context) == "table" and type(context.context) == "table" then local nested = context.context.conversation_id if type(nested) == "string" and nested ~= "" then return nested end end if type(options.conversation_id) == "string" and options.conversation_id ~= "" then return options.conversation_id end return nil end local function resolve_project_id(options, context) if type(context) == "table" and type(context.project_id) == "string" and context.project_id ~= "" then return context.project_id end if type(context) == "table" and type(context.context) == "table" then local nested = context.context.project_id if type(nested) == "string" and nested ~= "" then return nested end end if type(options.project_id) == "string" and options.project_id ~= "" then return options.project_id end return nil end local function make_scratchpad_store(options, get_cached) return { record = function(key, value, context) local conversation_id = resolve_conversation_id(options, context) if not conversation_id then return nil, { code = "missing_context", detail = "record requires context.conversation_id", } end local repos = resolve_repositories(options, context, get_cached) if type(repos) ~= "table" or type(repos.conversation_note) ~= "table" then return nil, { code = "missing_repository", detail = "conversation_note repository is unavailable", } end if type(repos.conversation_note.upsert) ~= "function" then return nil, { code = "missing_repository", detail = "conversation_note repository does not support upsert", } end local encoded = json.encode({ key = key, value = value }) if not encoded then return nil, { code = "storage_failed", detail = "failed to encode scratchpad value as JSON", } end return repos.conversation_note.upsert({ conversation_id = conversation_id, note_id = "scratchpad:" .. key, content = encoded, }) end, recall = function(key, context) local conversation_id = resolve_conversation_id(options, context) if not conversation_id then return nil, { code = "missing_context", detail = "recall requires context.conversation_id", } end local repos = resolve_repositories(options, context, get_cached) if type(repos) ~= "table" or type(repos.conversation_note) ~= "table" then return nil, { code = "missing_repository", detail = "conversation_note repository is unavailable", } end if type(repos.conversation_note.get) ~= "function" then return nil, { code = "missing_repository", detail = "conversation_note repository does not support get", } end local note, err = repos.conversation_note.get(conversation_id, "scratchpad:" .. key) if err then return nil, err end if not note then return nil end local decoded = json.decode(note.content) if type(decoded) == "table" and decoded.value ~= nil then return decoded.value end return note.content end, scan = function(prefix, context) local conversation_id = resolve_conversation_id(options, context) if not conversation_id then return nil, { code = "missing_context", detail = "recall requires context.conversation_id", } end local repos = resolve_repositories(options, context, get_cached) if type(repos) ~= "table" or type(repos.conversation_note) ~= "table" then return nil, { code = "missing_repository", detail = "conversation_note repository is unavailable", } end if type(repos.conversation_note.list_by_conversation) ~= "function" then return nil, { code = "missing_repository", detail = "conversation_note repository does not support list_by_conversation", } end local notes, err = repos.conversation_note.list_by_conversation(conversation_id) if not notes then return nil, err end local entries = {} for i = 1, #notes do local note = notes[i] local note_id = type(note.note_id) == "string" and note.note_id or "" if note_id:sub(1, 11) == "scratchpad:" then local key = note_id:sub(12) if key:sub(1, #prefix) == prefix then local decoded = json.decode(note.content) local value = note.content if type(decoded) == "table" and decoded.value ~= nil then value = decoded.value end entries[#entries + 1] = { key = key, value = value } end end end table.sort(entries, function(a, b) return a.key < b.key end) return entries end, } end local function make_note_store(options, get_cached) return { list = function(context) local project_id = resolve_project_id(options, context) if not project_id then return nil, { code = "missing_project_id", detail = "note requires context.project_id", } end local repos = resolve_repositories(options, context, get_cached) if type(repos) ~= "table" or type(repos.project_memory) ~= "table" then return nil, { code = "missing_repository", detail = "project_memory repository is unavailable", } end if type(repos.project_memory.list) ~= "function" then return nil, { code = "missing_repository", detail = "project_memory repository does not support list", } end return repos.project_memory.list(project_id) end, get = function(id, context) local project_id = resolve_project_id(options, context) if not project_id then return nil, { code = "missing_project_id", detail = "note requires context.project_id", } end local repos = resolve_repositories(options, context, get_cached) if type(repos) ~= "table" or type(repos.project_memory) ~= "table" then return nil, { code = "missing_repository", detail = "project_memory repository is unavailable", } end if type(repos.project_memory.get) ~= "function" then return nil, { code = "missing_repository", detail = "project_memory repository does not support get", } end return repos.project_memory.get(project_id, id) end, upsert = function(id, content, context) local project_id = resolve_project_id(options, context) if not project_id then return nil, { code = "missing_project_id", detail = "note requires context.project_id", } end local repos = resolve_repositories(options, context, get_cached) if type(repos) ~= "table" or type(repos.project_memory) ~= "table" then return nil, { code = "missing_repository", detail = "project_memory repository is unavailable", } end if type(repos.project_memory.upsert) ~= "function" then return nil, { code = "missing_repository", detail = "project_memory repository does not support upsert", } end return repos.project_memory.upsert({ project_id = project_id, memory_id = id, text = content, }) end, delete = function(id, context) local project_id = resolve_project_id(options, context) if not project_id then return nil, { code = "missing_project_id", detail = "note requires context.project_id", } end local repos = resolve_repositories(options, context, get_cached) if type(repos) ~= "table" or type(repos.project_memory) ~= "table" then return nil, { code = "missing_repository", detail = "project_memory repository is unavailable", } end if type(repos.project_memory.delete) ~= "function" then return nil, { code = "missing_repository", detail = "project_memory repository does not support delete", } end return repos.project_memory.delete(project_id, id) end, } end function M.new(options) options = options or {} local reg = registry_mod.new(options.registry or options) local builtin_options = shallow_copy(options) if type(options.builtin_options) == "table" then for key, value in pairs(options.builtin_options) do builtin_options[key] = value end end local cached_repositories = nil local function get_cached_repositories() if cached_repositories then return cached_repositories end cached_repositories = repository.new(options.repository_options or {}) return cached_repositories end if type(builtin_options.store) ~= "table" and type(builtin_options.scratchpad_store) ~= "table" then builtin_options.scratchpad_store = make_scratchpad_store(builtin_options, get_cached_repositories) end if type(builtin_options.store) ~= "table" and type(builtin_options.note_store) ~= "table" then builtin_options.note_store = make_note_store(builtin_options, get_cached_repositories) end local err = builtin.register_all(reg, builtin_options) if err then return nil, err end local event_bus = options.event_bus local exec = executor_mod.new({ registry = reg, event_bus = event_bus, }) local service = { name = "tool", kind = "service", options = options, registry = reg, executor = exec, } function service:execute(call, context, exec_options) return self.executor:execute(call, context, exec_options) end function service:list(role) return self.registry:list(role) end function service:resolve(name, role) return self.registry:resolve(name, role) end function service:get(name) return self.registry:get(name) end function service:llm_specs(role) return self.registry:llm_specs(role) end return service end return M