Require identity for agent node tools
This commit is contained in:
@@ -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"})
|
||||||
|
|||||||
Reference in New Issue
Block a user