Require identity for agent node tools

This commit is contained in:
HumbleTurtle
2026-05-22 23:32:31 -03:00
parent 5fb0603802
commit fa754bb5bd
2 changed files with 155 additions and 31 deletions
+74 -31
View File
@@ -9707,6 +9707,22 @@ def agent_node_args(args, key="path"):
return scoped return scoped
def resolve_node_ref_allow_agent(args, *, agent_slug=None):
if args.get("path"):
raise RuntimeError("path is an index/route only; resolve it with nap_node_by_path, then use node_id or uri")
if args.get("uri"):
resolved = node_by_uri({"uri": args["uri"], "view": args.get("view", "render")})["node"]
elif args.get("node_id") or args.get("id"):
resolved = get_node_by_id({"node_id": args.get("node_id") or args.get("id"), "view": args.get("view", "render")})
else:
raise RuntimeError("one of node_id or uri is required")
if agent_slug:
root = agent_root_path(agent_slug)
if not (resolved.get("full_path") or "").startswith(f"{root}/"):
raise RuntimeError(f"node is not inside /agents/{agent_slug}")
return resolved
def list_agent_nodes(args): def list_agent_nodes(args):
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
root = agent_root_path(agent_slug_from_args(args)) root = agent_root_path(agent_slug_from_args(args))
@@ -9727,23 +9743,32 @@ def list_agent_nodes(args):
def get_agent_node(args): def get_agent_node(args):
return read_node_by_path({"path": agent_scoped_path(args)}, allow_agent=True) slug = agent_slug_from_args(args)
return resolve_node_ref_allow_agent(args, agent_slug=slug)
def upsert_agent_node(args): def upsert_agent_node(args):
scoped = agent_node_args(args)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
existing = try_get_node_by_path(scoped["path"], allow_agent=True) slug = agent_slug_from_args(args)
if existing: if args.get("node_id") or args.get("id") or args.get("uri"):
existing = resolve_node_ref_allow_agent(args, agent_slug=slug)
updated = request_project_write( updated = request_project_write(
"PATCH", "PATCH",
f"/v1/projects/{project_id}/nodes/{existing['id']}", f"/v1/projects/{project_id}/nodes/{existing['id']}",
node_payload_from_args(scoped, existing=existing), node_payload_from_args(args, existing=existing),
project_id, project_id,
f"update agent node {existing['full_path']}", f"update agent node {existing['full_path']}",
"node",
existing["id"],
) )
index_node(updated) index_node(updated)
return {"ok": True, "changed": True, "created": False, "updated": True, **node_write_reference(updated)} return {"ok": True, "changed": True, "created": False, "updated": True, **node_write_reference(updated)}
if not args.get("path"):
raise RuntimeError("path is required when creating an agent node; use node_id or uri to update an existing node")
scoped = agent_node_args(args)
existing = try_get_node_by_path(scoped["path"], allow_agent=True)
if existing:
raise RuntimeError("path already resolves to an agent node; use node_id or uri to update it")
created = request_project_write( created = request_project_write(
"POST", "POST",
f"/v1/projects/{project_id}/nodes", f"/v1/projects/{project_id}/nodes",
@@ -9756,30 +9781,34 @@ def upsert_agent_node(args):
def update_agent_node(args): def update_agent_node(args):
scoped = agent_node_args(args) slug = agent_slug_from_args(args)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
existing = read_node_by_path({"path": scoped["path"]}, allow_agent=True) existing = resolve_node_ref_allow_agent(args, agent_slug=slug)
updated = request_project_write( updated = request_project_write(
"PATCH", "PATCH",
f"/v1/projects/{project_id}/nodes/{existing['id']}", f"/v1/projects/{project_id}/nodes/{existing['id']}",
node_payload_from_args(scoped, existing=existing), node_payload_from_args(args, existing=existing),
project_id, project_id,
f"update agent node {existing['full_path']}", f"update agent node {existing['full_path']}",
"node",
existing["id"],
) )
index_node(updated) index_node(updated)
return {"ok": True, "changed": True, "updated": True, **node_write_reference(updated)} return {"ok": True, "changed": True, "updated": True, **node_write_reference(updated)}
def archive_agent_node(args): def archive_agent_node(args):
scoped = agent_node_args(args) slug = agent_slug_from_args(args)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
existing = read_node_by_path({"path": scoped["path"]}, allow_agent=True) existing = resolve_node_ref_allow_agent(args, agent_slug=slug)
request_project_write( request_project_write(
"DELETE", "DELETE",
f"/v1/projects/{project_id}/nodes/{existing['id']}", f"/v1/projects/{project_id}/nodes/{existing['id']}",
None, None,
project_id, project_id,
f"archive agent node {existing['full_path']}", f"archive agent node {existing['full_path']}",
"node",
existing["id"],
) )
remove_indexed_node(existing["id"]) remove_indexed_node(existing["id"])
return {"archived": True, "path": existing["full_path"], "id": existing["id"]} return {"archived": True, "path": existing["full_path"], "id": existing["id"]}
@@ -9788,11 +9817,10 @@ def archive_agent_node(args):
def link_agent_node(args): def link_agent_node(args):
slug = agent_slug_from_args(args) slug = agent_slug_from_args(args)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
source_path = agent_scoped_path({"agent_slug": slug, "path": args["source_path"]}) if args.get("source_path") or args.get("target_path"):
target_raw = str(args.get("target_path") or "").strip() raise RuntimeError("paths are index/routes only; use source_node_id/target_node_id or source_uri/target_uri")
target_path = agent_scoped_path({"agent_slug": slug, "path": target_raw}) if is_agent_namespace_path(target_raw) or not target_raw.startswith("/") else normalize_node_path(target_raw) source = resolve_node_ref_allow_agent({"node_id": args.get("source_node_id"), "uri": args.get("source_uri")}, agent_slug=slug)
source = read_node_by_path({"path": source_path}, allow_agent=True) target = resolve_node_ref_allow_agent({"node_id": args.get("target_node_id"), "uri": args.get("target_uri")})
target = read_node_by_path({"path": target_path}, allow_agent=is_agent_namespace_path(target_path))
relation = args.get("relation", "references") relation = args.get("relation", "references")
links = source.get("links") or [] links = source.get("links") or []
if not isinstance(links, list): if not isinstance(links, list):
@@ -9811,6 +9839,8 @@ def link_agent_node(args):
{"links": links}, {"links": links},
project_id, project_id,
f"link agent node {source['full_path']}", f"link agent node {source['full_path']}",
"node",
source["id"],
) )
index_node(updated) index_node(updated)
else: else:
@@ -11640,23 +11670,29 @@ def raw_tools():
}, },
{ {
"name": "nap_agent_cat", "name": "nap_agent_cat",
"description": "Read one file inside /agents/{agent_slug}. Path may be relative to the agent root.", "description": "Read one existing node inside /agents/{agent_slug} by canonical node_id or nap:// URI.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["agent_slug", "path"], "required": ["agent_slug"],
"properties": {"agent_slug": {"type": "string"}, "path": {"type": "string"}}, "properties": {
"agent_slug": {"type": "string"},
"node_id": {"type": "string"},
"uri": {"type": "string"},
},
"additionalProperties": False, "additionalProperties": False,
}, },
}, },
{ {
"name": "nap_agent_tee", "name": "nap_agent_tee",
"description": "Create or update a file inside /agents/{agent_slug} using an auto-acquired project lock. Use this for agent profile, role, personality, rules, and memory.", "description": "Create a route-addressed node inside /agents/{agent_slug}, or update an existing agent node by canonical node_id or nap:// URI.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["agent_slug", "path"], "required": ["agent_slug"],
"properties": { "properties": {
"agent_slug": {"type": "string"}, "agent_slug": {"type": "string"},
"path": {"type": "string"}, "path": {"type": "string", "description": "Route path for creating a new agent node. Existing-node updates require node_id or uri."},
"node_id": {"type": "string"},
"uri": {"type": "string"},
"content_text": {"type": "string"}, "content_text": {"type": "string"},
"type": {"type": "string"}, "type": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}}, "tags": {"type": "array", "items": {"type": "string"}},
@@ -11669,13 +11705,14 @@ def raw_tools():
}, },
{ {
"name": "nap_agent_patch", "name": "nap_agent_patch",
"description": "Update an existing file inside /agents/{agent_slug}.", "description": "Update an existing node inside /agents/{agent_slug} by canonical node_id or nap:// URI.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["agent_slug", "path"], "required": ["agent_slug"],
"properties": { "properties": {
"agent_slug": {"type": "string"}, "agent_slug": {"type": "string"},
"path": {"type": "string"}, "node_id": {"type": "string"},
"uri": {"type": "string"},
"content_text": {"type": "string"}, "content_text": {"type": "string"},
"type": {"type": "string"}, "type": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}}, "tags": {"type": "array", "items": {"type": "string"}},
@@ -11688,24 +11725,30 @@ def raw_tools():
}, },
{ {
"name": "nap_agent_rm", "name": "nap_agent_rm",
"description": "Archive a file inside /agents/{agent_slug}.", "description": "Archive an existing node inside /agents/{agent_slug} by canonical node_id or nap:// URI.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["agent_slug", "path"], "required": ["agent_slug"],
"properties": {"agent_slug": {"type": "string"}, "path": {"type": "string"}}, "properties": {
"agent_slug": {"type": "string"},
"node_id": {"type": "string"},
"uri": {"type": "string"},
},
"additionalProperties": False, "additionalProperties": False,
}, },
}, },
{ {
"name": "nap_agent_ln", "name": "nap_agent_ln",
"description": "Create a graph link from an agent file to another node. Agent source is scoped by agent_slug.", "description": "Create a graph link from an existing agent node to another existing node by canonical node identities.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["agent_slug", "source_path", "target_path"], "required": ["agent_slug"],
"properties": { "properties": {
"agent_slug": {"type": "string"}, "agent_slug": {"type": "string"},
"source_path": {"type": "string"}, "source_node_id": {"type": "string"},
"target_path": {"type": "string"}, "source_uri": {"type": "string"},
"target_node_id": {"type": "string"},
"target_uri": {"type": "string"},
"relation": {"type": "string", "default": "references"}, "relation": {"type": "string", "default": "references"},
}, },
"additionalProperties": False, "additionalProperties": False,
@@ -348,6 +348,26 @@ def test_tools_include_node_by_path_descriptor(mod):
assert "plan_uri" in plan_to_kanban_schema["properties"] assert "plan_uri" in plan_to_kanban_schema["properties"]
assert "plan_path" not in plan_to_kanban_schema["properties"] assert "plan_path" not in plan_to_kanban_schema["properties"]
for name in ["nap_agent_cat", "nap_agent_patch", "nap_agent_rm"]:
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
assert schema["required"] == ["agent_slug"]
assert "node_id" in schema["properties"]
assert "uri" in schema["properties"]
assert "path" not in schema["properties"]
agent_tee_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_tee")["inputSchema"]
assert agent_tee_schema["required"] == ["agent_slug"]
assert "path" in agent_tee_schema["properties"]
assert "node_id" in agent_tee_schema["properties"]
assert "uri" in agent_tee_schema["properties"]
agent_ln_schema = next(item for item in mod.tools() if item["name"] == "nap_agent_ln")["inputSchema"]
assert agent_ln_schema["required"] == ["agent_slug"]
assert "source_node_id" in agent_ln_schema["properties"]
assert "target_node_id" in agent_ln_schema["properties"]
assert "source_path" not in agent_ln_schema["properties"]
assert "target_path" not in agent_ln_schema["properties"]
def test_mutation_tools_reject_path_and_patch_by_node_id(mod): def test_mutation_tools_reject_path_and_patch_by_node_id(mod):
found = node("/notes/current", "active", node_type="note") found = node("/notes/current", "active", node_type="note")
@@ -485,6 +505,67 @@ def test_tee_and_bulk_create_by_path_update_by_identity(mod):
mod.bulk_upsert_nodes({"nodes": [{"path": "/notes/current", "content_text": "bad"}]}) mod.bulk_upsert_nodes({"nodes": [{"path": "/notes/current", "content_text": "bad"}]})
def test_agent_tools_create_by_route_and_update_by_identity(mod):
agent_file = node("/agents/alice/memory/current", "active", node_type="agent-file")
agent_file["id"] = "agent-node-123"
target = node("/notes/target", "active", node_type="note")
target["id"] = "target-123"
writes = []
def fake_get_node_by_id(args):
node_id = args.get("node_id")
if node_id == "agent-node-123":
return agent_file
if node_id == "target-123":
return target
raise AssertionError(f"unexpected node_id {node_id}")
mod.get_node_by_id = fake_get_node_by_id
mod.try_get_node_by_path = lambda path, allow_agent=False: agent_file if path == "/agents/alice/memory/current" else None
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload, args)) or {
**agent_file,
"id": "agent-node-new" if method == "POST" else agent_file["id"],
"full_path": f"{payload.get('folder_path')}/{payload.get('name')}" if method == "POST" else agent_file["full_path"],
"content_text": payload.get("content_text", agent_file["content_text"]) if payload else agent_file["content_text"],
"links": payload.get("links", agent_file["links"]) if payload else agent_file["links"],
}
mod.index_node = lambda node: None
mod.remove_indexed_node = lambda node_id: None
read = mod.get_agent_node({"agent_slug": "alice", "node_id": "agent-node-123"})
assert read["id"] == "agent-node-123"
created = mod.upsert_agent_node({"agent_slug": "alice", "path": "/memory/new", "content_text": "new"})
assert created["created"] is True
assert writes[-1][0] == "POST"
assert writes[-1][2]["folder_path"] == "/agents/alice/memory"
updated = mod.upsert_agent_node({"agent_slug": "alice", "node_id": "agent-node-123", "content_text": "updated"})
assert updated["updated"] is True
assert writes[-1][1] == "/v1/projects/project-1/nodes/agent-node-123"
assert writes[-1][3][2:] == ("node", "agent-node-123")
patched = mod.update_agent_node({"agent_slug": "alice", "node_id": "agent-node-123", "content_text": "patched"})
assert patched["updated"] is True
archived = mod.archive_agent_node({"agent_slug": "alice", "node_id": "agent-node-123"})
assert archived["archived"] is True
assert writes[-1][0] == "DELETE"
assert writes[-1][3][2:] == ("node", "agent-node-123")
linked = mod.link_agent_node({"agent_slug": "alice", "source_node_id": "agent-node-123", "target_node_id": "target-123"})
assert linked["created"] is True
assert writes[-1][2]["links"] == [{"path": "/notes/target", "relation": "references"}]
assert writes[-1][3][2:] == ("node", "agent-node-123")
with pytest.raises(RuntimeError, match="path already resolves"):
mod.upsert_agent_node({"agent_slug": "alice", "path": "/memory/current", "content_text": "bad"})
with pytest.raises(RuntimeError, match="path is an index/route only"):
mod.update_agent_node({"agent_slug": "alice", "path": "/memory/current", "content_text": "bad"})
with pytest.raises(RuntimeError, match="paths are index/routes only"):
mod.link_agent_node({"agent_slug": "alice", "source_path": "/memory/current", "target_path": "/notes/target"})
def test_discovery_rejects_full_content_view(mod): def test_discovery_rejects_full_content_view(mod):
with pytest.raises(RuntimeError, match="full node bodies require nap_node_get"): with pytest.raises(RuntimeError, match="full node bodies require nap_node_get"):
mod.list_project_nodes({"view": "full"}) mod.list_project_nodes({"view": "full"})