feat(backend/copilot): add include_graph option to find_library_agent for agent debugging/editing

The copilot's edit_agent tool required the LLM to provide a complete agent
JSON (nodes + links) without ever seeing the current graph structure — it was
editing blindly. This adds an `include_graph` boolean parameter to the
existing `find_library_agent` tool so the copilot can fetch the full graph
before making modifications.

Also updates the agent generation guide to split creating vs editing
workflows, instructing the LLM to always fetch the current graph first.
This commit is contained in:
Zamil Majdy
2026-03-31 15:43:35 +02:00
parent 24d0c35ed3
commit 9cf6ac9ad9
6 changed files with 149 additions and 10 deletions

View File

@@ -18,7 +18,7 @@ or trigger. If so:
**Skip this** when the goal already specifies all dimensions (e.g.
"scrape prices from Amazon and email me daily").
### Workflow for Creating/Editing Agents
### Workflow for Creating Agents
1. **Discover blocks**: Call `find_block(query, include_schemas=true)` to
search for relevant blocks. This returns block IDs, names, descriptions,
@@ -36,8 +36,25 @@ or trigger. If so:
for errors
6. **Fix if needed**: Call `fix_agent_graph` to auto-fix common issues,
or fix manually based on the error descriptions. Iterate until valid.
7. **Save**: Call `create_agent` (new) or `edit_agent` (existing) with
the final `agent_json`
7. **Save**: Call `create_agent` with the final `agent_json`
### Workflow for Editing Agents
1. **Fetch the current graph**: Call
`find_library_agent(query="<agent_id>", include_graph=true)` to get the
full graph structure (nodes + links). **Never edit blindly** — always
inspect the current graph first so you know exactly what to change.
2. **Understand the graph**: Review the returned nodes, links, and
`input_default` values. Identify which nodes and links need changes.
3. **Discover blocks** (if needed): Call `find_block` to look up new blocks
you need to add.
4. **Modify the JSON**: Apply targeted changes to the agent JSON — add,
remove, or update specific nodes and links. Preserve unchanged parts.
5. **Validate**: Call `validate_agent_graph` with the modified JSON.
6. **Fix if needed**: Call `fix_agent_graph` or fix manually. Iterate until
valid.
7. **Save**: Call `edit_agent` with the agent ID and the modified
`agent_json`
### Agent JSON Structure

View File

