-
Notifications
You must be signed in to change notification settings - Fork 127
fix(sensor): attribute MCP servers and failed tool calls in Claude Code sessions #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -67,6 +67,24 @@ def parse_all(self) -> List[AgentEvent]: | |||||
|
|
||||||
| return entries | ||||||
|
|
||||||
| def _classify_tool(self, tool_name: str) -> tuple: | ||||||
| """Classify a Claude Code tool call and attribute MCP tools to their server. | ||||||
|
|
||||||
| Claude Code namespaces MCP tools as ``mcp__<server>__<tool>``, so the server is | ||||||
| recoverable from the name alone. Without this, every call — including third-party | ||||||
| MCP servers — collapses into a single ``tool_use`` bucket with no server attribution. | ||||||
| """ | ||||||
| if tool_name.startswith("mcp__"): | ||||||
| parts = tool_name.split("__") | ||||||
| if len(parts) >= 3 and parts[1]: | ||||||
| return "mcp_tool", parts[1] | ||||||
| return "mcp_tool", None | ||||||
|
|
||||||
| if tool_name in ("Bash", "PowerShell", "BashOutput", "KillShell"): | ||||||
| return "terminal_command", None | ||||||
|
|
||||||
| return "tool_use", None | ||||||
|
|
||||||
| def _normalize_result_content(self, result_content: Any) -> str: | ||||||
| """Normalize result content which can be a string or list of content items.""" | ||||||
| if isinstance(result_content, str): | ||||||
|
|
@@ -186,7 +204,13 @@ def _extract_message_data(self, obj: Dict[str, Any]) -> Optional[Dict[str, Any]] | |||||
|
|
||||||
| if result_content and isinstance(result_content, str): | ||||||
| result_content = truncate_middle(result_content, max_length=1000, edge_chars=400) | ||||||
| tool_results.append({"tool_use_id": tool_use_id, "result": result_content}) | ||||||
| tool_results.append( | ||||||
| { | ||||||
| "tool_use_id": tool_use_id, | ||||||
| "result": result_content, | ||||||
| "is_error": bool(item.get("is_error")), | ||||||
| } | ||||||
| ) | ||||||
|
|
||||||
| if tool_results: | ||||||
| extracted["tool_results"] = tool_results | ||||||
|
|
@@ -245,14 +269,23 @@ def _create_entry_from_extracted_session( | |||||
| for tool_result in tool_results: | ||||||
| tool_use_id = tool_result.get("tool_use_id") | ||||||
| result = tool_result.get("result") | ||||||
| is_error = tool_result.get("is_error", False) | ||||||
| if tool_use_id in pending_tools: | ||||||
| old_tool = pending_tools[tool_use_id] | ||||||
| if is_error: | ||||||
| status = "error" | ||||||
| elif result: | ||||||
| status = "success" | ||||||
| else: | ||||||
| status = "unknown" | ||||||
| updated_tool = ToolUsage( | ||||||
| tool_name=old_tool.tool_name, | ||||||
| tool_type=old_tool.tool_type, | ||||||
| server_name=old_tool.server_name, | ||||||
| arguments=old_tool.arguments, | ||||||
| result=result, | ||||||
| status="success" if result else "unknown", | ||||||
| status=status, | ||||||
| error=result if is_error else None, | ||||||
| ) | ||||||
| for msg in entry.chat_history: | ||||||
| if msg.role == "assistant": | ||||||
|
Comment on lines
290
to
291
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The result merge two lines below matches by value, not identity — and this PR makes the misattribution worse.
Concrete failure: an assistant message issues two parallel The sibling parser already fixes exactly this, at # Match on identity: two identical calls in different messages compare
# equal, and only the one this result belongs to should change.
if tool is not old_tool:
continueSeparately, the inner |
||||||
|
|
@@ -274,9 +307,12 @@ def _create_entry_from_extracted_session( | |||||
| tools = [] | ||||||
|
|
||||||
| for tool_data in msg_data.get("tools", []): | ||||||
| tool_name = tool_data.get("name", "unknown") | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A
Reproduced locally: the parser prints
Suggested change
An |
||||||
| tool_type, server_name = self._classify_tool(tool_name) | ||||||
| tool = ToolUsage( | ||||||
| tool_name=tool_data.get("name", "unknown"), | ||||||
| tool_type="tool_use", | ||||||
| tool_name=tool_name, | ||||||
| tool_type=tool_type, | ||||||
| server_name=server_name, | ||||||
| arguments=tool_data.get("input", {}), | ||||||
| result=None, | ||||||
| ) | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
status="unknown"is provably wrong for empty non-error results.A
tool_resultblock is the completion signal, andis_erroris authoritative — anything with falsyis_errorsucceeded, whether or not its content normalizes to a non-empty string.Measured against ~1,300 real tool calls in
~/.claude/projects: 12 come out asunknown, all of them successfulToolSearchcalls whose result content normalizes to"", each carrying an explicitis_error: falsein the log. Collapsing the branch also matches the stated point of the PR: