Custom HTTP tools an administrator defines
A row in custom_tools becomes a ToolDef like any built-in, offered beside the thirteen. The registry had to stop being an import-time constant for that: `resolve_tools` now returns the schemas *and* the runners together, carried to the loop on the ToolContext. That closes a hole on the way. `run_tool` looked names up in the global REGISTRY with no reference to what had been offered, so a model naming a tool its chat was gated out of -- a family switched off, a permission the reader lacks -- had it run anyway. The resolved set is now authoritative. Arguments come from a model, so an argument may fill a hole but never move the target: the scheme and host of a URL template are literal, values are escaped for where they land, and the origin is pinned afterwards. Every redirect hop is checked the way services/fetch.py checks one, and the secret is dropped if a hop leaves the origin it was issued for. Also fixes the tool-activity block claiming every library tool had "searched the web", which it has done since the second family landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -166,6 +166,57 @@ def _context(**kwargs):
|
||||
return tools_service.ToolContext(owner_id="someone", **kwargs)
|
||||
|
||||
|
||||
# --- The offer and the runner travel together --------------------------------
|
||||
def test_the_resolved_set_carries_the_runners_with_the_schemas(db, user_id):
|
||||
"""A tool that is a database row is not reachable through the import-time
|
||||
registry, so the resolution has to travel with the offer."""
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
||||
chat = _chat_with(db, user_id, capabilities={"tools": True})
|
||||
resolved = tools_service.resolve_tools(db, chat, _user(db, user_id))
|
||||
|
||||
assert _names(resolved.schemas) == set(resolved.by_name)
|
||||
assert all(callable(tool.run) for tool in resolved.defs)
|
||||
# The old accessor is the same set, so nothing that only wants schemas moved.
|
||||
assert resolved.schemas == tools_service.enabled_tools(db, chat, _user(db, user_id))
|
||||
|
||||
|
||||
async def test_a_tool_that_was_not_offered_is_refused(db, user_id):
|
||||
"""The lookup is against what was offered, not against everything that
|
||||
exists. A model naming a tool its chat was gated out of used to have it run,
|
||||
because only the offer was ever filtered."""
|
||||
from lembas.db.models import User
|
||||
from lembas.services.library import notes as notes_service
|
||||
|
||||
owner = db.get(User, user_id)
|
||||
note = notes_service.create(db, owner=owner, title="Keep me", body="...")
|
||||
|
||||
chat = _chat_with(db, user_id, capabilities={"tools": True, "tool_notes": False})
|
||||
resolved = tools_service.resolve_tools(db, chat, owner)
|
||||
context = tools_service.context_for(db, owner, chat, tools=resolved)
|
||||
|
||||
outcome = await tools_service.run_tool(
|
||||
context, "notes_delete", json.dumps({"id": note.id})
|
||||
)
|
||||
assert outcome.event["status"] == "error"
|
||||
assert notes_service.get(db, note.id, owner) is not None
|
||||
|
||||
|
||||
async def test_a_context_with_no_toolset_still_finds_the_builtins(monkeypatch):
|
||||
"""None means nobody resolved a set. An empty dict does not -- it means
|
||||
nothing was offered, and is authoritative."""
|
||||
|
||||
async def fake_run(_config, query, *, limit=None):
|
||||
return [SearchResult("A title", "https://a.test", "a snippet")]
|
||||
|
||||
monkeypatch.setattr("lembas.services.search.run", fake_run)
|
||||
|
||||
unresolved = await tools_service.run_tool(_context(), "web_search", '{"query": "x"}')
|
||||
assert unresolved.event["status"] == "ok"
|
||||
|
||||
empty = await tools_service.run_tool(_context(tools={}), "web_search", '{"query": "x"}')
|
||||
assert empty.event["status"] == "error"
|
||||
|
||||
|
||||
# --- Knowledge is scoped to the chat's bases ---------------------------------
|
||||
async def test_knowledge_search_is_limited_to_the_attached_bases(db, user_id):
|
||||
""""Answer from the contracts folder" is a different question from "answer
|
||||
|
||||
Reference in New Issue
Block a user