Skip to content

Commit 5509d7e

Browse files
abossardCopilot
andcommitted
feat: suggest schema & tools, default no tools, pure function refactor
- 'Suggest Schema & Tools' button: LLM suggests output schema AND tool selection - Backend: _build_suggest_prompt and _parse_suggest_response as pure functions - Frontend: tools default to empty, populated by suggest response - RunsSidePanel: pure calculations extracted (buildAgentMap, sortRunsNewestFirst, resolveOutputSchema, resolveAgentName, parseRunOutput, formatRelativeTime) - All 49 Playwright tests pass (2 live LLM tests included) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 99d7ca5 commit 5509d7e

5 files changed

Lines changed: 309 additions & 158 deletions

File tree

backend/agent_builder/routes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,15 @@ async def workbench_list_tools():
149149

150150
@agent_builder_bp.route("/api/workbench/suggest-schema", methods=["POST"])
151151
async def workbench_suggest_schema():
152-
"""Suggest a JSON Schema for structured output based on agent definition."""
152+
"""Suggest a JSON Schema and tool selection based on agent definition."""
153153
try:
154154
data = await request.get_json()
155-
schema = await _workbench_service.suggest_schema(
155+
result = await _workbench_service.suggest_schema(
156156
name=data.get("name", ""),
157157
description=data.get("description", ""),
158158
system_prompt=data.get("system_prompt", ""),
159159
)
160-
return jsonify({"schema": schema}), 200
160+
return jsonify(result), 200
161161
except Exception as exc:
162162
return _error_response(exc)
163163

backend/agent_builder/service.py

Lines changed: 98 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,95 @@
3939
logger = logging.getLogger(__name__)
4040

4141

42+
# ============================================================================
43+
# CALCULATIONS (pure — no side effects, no I/O)
44+
# ============================================================================
45+
46+
def _build_suggest_prompt(
47+
name: str, description: str, system_prompt: str,
48+
all_tools: list[dict[str, Any]], tool_names_list: list[str],
49+
) -> str:
50+
"""Build the LLM prompt for schema + tool suggestion. Pure calculation."""
51+
context_parts = []
52+
if name:
53+
context_parts.append(f"Agent name: {name}")
54+
if description:
55+
context_parts.append(f"Description: {description}")
56+
if system_prompt:
57+
context_parts.append(f"System prompt: {system_prompt}")
58+
agent_context = "\n".join(context_parts)
59+
60+
tool_descriptions = "\n".join(
61+
f" - {t['name']}: {t['description'][:150]}" for t in all_tools
62+
)
63+
64+
return (
65+
"You are a JSON Schema designer AND tool selector for an AI agent.\n\n"
66+
"## Agent Definition\n"
67+
f"{agent_context}\n\n"
68+
"## Data Domain\n"
69+
"The agent works with IT support/helpdesk ticket data (BMC Remedy/ITSM export). "
70+
"Each ticket has fields: incident_id, summary, status, priority, assignee, "
71+
"assigned_group, requester_name, city, created_at, updated_at, notes, resolution, description.\n\n"
72+
"## Available Tools\n"
73+
f"{tool_descriptions}\n\n"
74+
"## UI Widget System\n"
75+
"Each property MUST have an 'x-ui' annotation with a 'widget' field:\n"
76+
" 'markdown' — GFM text. 'table' — array of objects ({\"columns\": [...]}).\n"
77+
" 'badge-list' — array of strings. 'stat-card' — single number ({\"label\": \"...\"}).\n"
78+
" 'bar-chart' — array of objects ({\"indexBy\": \"...\", \"keys\": [\"...\"]}).\n"
79+
" 'pie-chart' — object or [{\"id\":...,\"value\":...}]. 'json' — raw. 'hidden' — skip.\n\n"
80+
"## Your Task\n"
81+
"Return a JSON object with exactly two keys:\n"
82+
"1. \"schema\" — JSON Schema with 'type', 'properties', x-ui annotations.\n"
83+
"2. \"tool_names\" — array of tool names the agent needs.\n\n"
84+
"## Rules\n"
85+
"1. Always include 'message' (string, widget 'markdown').\n"
86+
"2. Always include 'referenced_tickets' (array of strings, widget 'badge-list').\n"
87+
"3. Match widget to data shape: numbers→stat-card, ticket lists→table, distributions→pie/bar-chart.\n"
88+
"4. For tool_names, select ONLY tools the agent actually needs.\n"
89+
f"5. Valid tool names: {tool_names_list}\n\n"
90+
"Respond with ONLY valid JSON (no markdown fences)."
91+
)
92+
93+
94+
def _parse_suggest_response(raw: str, valid_tool_names: list[str]) -> dict[str, Any]:
95+
"""Parse LLM response into {schema, tool_names}. Pure calculation."""
96+
import json as json_mod
97+
import re
98+
99+
json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)
100+
json_str = json_match.group(1) if json_match else raw
101+
102+
fallback = {
103+
"schema": {"type": "object", "properties": {"result": {"type": "string", "description": "Agent output"}}},
104+
"tool_names": valid_tool_names,
105+
}
106+
107+
try:
108+
result = json_mod.loads(json_str)
109+
if not isinstance(result, dict):
110+
return fallback
111+
112+
if "schema" in result and "tool_names" in result:
113+
schema, suggested_tools = result["schema"], result["tool_names"]
114+
elif "properties" in result:
115+
schema, suggested_tools = result, valid_tool_names
116+
else:
117+
return fallback
118+
119+
if not isinstance(schema, dict) or "properties" not in schema:
120+
return fallback
121+
122+
return {"schema": schema, "tool_names": [t for t in suggested_tools if t in valid_tool_names]}
123+
except (json_mod.JSONDecodeError, ValueError):
124+
return fallback
125+
126+
127+
# ============================================================================
128+
# SERVICE (actions — I/O, database, LLM calls)
129+
# ============================================================================
130+
42131
class WorkbenchService:
43132
"""
44133
Manages the full lifecycle of agent definitions, runs, and evaluations.
@@ -119,109 +208,21 @@ async def suggest_schema(
119208
system_prompt: str,
120209
) -> dict[str, Any]:
121210
"""
122-
Ask the LLM to propose a JSON Schema for the agent's output.
123-
124-
Provides full context about available data, tools, and UI widgets
125-
so the LLM can make informed schema suggestions.
211+
Ask the LLM to propose a JSON Schema AND recommended tools.
212+
213+
Action: calls LLM. Delegates prompt building and response
214+
parsing to pure functions (_build_suggest_prompt, _parse_suggest_response).
126215
"""
127-
context_parts = []
128-
if name:
129-
context_parts.append(f"Agent name: {name}")
130-
if description:
131-
context_parts.append(f"Description: {description}")
132-
if system_prompt:
133-
context_parts.append(f"System prompt: {system_prompt}")
134-
agent_context = "\n".join(context_parts)
135-
136-
# Gather available tool descriptions for context
137-
tool_descriptions = "\n".join(
138-
f" - {t['name']}: {t['description'][:150]}"
139-
for t in self.list_tools()
140-
)
216+
all_tools = self.list_tools()
217+
tool_names_list = [t["name"] for t in all_tools]
141218

142-
suggest_prompt = (
143-
"You are a JSON Schema designer for an AI agent output format.\n\n"
144-
"## Agent Definition\n"
145-
f"{agent_context}\n\n"
146-
"## Data Domain\n"
147-
"The agent works with IT support/helpdesk ticket data (BMC Remedy/ITSM export). "
148-
"Each ticket has these fields:\n"
149-
" - incident_id (string, e.g. 'INC000016349327')\n"
150-
" - summary (string, short description of the issue)\n"
151-
" - status (enum: new, assigned, in_progress, pending, resolved, closed, cancelled)\n"
152-
" - priority (enum: critical, high, medium, low)\n"
153-
" - assignee (string or null, person assigned)\n"
154-
" - assigned_group (string, e.g. 'WOS - Workplace & Software')\n"
155-
" - requester_name (string, who reported it)\n"
156-
" - city (string, e.g. 'Bern', 'Zollikofen', 'Ittigen')\n"
157-
" - created_at / updated_at (datetime)\n"
158-
" - notes, resolution, description (longer text fields)\n"
159-
" - operational_category_1/2/3 (categorization tiers)\n\n"
160-
"## Available Tools\n"
161-
f"{tool_descriptions}\n\n"
162-
"## UI Widget System\n"
163-
"Each property MUST have an 'x-ui' annotation with a 'widget' field. "
164-
"The frontend renders each property using the specified widget:\n\n"
165-
" 'markdown' — Renders text as GitHub-flavored Markdown (headings, tables, bold, lists).\n"
166-
" Use for: analysis text, recommendations, summaries, explanations.\n\n"
167-
" 'table' — Renders array of objects as an HTML table.\n"
168-
" Use for: ticket lists, comparison data, multi-row results.\n"
169-
" Options: {\"columns\": [\"col1\", \"col2\"]} to control visible columns and order.\n"
170-
" The array items must be objects with consistent keys.\n\n"
171-
" 'badge-list' — Renders array of strings as monospace badge chips.\n"
172-
" Use for: ticket IDs, tags, categories, short labels.\n\n"
173-
" 'stat-card' — Renders a single number as a large prominent card.\n"
174-
" Use for: totals, counts, percentages, KPIs.\n"
175-
" Options: {\"label\": \"Total Tickets\"} for the display label.\n\n"
176-
" 'bar-chart' — Renders array of objects as a Nivo bar chart.\n"
177-
" Use for: counts by category (status, priority, city, group).\n"
178-
" Options: {\"indexBy\": \"category_key\", \"keys\": [\"value_key\"]}.\n"
179-
" Data must be array of objects like [{\"status\": \"open\", \"count\": 42}, ...].\n\n"
180-
" 'pie-chart' — Renders object or array as a Nivo pie chart.\n"
181-
" Use for: proportional breakdowns (status distribution, priority split).\n"
182-
" Can accept {\"open\": 42, \"closed\": 18} or [{\"id\": \"open\", \"value\": 42}].\n\n"
183-
" 'json' — Renders as formatted JSON in a code block.\n"
184-
" Use for: raw data, debug output, complex nested structures.\n\n"
185-
" 'hidden' — Not rendered in the UI.\n"
186-
" Use for: internal metadata, IDs used for linking but not display.\n\n"
187-
"## Rules\n"
188-
"1. Always include a 'message' property (type string, widget 'markdown') for the main response.\n"
189-
"2. Always include 'referenced_tickets' (type array of strings, widget 'badge-list') listing ticket IDs looked at.\n"
190-
"3. Add additional properties based on what the agent would logically produce.\n"
191-
"4. Use descriptive property names (snake_case) and include 'description' for each.\n"
192-
"5. Match widget to data shape: numbers→stat-card, lists of tickets→table, distributions→pie/bar-chart.\n"
193-
"6. For table widgets, define 'columns' matching the object keys in the array items.\n\n"
194-
"Respond with ONLY a valid JSON object (no markdown fences, no explanation)."
195-
)
219+
prompt = _build_suggest_prompt(name, description, system_prompt, all_tools, tool_names_list)
196220

197221
from langchain_core.messages import HumanMessage
198-
response = await self.llm.ainvoke([HumanMessage(content=suggest_prompt)])
222+
response = await self.llm.ainvoke([HumanMessage(content=prompt)])
199223
raw = (response.content or "").strip()
200224

201-
# Extract JSON from response (strip markdown fences if present)
202-
import re
203-
json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)
204-
json_str = json_match.group(1) if json_match else raw
205-
206-
import json as json_mod
207-
try:
208-
schema = json_mod.loads(json_str)
209-
if not isinstance(schema, dict) or "properties" not in schema:
210-
raise ValueError("Schema must have 'properties'")
211-
return schema
212-
except (json_mod.JSONDecodeError, ValueError):
213-
return {
214-
"type": "object",
215-
"properties": {
216-
"result": {"type": "string", "description": "Agent output"},
217-
},
218-
}
219-
result.append({
220-
"name": t.name,
221-
"description": (t.description or "")[:200],
222-
"input_schema": input_schema,
223-
})
224-
return result
225+
return _parse_suggest_response(raw, tool_names_list)
225226

226227
# ------------------------------------------------------------------
227228
# Validation helpers (calculations)

frontend/src/features/workbench/AgentCreateForm.jsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export default function AgentCreateForm({ tools, onAgentCreated, initialData })
7878
if (initialData?.tool_names) {
7979
return [...initialData.tool_names]
8080
}
81-
return tools.map((t) => t.name)
81+
return [] // No tools selected by default — use "Suggest Schema & Tools"
8282
})
8383
const [outputSchema, setOutputSchema] = useState(() => {
8484
if (initialData?.output_schema && Object.keys(initialData.output_schema).length > 0) {
@@ -90,15 +90,13 @@ export default function AgentCreateForm({ tools, onAgentCreated, initialData })
9090
const [submitting, setSubmitting] = useState(false)
9191
const [error, setError] = useState('')
9292

93-
// Sync tool selection when tools list changes (only for create mode)
93+
// Keep tool selection in sync when tools list changes (remove stale names)
9494
useEffect(() => {
9595
if (isEditing) return
9696
const availableNames = tools.map((t) => t.name)
97-
setSelectedToolNames((prev) => {
98-
if (prev.length === 0) return availableNames
99-
const filtered = prev.filter((name) => availableNames.includes(name))
100-
return filtered.length > 0 ? filtered : availableNames
101-
})
97+
setSelectedToolNames((prev) =>
98+
prev.filter((name) => availableNames.includes(name)),
99+
)
102100
}, [tools, isEditing])
103101

104102
const toggleTool = (toolName) => {
@@ -133,7 +131,7 @@ export default function AgentCreateForm({ tools, onAgentCreated, initialData })
133131
return !nextErrors.name && !nextErrors.systemPrompt && !nextErrors.tools && !nextErrors.requiredInputDescription
134132
}
135133

136-
const handleSuggestSchema = async () => {
134+
const handleSuggestSchemaAndTools = async () => {
137135
setSuggestingSchema(true)
138136
setError('')
139137
try {
@@ -142,9 +140,14 @@ export default function AgentCreateForm({ tools, onAgentCreated, initialData })
142140
description: formData.description.trim(),
143141
systemPrompt: formData.systemPrompt.trim(),
144142
})
145-
setOutputSchema(JSON.stringify(resp.schema, null, 2))
143+
if (resp.schema) {
144+
setOutputSchema(JSON.stringify(resp.schema, null, 2))
145+
}
146+
if (resp.tool_names && Array.isArray(resp.tool_names)) {
147+
setSelectedToolNames(resp.tool_names)
148+
}
146149
} catch (err) {
147-
setError(err?.message || 'Failed to suggest schema')
150+
setError(err?.message || 'Failed to suggest schema and tools')
148151
} finally {
149152
setSuggestingSchema(false)
150153
}
@@ -295,9 +298,9 @@ export default function AgentCreateForm({ tools, onAgentCreated, initialData })
295298
<Button
296299
data-testid="workbench-suggest-schema-button"
297300
disabled={suggestingSchema || (!formData.name.trim() && !formData.systemPrompt.trim())}
298-
onClick={handleSuggestSchema}
301+
onClick={handleSuggestSchemaAndTools}
299302
>
300-
{suggestingSchema ? 'Suggesting...' : '✨ Suggest Schema'}
303+
{suggestingSchema ? 'Suggesting...' : '✨ Suggest Schema & Tools'}
301304
</Button>
302305
<Button
303306
appearance="primary"

0 commit comments

Comments
 (0)