@@ -12,6 +12,7 @@ if TYPE_CHECKING:
from backend.data.db_accessors import library_db, store_db
from backend.util.exceptions import DatabaseError, NotFoundError
from .agent_generator import get_agent_as_json
from .models import (
AgentInfo,
AgentsFoundResponse,
@@ -34,12 +35,13 @@ async def search_agents(
source: SearchSource,
session_id: str | None = None,
user_id: str | None = None,
include_graph: bool = False,
) -> ToolResponseBase:
"""Search for agents in marketplace or user library."""
if source == "marketplace":
return await _search_marketplace(query, session_id)
else:
return await _search_library(query, session_id, user_id)
return await _search_library(query, session_id, user_id, include_graph)
async def _search_marketplace(query: str, session_id: str | None) -> ToolResponseBase:
@@ -105,7 +107,10 @@ async def _search_marketplace(query: str, session_id: str | None) -> ToolRespons
async def _search_library(
query: str, session_id: str | None, user_id: str | None
query: str,
session_id: str | None,
user_id: str | None,
include_graph: bool = False,
) -> ToolResponseBase:
"""Search user's library agents, with direct UUID lookup fallback."""
if not user_id:
@@ -149,6 +154,9 @@ async def _search_library(
session_id=session_id,
)
if include_graph and agents:
await _enrich_agents_with_graph(agents, user_id)
if not agents:
if not query:
return NoResultsResponse(
@@ -196,6 +204,18 @@ async def _search_library(
)
async def _enrich_agents_with_graph(agents: list[AgentInfo], user_id: str) -> None:
"""Fetch and attach full graph JSON (nodes + links) to each agent in-place."""
for agent in agents:
graph_id = agent.graph_id
if not graph_id:
continue
try:
agent.graph = await get_agent_as_json(graph_id, user_id)
except Exception as e:
logger.warning(f"Failed to fetch graph for agent {graph_id}: {e}")
def _marketplace_agent_to_info(agent: StoreAgent | StoreAgentDetails) -> AgentInfo:
"""Convert a marketplace agent (StoreAgent or StoreAgentDetails) to an AgentInfo."""
return AgentInfo(

View File

@@ -133,10 +133,10 @@ class TestMarketplaceSlugLookup:
class TestLibraryUUIDLookup:
"""Tests for UUID direct lookup in library search."""
@pytest.mark.asyncio(loop_scope="session")
async def test_uuid_lookup_found_by_graph_id(self):
"""UUID query matching a graph_id returns the agent directly."""
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
@staticmethod
def _make_mock_library_agent(
agent_id: str = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
) -> MagicMock:
mock_agent = MagicMock()
mock_agent.id = "lib-agent-id"
mock_agent.name = "My Library Agent"
@@ -150,6 +150,13 @@ class TestLibraryUUIDLookup:
mock_agent.graph_version = 1
mock_agent.input_schema = {}
mock_agent.output_schema = {}
return mock_agent
@pytest.mark.asyncio(loop_scope="session")
async def test_uuid_lookup_found_by_graph_id(self):
"""UUID query matching a graph_id returns the agent directly."""
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
mock_agent = self._make_mock_library_agent(agent_id)
mock_lib_db = MagicMock()
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
@@ -168,3 +175,75 @@ class TestLibraryUUIDLookup:
assert isinstance(response, AgentsFoundResponse)
assert response.count == 1
assert response.agents[0].name == "My Library Agent"
@pytest.mark.asyncio(loop_scope="session")
async def test_include_graph_fetches_nodes_and_links(self):
"""include_graph=True attaches full graph JSON to agent results."""
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
mock_agent = self._make_mock_library_agent(agent_id)
mock_lib_db = MagicMock()
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
fake_graph = {
"id": agent_id,
"name": "My Library Agent",
"nodes": [{"id": "node-1", "block_id": "block-1"}],
"links": [{"id": "link-1", "source_id": "node-1", "sink_id": "node-2"}],
}
with (
patch(
"backend.copilot.tools.agent_search.library_db",
return_value=mock_lib_db,
),
patch(
"backend.copilot.tools.agent_search.get_agent_as_json",
new_callable=AsyncMock,
return_value=fake_graph,
) as mock_get_json,
):
response = await search_agents(
query=agent_id,
source="library",
session_id="test-session",
user_id=_TEST_USER_ID,
include_graph=True,
)
assert isinstance(response, AgentsFoundResponse)
assert response.agents[0].graph is not None
assert response.agents[0].graph["nodes"] == fake_graph["nodes"]
assert response.agents[0].graph["links"] == fake_graph["links"]
mock_get_json.assert_awaited_once_with(agent_id, _TEST_USER_ID)
@pytest.mark.asyncio(loop_scope="session")
async def test_include_graph_false_does_not_fetch(self):
"""include_graph=False (default) does not fetch graph data."""
agent_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
mock_agent = self._make_mock_library_agent(agent_id)
mock_lib_db = MagicMock()
mock_lib_db.get_library_agent_by_graph_id = AsyncMock(return_value=mock_agent)
with (
patch(
"backend.copilot.tools.agent_search.library_db",
return_value=mock_lib_db,
),
patch(
"backend.copilot.tools.agent_search.get_agent_as_json",
new_callable=AsyncMock,
) as mock_get_json,
):
response = await search_agents(
query=agent_id,
source="library",
session_id="test-session",
user_id=_TEST_USER_ID,
include_graph=False,
)
assert isinstance(response, AgentsFoundResponse)
assert response.agents[0].graph is None
mock_get_json.assert_not_awaited()

View File

@@ -20,7 +20,8 @@ class FindLibraryAgentTool(BaseTool):
def description(self) -> str:
return (
"Search user's library agents. Returns graph_id, schemas for sub-agent composition. "
"Omit query to list all."
"Omit query to list all. Set include_graph=true to also fetch the full "
"graph structure (nodes + links) for debugging or editing."
)
@property
@@ -32,6 +33,15 @@ class FindLibraryAgentTool(BaseTool):
"type": "string",
"description": "Search by name/description. Omit to list all.",
},
"include_graph": {
"type": "boolean",
"description": (
"When true, includes the full graph structure "
"(nodes + links) for each found agent. "
"Use when you need to inspect, debug, or edit an agent."
),
"default": False,
},
},
"required": [],
}
@@ -52,4 +62,5 @@ class FindLibraryAgentTool(BaseTool):
source="library",
session_id=session.session_id,
user_id=user_id,
include_graph=kwargs.get("include_graph", False),
)

View File

@@ -122,6 +122,10 @@ class AgentInfo(BaseModel):
default=None,
description="Input schema for the agent, including field names, types, and defaults",
)
graph: dict[str, Any] | None = Field(
default=None,
description="Full graph structure (nodes + links) when include_graph is requested",
)
class AgentsFoundResponse(ToolResponseBase):

View File

@@ -7460,6 +7460,14 @@
],
"title": "Inputs",
"description": "Input schema for the agent, including field names, types, and defaults"
},
"graph": {
"anyOf": [
{ "additionalProperties": true, "type": "object" },
{ "type": "null" }
],
"title": "Graph",
"description": "Full graph structure (nodes + links) when include_graph is requested"
}
},
"type": "object",