Skip to content

Commit eb824e6

Browse files
authored
Agent fabric (#18)
* feat: Implement Tool Registry and Workbench Integration - Added ToolRegistry class to manage LangChain StructuredTool instances. - Created workbench_integration.py to wire tools into the Agent Workbench. - Developed WorkbenchPage component for agent management in the frontend. - Implemented backend tests for tool registration and agent operations. - Added end-to-end tests for agent creation and deletion in the UI. Signed-off-by: Andre Bossard <anbossar@microsoft.com> * feat: Refactor Agent Workbench to Agent Fabric and enhance tool metadata handling Signed-off-by: Andre Bossard <anbossar@microsoft.com> * feat: Add required input handling to agent definitions and update UI components Signed-off-by: Andre Bossard <anbossar@microsoft.com> * feat: Enhance Markdown output handling in agent workflow and update UI components Signed-off-by: Andre Bossard <anbossar@microsoft.com> * feat: Enhance ticket handling by adding incident ID support and improve UI components for better user experience Signed-off-by: Andre Bossard <anbossar@microsoft.com> * feat: Add tool invocation logging with latency tracking in WorkbenchService Signed-off-by: Andre Bossard <anbossar@microsoft.com> --------- Signed-off-by: Andre Bossard <anbossar@microsoft.com>
1 parent f47e48d commit eb824e6

11 files changed

Lines changed: 221 additions & 96 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,4 @@ logs/
5252
*.db
5353
csv/*.csv
5454
screenshots/
55+
*.png

backend/agent_workbench/service.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
The host project injects tools and LLM configuration at startup.
1010
"""
1111

12+
import logging
1213
import os
1314
from datetime import datetime
1415
from pathlib import Path
16+
from time import perf_counter
1517
from typing import Any, Optional
1618

1719
from sqlmodel import Session, select
@@ -33,6 +35,41 @@
3335
)
3436
from .tool_registry import ToolRegistry
3537

38+
logger = logging.getLogger(__name__)
39+
40+
41+
# ============================================================================
42+
# TOOL CALL LOGGING CALLBACK
43+
# ============================================================================
44+
45+
def _make_tool_logging_callback() -> Any:
46+
"""Create a callback handler that logs tool invocations with latency."""
47+
from langchain_core.callbacks import BaseCallbackHandler
48+
49+
class ToolCallLoggingCallback(BaseCallbackHandler):
50+
def __init__(self) -> None:
51+
super().__init__()
52+
self._start_times: dict[Any, float] = {}
53+
54+
def on_tool_start(self, serialized: dict[str, Any], input_str: str, *, run_id: Any, **kwargs: Any) -> None:
55+
self._start_times[run_id] = perf_counter()
56+
name = serialized.get("name", "?")
57+
preview = input_str[:200] if isinstance(input_str, str) else str(input_str)[:200]
58+
logger.info("🔧 Tool START name=%s run_id=%s input=%s", name, run_id, preview)
59+
60+
def on_tool_end(self, output: str, *, run_id: Any, **kwargs: Any) -> None:
61+
started = self._start_times.pop(run_id, None)
62+
ms = int((perf_counter() - started) * 1000) if started is not None else None
63+
preview = output[:300] if isinstance(output, str) else str(output)[:300]
64+
logger.info("✅ Tool END run_id=%s duration_ms=%s output=%s", run_id, ms, preview)
65+
66+
def on_tool_error(self, error: BaseException, *, run_id: Any, **kwargs: Any) -> None:
67+
started = self._start_times.pop(run_id, None)
68+
ms = int((perf_counter() - started) * 1000) if started is not None else None
69+
logger.error("❌ Tool ERROR run_id=%s duration_ms=%s error=%s", run_id, ms, error)
70+
71+
return ToolCallLoggingCallback()
72+
3673
# ============================================================================
3774
# LLM HELPER - isolated so it stays optional at import time
3875
# ============================================================================
@@ -381,11 +418,20 @@ async def run_agent(
381418
runtime_system_prompt = _append_markdown_output_instruction(agent_def.system_prompt)
382419
react = _build_react_agent(self.llm, tools, runtime_system_prompt)
383420

421+
logger.info("▶️ Agent run_id=%s agent=%s tools=%s prompt=%s",
422+
run_id, agent_id, validated_tool_names, user_message[:120])
423+
t0 = perf_counter()
424+
384425
result = await react.ainvoke(
385426
{"messages": [("user", user_message)]},
386-
config={"recursion_limit": self._recursion_limit},
427+
config={
428+
"recursion_limit": self._recursion_limit,
429+
"callbacks": [_make_tool_logging_callback()],
430+
},
387431
)
388432

433+
total_ms = int((perf_counter() - t0) * 1000)
434+
389435
final_msg = result["messages"][-1]
390436
output = final_msg.content if hasattr(final_msg, "content") else str(final_msg)
391437

@@ -398,6 +444,9 @@ async def run_agent(
398444
if name:
399445
tools_used.append(name)
400446

447+
logger.info("⏹️ Agent done run_id=%s total_ms=%s tools_used=%s messages=%d",
448+
run_id, total_ms, tools_used, len(result["messages"]))
449+
401450
# -- Persist completion --
402451
with Session(self._engine) as session:
403452
db_run = session.get(AgentRun, run_id)

backend/app.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -748,17 +748,21 @@ def get_sort_key(ticket: Ticket):
748748
@app.route("/api/csv-tickets/<ticket_id>", methods=["GET"])
749749
async def get_csv_ticket(ticket_id: str):
750750
"""
751-
Get one CSV ticket by ID.
751+
Get one CSV ticket by INC number (e.g. INC000016349327) or UUID.
752752
753753
Query params:
754754
- fields: optional comma-separated list of fields to include
755755
"""
756-
try:
757-
parsed_id = UUID(ticket_id)
758-
except ValueError:
759-
return jsonify({"error": "Invalid ticket ID"}), 400
756+
# Try INC number first (primary identifier)
757+
if ticket_id.upper().startswith("INC"):
758+
ticket = _csv_ticket_service.get_ticket_by_incident_id(ticket_id)
759+
else:
760+
try:
761+
parsed_id = UUID(ticket_id)
762+
except ValueError:
763+
return jsonify({"error": "Invalid ticket ID. Use an INC number (e.g. INC000016349327) or UUID."}), 400
764+
ticket = _csv_ticket_service.get_ticket(parsed_id)
760765

761-
ticket = _csv_ticket_service.get_ticket(parsed_id)
762766
if ticket is None:
763767
return jsonify({"error": "Ticket not found"}), 404
764768

backend/csv_data.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ class CSVTicketService:
440440

441441
def __init__(self):
442442
self._tickets: dict[UUID, Ticket] = {}
443+
self._tickets_by_incident_id: dict[str, Ticket] = {}
443444
self._loaded_files: set[str] = set()
444445

445446
def load_csv(self, file_path: str | Path) -> int:
@@ -455,13 +456,19 @@ def load_csv(self, file_path: str | Path) -> int:
455456

456457
for ticket in tickets:
457458
self._tickets[ticket.id] = ticket
459+
if ticket.incident_id:
460+
self._tickets_by_incident_id[ticket.incident_id] = ticket
458461

459462
self._loaded_files.add(file_key)
460463
return len(tickets)
461464

462465
def get_ticket(self, ticket_id: UUID) -> Optional[Ticket]:
463-
"""Get ticket by ID."""
466+
"""Get ticket by UUID."""
464467
return self._tickets.get(ticket_id)
468+
469+
def get_ticket_by_incident_id(self, incident_id: str) -> Optional[Ticket]:
470+
"""Get ticket by INC number (e.g. INC000016349327)."""
471+
return self._tickets_by_incident_id.get(incident_id)
465472

466473
def list_tickets(
467474
self,

backend/operations.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,16 @@ async def op_csv_list_tickets(
198198

199199
@operation(
200200
name="csv_get_ticket",
201-
description="Get a single CSV ticket by UUID",
201+
description="Get a single CSV ticket by INC number (e.g. INC000016349327) or UUID",
202202
http_method="GET",
203203
)
204204
async def op_csv_get_ticket(ticket_id: str) -> Ticket | None:
205-
"""Get one CSV ticket."""
205+
"""Get one CSV ticket by INC number or UUID."""
206206
_ensure_csv_loaded()
207+
# Try INC number first (primary identifier)
208+
if ticket_id.upper().startswith("INC"):
209+
return _csv_service.get_ticket_by_incident_id(ticket_id)
210+
# Fall back to UUID for internal use
207211
try:
208212
parsed_id = UUID(ticket_id)
209213
except ValueError:
@@ -213,7 +217,7 @@ async def op_csv_get_ticket(ticket_id: str) -> Ticket | None:
213217

214218
@operation(
215219
name="csv_search_tickets",
216-
description="Search CSV tickets by text across summary, description, notes, requester and location fields",
220+
description="Search CSV tickets by text across incident ID, summary, description, notes, requester and location fields",
217221
http_method="GET",
218222
)
219223
async def op_csv_search_tickets(query: str, limit: int = 50) -> list[Ticket]:
@@ -228,6 +232,7 @@ async def op_csv_search_tickets(query: str, limit: int = 50) -> list[Ticket]:
228232
for ticket in _csv_service.list_tickets():
229233
haystack = " ".join(
230234
[
235+
ticket.incident_id or "",
231236
ticket.summary or "",
232237
ticket.description or "",
233238
ticket.notes or "",

frontend/src/features/usecase-demo/UsecaseDemoPage.jsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export default function UsecaseDemoPage({ definition }) {
204204
)
205205

206206
const selectedTicket = useMemo(
207-
() => matchingTickets.find((ticket) => ticket.id === selectedTicketId) || null,
207+
() => matchingTickets.find((ticket) => ticket.incident_id === selectedTicketId || ticket.id === selectedTicketId) || null,
208208
[matchingTickets, selectedTicketId]
209209
)
210210

@@ -254,10 +254,10 @@ export default function UsecaseDemoPage({ definition }) {
254254
const loadedTickets = tickets.filter((ticket) => !ticket._error)
255255
setMatchingTickets(loadedTickets)
256256
setSelectedTicketId((previousId) => {
257-
if (previousId && loadedTickets.some((ticket) => ticket.id === previousId)) {
257+
if (previousId && loadedTickets.some((ticket) => ticket.incident_id === previousId || ticket.id === previousId)) {
258258
return previousId
259259
}
260-
return loadedTickets[0]?.id || null
260+
return loadedTickets[0]?.incident_id || loadedTickets[0]?.id || null
261261
})
262262

263263
if (!loadedTickets.length) {
@@ -462,31 +462,33 @@ export default function UsecaseDemoPage({ definition }) {
462462
</tr>
463463
</thead>
464464
<tbody>
465-
{matchingTickets.map((ticket, index) => (
466-
<tr key={ticket.id} className={index % 2 ? styles.rowAlt : ''}>
467-
<td className={styles.td}>{ticket.id}</td>
465+
{matchingTickets.map((ticket, index) => {
466+
const ticketKey = ticket.incident_id || ticket.id
467+
return (
468+
<tr key={ticketKey} className={index % 2 ? styles.rowAlt : ''}>
469+
<td className={styles.td}>{ticketKey}</td>
468470
<td className={styles.td}>{ticket.summary || '—'}</td>
469471
<td className={styles.td}>{ticket.status || '—'}</td>
470472
<td className={styles.td}>
471473
<Button
472474
size="small"
473-
appearance={selectedTicketId === ticket.id ? 'primary' : 'secondary'}
474-
onClick={() => setSelectedTicketId(ticket.id)}
475-
data-testid={`${testIdPrefix}-ticket-open-${ticket.id}`}
475+
appearance={selectedTicketId === ticketKey ? 'primary' : 'secondary'}
476+
onClick={() => setSelectedTicketId(ticketKey)}
477+
data-testid={`${testIdPrefix}-ticket-open-${ticketKey}`}
476478
>
477479
Open
478480
</Button>
479481
</td>
480482
</tr>
481-
))}
483+
)})}
482484
</tbody>
483485
</table>
484486
</div>
485487

486488
{selectedTicket && (
487489
<div className={styles.ticketDetails} data-testid={`${testIdPrefix}-ticket-details`}>
488490
<Text weight="semibold">{selectedTicket.summary || 'Ticket Details'}</Text>
489-
<Text>ID: {selectedTicket.id}</Text>
491+
<Text>INC: {selectedTicket.incident_id || '—'}</Text>
490492
<Text>Status: {selectedTicket.status || '—'}</Text>
491493
<Text>Priority: {selectedTicket.priority || '—'}</Text>
492494
<Text>Assignee: {selectedTicket.assignee || '—'}</Text>

frontend/src/features/usecase-demo/demoDefinitions.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
const VPN_DEFAULT_PROMPT = `Find VPN issues where you think it's more a skill issue than a technical issue.
22
33
For speed:
4-
- First call csv_search_tickets with fields="id,summary,status,priority,assignee,assigned_group,created_at" and limit=20.
5-
- Use csv_get_ticket only for the top 3-5 most relevant IDs when deeper context is required.
4+
- First call csv_search_tickets with fields="incident_id,summary,status,priority,assignee,assigned_group,created_at" and limit=20.
5+
- Use csv_get_ticket only for the top 3-5 most relevant INC numbers when deeper context is required.
66
- Stop after the first sufficient result set; avoid extra tool loops.
77
- Do not request notes or resolution by default; only request them when explicitly needed for evidence.`;
88

99
const OPS_DEFAULT_PROMPT = `Analysiere Tickets zu "Outlook" oder "E-Mail" und erstelle einen einzigen Operations-Usecase.
1010
Für schnelle Ausführung:
11-
- Nutze zuerst csv_search_tickets mit fields="id,summary,status,priority,assigned_group,created_at" und limit=20.
12-
- Nutze csv_get_ticket nur für wenige ausgewählte Ticket-IDs, wenn Details nötig sind.
11+
- Nutze zuerst csv_search_tickets mit fields="incident_id,summary,status,priority,assigned_group,created_at" und limit=20.
12+
- Nutze csv_get_ticket nur für wenige ausgewählte INC-Nummern, wenn Details nötig sind.
1313
- Beende nach dem ersten ausreichenden Datensatz und vermeide zusätzliche Tool-Schleifen.
1414
- Fordere notes/resolution nicht standardmäßig an, nur wenn sie für die Aussage zwingend notwendig sind.
1515
Liefere nur eine kurze, handlungsorientierte Zusammenfassung mit Prioritäten und nächstem Schritt.
16-
Nutze ausschließlich CSV-Daten und nenne die verwendeten Ticket-IDs in Fließtext.`;
16+
Nutze ausschließlich CSV-Daten und nenne die verwendeten INC-Nummern in Fließtext.`;
1717

1818
const SLA_BREACH_DEFAULT_PROMPT = `Call csv_sla_breach_tickets with default parameters (unassigned_only=true, include_ok=false).
1919
@@ -58,6 +58,7 @@ export const USECASE_DEMO_DEFINITIONS = [
5858
description:
5959
"Ticket IDs from the agent result are resolved against CSV data. Click a ticket to inspect details.",
6060
fields: [
61+
"incident_id",
6162
"id",
6263
"summary",
6364
"status",
Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,69 @@
11
const UUID_PATTERN =
2-
/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi
2+
/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
3+
4+
const INC_PATTERN = /INC\d{12,15}/gi;
35

46
export function formatDateTime(value) {
5-
if (!value) return '—'
6-
const parsed = new Date(value)
7-
if (Number.isNaN(parsed.getTime())) return value
8-
return parsed.toLocaleString()
7+
if (!value) return "—";
8+
const parsed = new Date(value);
9+
if (Number.isNaN(parsed.getTime())) return value;
10+
return parsed.toLocaleString();
911
}
1012

1113
export function upsertRun(runs, updatedRun, maxSize = 25) {
12-
const index = runs.findIndex((item) => item.id === updatedRun.id)
14+
const index = runs.findIndex((item) => item.id === updatedRun.id);
1315
if (index === -1) {
14-
return [updatedRun, ...runs].slice(0, maxSize)
16+
return [updatedRun, ...runs].slice(0, maxSize);
1517
}
1618

17-
const next = [...runs]
18-
next[index] = updatedRun
19-
next.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
20-
return next
19+
const next = [...runs];
20+
next[index] = updatedRun;
21+
next.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
22+
return next;
2123
}
2224

2325
export function parseTicketIds(rawValue) {
24-
if (rawValue == null) return []
26+
if (rawValue == null) return [];
2527

2628
const asText = Array.isArray(rawValue)
27-
? rawValue.join(',')
28-
: String(rawValue)
29+
? rawValue.join(",")
30+
: String(rawValue);
31+
32+
// Try INC numbers first (primary identifier)
33+
const incMatches = asText.match(INC_PATTERN);
34+
if (incMatches?.length) {
35+
return incMatches.map((value) => value.toUpperCase());
36+
}
2937

30-
const uuidMatches = asText.match(UUID_PATTERN)
38+
const uuidMatches = asText.match(UUID_PATTERN);
3139
if (uuidMatches?.length) {
32-
return uuidMatches.map((value) => value.toLowerCase())
40+
return uuidMatches.map((value) => value.toLowerCase());
3341
}
3442

3543
return asText
3644
.split(/[\n,;\s]+/)
3745
.map((value) => value.trim())
38-
.filter(Boolean)
46+
.filter(Boolean);
3947
}
4048

41-
export function extractTicketIdsFromRows(rows, ticketIdFields = ['ticket_ids', 'ticket_id', 'ticketIds']) {
42-
const ids = new Set()
49+
export function extractTicketIdsFromRows(
50+
rows,
51+
ticketIdFields = ["ticket_ids", "ticket_id", "ticketIds"],
52+
) {
53+
const ids = new Set();
4354
for (const row of rows || []) {
44-
if (!row || typeof row !== 'object') continue
55+
if (!row || typeof row !== "object") continue;
4556
for (const field of ticketIdFields) {
46-
const fieldValue = row[field]
57+
const fieldValue = row[field];
4758
for (const parsedId of parseTicketIds(fieldValue)) {
48-
ids.add(parsedId)
59+
ids.add(parsedId);
4960
}
5061
}
5162
}
52-
return Array.from(ids)
63+
return Array.from(ids);
5364
}
5465

5566
export function sanitizeMarkdownForDisplay(markdown) {
56-
if (!markdown) return ''
57-
return markdown.replace(/```json[\s\S]*?```/gi, '').trim()
67+
if (!markdown) return "";
68+
return markdown.replace(/```json[\s\S]*?```/gi, "").trim();
5869
}

frontend/src/services/api.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ export async function getCSVTicketStats() {
210210
}
211211

212212
/**
213-
* Get one CSV ticket by ID.
214-
* @param {string} ticketId - Ticket UUID
213+
* Get one CSV ticket by INC number (e.g. INC000016349327) or UUID.
214+
* @param {string} ticketId - INC number or UUID
215215
* @param {string[]} fields - Optional field selection
216216
* @returns {Promise<Object>} Ticket details
217217
*/
@@ -310,7 +310,7 @@ export async function deleteWorkbenchAgent(agentId) {
310310

311311
export async function runWorkbenchAgent(
312312
agentId,
313-
{ inputPrompt = "", requiredInputValue = "" } = {}
313+
{ inputPrompt = "", requiredInputValue = "" } = {},
314314
) {
315315
return fetchJSON(`${API_BASE_URL}/workbench/agents/${agentId}/runs`, {
316316
method: "POST",

0 commit comments

Comments
 (0)