VibecoderMcSwaggins commited on
Commit
4337145
·
unverified ·
2 Parent(s): ecaa2e8 2b44c5a

Merge pull request #116 from The-Obstacle-Is-The-Way/fix/p0-aifunction-serialization

Browse files
docs/bugs/P0_HUGGINGFACE_TOOL_CALLING_BROKEN.md ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # P0 Bug: HuggingFace Free Tier Tool Calling Broken
2
+
3
+ **Severity**: P0 (Critical) - Free Tier cannot perform multi-turn tool-based research
4
+ **Status**: PARTIALLY RESOLVED - Bug #1 FIXED, Bug #2 requires upstream fix
5
+ **Discovered**: 2025-12-01
6
+ **Investigator**: Claude Code (Systematic First-Principles Analysis)
7
+ **Last Updated**: 2025-12-01
8
+
9
+ ## Executive Summary
10
+
11
+ The HuggingFace Free Tier had two critical bugs preventing end-to-end tool-based research:
12
+
13
+ 1. **Bug #1 (FIXED)**: Conversation history serialization missing `tool_calls` and `tool_call_id`
14
+ 2. **Bug #2 (UPSTREAM)**: Microsoft Agent Framework produces repr strings instead of message text
15
+
16
+ ## Current Status
17
+
18
+ | Bug | Status | Location | Fix |
19
+ |-----|--------|----------|-----|
20
+ | #1 History Serialization | ✅ **FIXED** | `src/clients/huggingface.py` | Commit `809ad60` |
21
+ | #2 Framework Repr Bug | ⏳ **UPSTREAM** | `agent_framework/_workflows/_magentic.py` | [Issue #2562](https://github.com/microsoft/agent-framework/issues/2562) |
22
+
23
+ ---
24
+
25
+ ## BUG #1: Conversation History Serialization ✅ FIXED
26
+
27
+ ### What Was Wrong
28
+ `_convert_messages()` didn't serialize `tool_calls` (for assistant messages) or `tool_call_id` (for tool messages).
29
+
30
+ ### The Fix (Commit `809ad60`)
31
+ Updated `_convert_messages()` in `src/clients/huggingface.py:71-121` to:
32
+ 1. Extract `FunctionCallContent` from `msg.contents` → `tool_calls` array
33
+ 2. Extract `FunctionResultContent` from `msg.contents` → `tool_call_id`
34
+ 3. Properly format for HuggingFace/OpenAI API
35
+
36
+ ### Verification
37
+ ```python
38
+ # Before fix: BadRequestError on multi-turn
39
+ # After fix: Multi-turn conversations work
40
+
41
+ # The message format is now correct:
42
+ {
43
+ "role": "assistant",
44
+ "content": "",
45
+ "tool_calls": [{"id": "call_123", "type": "function", "function": {...}}]
46
+ }
47
+ ```
48
+
49
+ ---
50
+
51
+ ## BUG #2: Framework Message Corruption ⏳ UPSTREAM
52
+
53
+ ### Symptom
54
+ `MagenticAgentMessageEvent.message.text` contains:
55
+ ```text
56
+ '<agent_framework._types.ChatMessage object at 0x10c394210>'
57
+ ```
58
+
59
+ ### Root Cause (CONFIRMED)
60
+ **File**: `agent_framework/_workflows/_magentic.py` line ~1799
61
+
62
+ ```python
63
+ async def _invoke_agent(self, ctx, ...) -> ChatMessage:
64
+ # ...
65
+ if messages and len(messages) > 0:
66
+ last: ChatMessage = messages[-1]
67
+ text = last.text or str(last) # <-- BUG: str(last) gives repr!
68
+ msg = ChatMessage(role=role, text=text, author_name=author)
69
+ ```
70
+
71
+ **Why it happens**:
72
+ 1. `ChatMessage.text` property only extracts `TextContent` items
73
+ 2. Tool-call-only messages have empty `.text` (returns `""`)
74
+ 3. `"" or str(last)` evaluates to `str(last)`
75
+ 4. `ChatMessage` has no `__str__` method → default Python repr
76
+
77
+ ### Impact Assessment
78
+
79
+ | Aspect | Impact | Critical? |
80
+ |--------|--------|-----------|
81
+ | UI Display | Shows garbage instead of agent output | YES for UX |
82
+ | Logging | Can't debug what agents did | YES for debugging |
83
+ | Tool Execution | Tools ARE being called (middleware works) | NO - Works |
84
+ | Research Completion | Manager may not track progress properly | MAYBE - Unclear |
85
+
86
+ **Observed behavior**: Research loops often reach max rounds without synthesis. The Manager keeps saying "no progress" even though tools ARE being called. This COULD be:
87
+ 1. The repr bug affecting Manager's understanding
88
+ 2. Qwen 72B not handling tool message format well
89
+ 3. Unrelated orchestration issue
90
+
91
+ ### Upstream Issue Filed
92
+ **GitHub Issue**: [microsoft/agent-framework#2562](https://github.com/microsoft/agent-framework/issues/2562)
93
+
94
+ **Suggested fixes in issue**:
95
+ 1. **Minimal**: `text = last.text or ""`
96
+ 2. **Better UX**: Format tool calls for display
97
+ 3. **Best**: Add `__str__` to `ChatMessage` class
98
+
99
+ ### Workaround (Implemented in `advanced.py`)
100
+ We modified `_extract_text()` in `advanced.py` to extract tool call names from `.contents` when text is empty or looks like a repr:
101
+
102
+ ```python
103
+ def _extract_text(self, message: Any) -> str:
104
+ # ... existing logic with repr filtering ...
105
+
106
+ # Workaround: Extract tool call info when text is repr/empty
107
+ if hasattr(message, "contents") and message.contents:
108
+ tool_names = [
109
+ f"[Tool: {c.name}]"
110
+ for c in message.contents
111
+ if hasattr(c, "name") # FunctionCallContent
112
+ ]
113
+ if tool_names:
114
+ return " ".join(tool_names)
115
+
116
+ return ""
117
+ ```
118
+
119
+ **Decision**: Implemented locally to fix display and logging while we wait for upstream fix.
120
+
121
+ ---
122
+
123
+ ## Verification Matrix (Updated)
124
+
125
+ | Component | Status | Notes |
126
+ |-----------|--------|-------|
127
+ | Tool Serialization | ✅ WORKS | `_convert_tools()` |
128
+ | Tool Call Parsing | ✅ WORKS | `_parse_tool_calls()` |
129
+ | History Serialization | ✅ **FIXED** | `_convert_messages()` |
130
+ | Middleware Decorators | ✅ **FIXED** | `@use_function_invocation` etc. |
131
+ | Event Display | ❌ UPSTREAM | Shows repr - framework bug |
132
+ | End-to-End Research | ⚠️ UNCLEAR | Needs testing after upstream fix |
133
+
134
+ ---
135
+
136
+ ## Files Changed
137
+
138
+ ### Fixed (Commit `809ad60`)
139
+ - `src/clients/huggingface.py`
140
+ - `_convert_messages()` - Now serializes `tool_calls` and `tool_call_id`
141
+ - Added `@use_function_invocation`, `@use_observability`, `@use_chat_middleware` decorators
142
+ - Added `__function_invoking_chat_client__ = True` marker
143
+
144
+ ### Also Fixed
145
+ - `src/orchestrators/advanced.py` - `_extract_text()` now filters repr strings AND extracts tool call names
146
+
147
+ ---
148
+
149
+ ## Related Upstream Issues
150
+
151
+ | Issue | Title | Status | Relevance |
152
+ |-------|-------|--------|-----------|
153
+ | [#2562](https://github.com/microsoft/agent-framework/issues/2562) | Repr string bug (OUR ISSUE) | OPEN | Direct cause |
154
+ | [#1366](https://github.com/microsoft/agent-framework/issues/1366) | Thread corruption - unexecuted tool calls | OPEN | Same area |
155
+ | [#2410](https://github.com/microsoft/agent-framework/issues/2410) | OpenAI client splits content/tool_calls | OPEN | Related bug |
156
+
157
+ ---
158
+
159
+ ## Next Steps
160
+
161
+ 1. **Monitor**: Watch for response to [Issue #2562](https://github.com/microsoft/agent-framework/issues/2562)
162
+ 2. **Test**: Run end-to-end research tests to see if Bug #2 actually blocks completion
163
+ 3. **Optional**: Implement workaround in `_extract_text()` if display is critical
164
+ 4. **Contribute**: Consider submitting PR to fix `_magentic.py` line 1799
165
+
166
+ ---
167
+
168
+ ## References
169
+
170
+ - [HuggingFace Chat Completion API - Tool Use](https://huggingface.co/docs/huggingface_hub/package_reference/inference_client#huggingface_hub.InferenceClient.chat_completion)
171
+ - [OpenAI Function Calling](https://platform.openai.com/docs/guides/function-calling)
172
+ - [Microsoft Agent Framework Repository](https://github.com/microsoft/agent-framework)
173
+ - [Our Upstream Issue #2562](https://github.com/microsoft/agent-framework/issues/2562)
src/clients/huggingface.py CHANGED
@@ -6,6 +6,7 @@ an OpenAI API key.
6
  """
7
 
8
  import asyncio
 
9
  from collections.abc import AsyncIterable, MutableSequence
10
  from functools import partial
11
  from typing import Any, cast
@@ -17,8 +18,13 @@ from agent_framework import (
17
  ChatOptions,
18
  ChatResponse,
19
  ChatResponseUpdate,
 
 
20
  )
21
- from agent_framework._types import FunctionCallContent
 
 
 
22
  from huggingface_hub import InferenceClient
23
 
24
  from src.utils.config import settings
@@ -26,9 +32,16 @@ from src.utils.config import settings
26
  logger = structlog.get_logger()
27
 
28
 
 
 
 
29
  class HuggingFaceChatClient(BaseChatClient): # type: ignore[misc]
30
  """Adapter for HuggingFace Inference API with full function calling support."""
31
 
 
 
 
 
32
  def __init__(
33
  self,
34
  model_id: str | None = None,
@@ -58,16 +71,72 @@ class HuggingFaceChatClient(BaseChatClient): # type: ignore[misc]
58
  def _convert_messages(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]:
59
  """Convert framework messages to HuggingFace format."""
60
  hf_messages: list[dict[str, Any]] = []
 
 
 
 
 
61
  for msg in messages:
62
- # Basic conversion - extend as needed for multi-modal
63
- content = msg.text or ""
64
  # msg.role can be string or enum - extract .value for enums
65
- # str(Role.USER) -> "Role.USER" (wrong), Role.USER.value -> "user" (correct)
66
  if hasattr(msg.role, "value"):
67
  role_str = str(msg.role.value)
68
  else:
69
  role_str = str(msg.role)
70
- hf_messages.append({"role": role_str, "content": content})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  return hf_messages
72
 
73
  def _convert_tools(self, tools: list[Any] | None) -> list[dict[str, Any]] | None:
@@ -108,12 +177,7 @@ class HuggingFaceChatClient(BaseChatClient): # type: ignore[misc]
108
  return json_tools if json_tools else None
109
 
110
  def _parse_tool_calls(self, message: Any) -> list[FunctionCallContent]:
111
- """Parse HuggingFace tool_calls into framework FunctionCallContent.
112
-
113
- HF returns tool_calls as:
114
- [ChatCompletionOutputToolCall(id='...', function=ChatCompletionOutputFunctionDefinition(
115
- name='...', arguments='{"key": "value"}'), type='function')]
116
- """
117
  contents: list[FunctionCallContent] = []
118
 
119
  if not hasattr(message, "tool_calls") or not message.tool_calls:
@@ -299,6 +363,8 @@ class HuggingFaceChatClient(BaseChatClient): # type: ignore[misc]
299
  if contents:
300
  yield ChatResponseUpdate(
301
  contents=contents,
 
 
302
  )
303
 
304
  except Exception as e:
 
6
  """
7
 
8
  import asyncio
9
+ import json
10
  from collections.abc import AsyncIterable, MutableSequence
11
  from functools import partial
12
  from typing import Any, cast
 
18
  ChatOptions,
19
  ChatResponse,
20
  ChatResponseUpdate,
21
+ FinishReason,
22
+ Role,
23
  )
24
+ from agent_framework._middleware import use_chat_middleware
25
+ from agent_framework._tools import use_function_invocation
26
+ from agent_framework._types import FunctionCallContent, FunctionResultContent
27
+ from agent_framework.observability import use_observability
28
  from huggingface_hub import InferenceClient
29
 
30
  from src.utils.config import settings
 
32
  logger = structlog.get_logger()
33
 
34
 
35
+ @use_function_invocation
36
+ @use_observability
37
+ @use_chat_middleware
38
  class HuggingFaceChatClient(BaseChatClient): # type: ignore[misc]
39
  """Adapter for HuggingFace Inference API with full function calling support."""
40
 
41
+ # Marker to tell agent_framework that this client supports function calling
42
+ # Without this, the framework warns and ignores tools
43
+ __function_invoking_chat_client__ = True
44
+
45
  def __init__(
46
  self,
47
  model_id: str | None = None,
 
71
  def _convert_messages(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]:
72
  """Convert framework messages to HuggingFace format."""
73
  hf_messages: list[dict[str, Any]] = []
74
+
75
+ # Track call_id -> tool_name mapping for tool result messages
76
+ # Assistant messages with tool_calls come before tool result messages
77
+ call_id_to_name: dict[str, str] = {}
78
+
79
  for msg in messages:
 
 
80
  # msg.role can be string or enum - extract .value for enums
 
81
  if hasattr(msg.role, "value"):
82
  role_str = str(msg.role.value)
83
  else:
84
  role_str = str(msg.role)
85
+
86
+ content_str = msg.text or ""
87
+ tool_calls = []
88
+ tool_call_id = None
89
+ tool_name = None
90
+
91
+ # Process contents for tool calls and results
92
+ if msg.contents:
93
+ for item in msg.contents:
94
+ if isinstance(item, FunctionCallContent):
95
+ # This is an assistant message invoking a tool
96
+ # Track call_id -> name for later tool result messages
97
+ call_id_to_name[item.call_id] = item.name
98
+ tool_calls.append(
99
+ {
100
+ "id": item.call_id,
101
+ "type": "function",
102
+ "function": {
103
+ "name": item.name,
104
+ "arguments": (
105
+ item.arguments
106
+ if isinstance(item.arguments, str)
107
+ else json.dumps(item.arguments)
108
+ ),
109
+ },
110
+ }
111
+ )
112
+ elif isinstance(item, FunctionResultContent):
113
+ # This is a tool result message
114
+ role_str = "tool"
115
+ tool_call_id = item.call_id
116
+ # Look up tool name from prior FunctionCallContent
117
+ tool_name = call_id_to_name.get(item.call_id)
118
+ # For tool results, JSON-encode structured data
119
+ # HuggingFace/OpenAI expects string content
120
+ if item.result is None:
121
+ content_str = ""
122
+ elif isinstance(item.result, str):
123
+ content_str = item.result
124
+ else:
125
+ content_str = json.dumps(item.result)
126
+
127
+ message_dict: dict[str, Any] = {"role": role_str, "content": content_str}
128
+
129
+ if tool_calls:
130
+ message_dict["tool_calls"] = tool_calls
131
+
132
+ if tool_call_id:
133
+ message_dict["tool_call_id"] = tool_call_id
134
+ # Add name field if we tracked it (required by some APIs)
135
+ if tool_name:
136
+ message_dict["name"] = tool_name
137
+
138
+ hf_messages.append(message_dict)
139
+
140
  return hf_messages
141
 
142
  def _convert_tools(self, tools: list[Any] | None) -> list[dict[str, Any]] | None:
 
177
  return json_tools if json_tools else None
178
 
179
  def _parse_tool_calls(self, message: Any) -> list[FunctionCallContent]:
180
+ """Parse HuggingFace tool_calls into framework FunctionCallContent."""
 
 
 
 
 
181
  contents: list[FunctionCallContent] = []
182
 
183
  if not hasattr(message, "tool_calls") or not message.tool_calls:
 
363
  if contents:
364
  yield ChatResponseUpdate(
365
  contents=contents,
366
+ role=Role.ASSISTANT,
367
+ finish_reason=FinishReason.TOOL_CALLS,
368
  )
369
 
370
  except Exception as e:
src/orchestrators/advanced.py CHANGED
@@ -337,32 +337,52 @@ The final output should be a structured research report."""
337
  """
338
  Defensively extract text from a message object.
339
 
340
- Fixes bug where message.text might return the object itself or its repr.
 
 
341
  """
342
  if not message:
343
  return ""
344
 
345
- # Priority 1: .content (often the raw string or list of content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  if hasattr(message, "content") and message.content:
347
  content = message.content
348
- # If it's a list (e.g., Multi-modal), join text parts
 
349
  if isinstance(content, list):
350
  return " ".join([str(c.text) for c in content if hasattr(c, "text")])
351
- return str(content)
352
-
353
- # Priority 2: .text (standard, but sometimes buggy/missing)
354
- if hasattr(message, "text") and message.text:
355
- # Verify it's not the object itself or a repr string
356
- text = str(message.text)
357
- if text.startswith("<") and "object at" in text:
358
- # Likely a repr string, ignore if possible
359
- pass
360
- else:
361
- return text
362
 
363
- # Fallback: If we can't find clean text, return str(message)
364
- # taking care to avoid infinite recursion if str() calls .text
365
- return str(message)
366
 
367
  def _get_event_type_for_agent(self, agent_name: str) -> str:
368
  """Map agent name to appropriate event type.
@@ -456,9 +476,11 @@ The final output should be a structured research report."""
456
 
457
  elif isinstance(event, WorkflowOutputEvent):
458
  if event.data:
 
 
459
  return AgentEvent(
460
  type="complete",
461
- message=str(event.data),
462
  iteration=iteration,
463
  )
464
 
 
337
  """
338
  Defensively extract text from a message object.
339
 
340
+ Handles ChatMessage objects from both OpenAI and HuggingFace clients.
341
+ ChatMessage has: .text (str), .contents (list of content objects)
342
+ Also handles plain string messages (e.g., WorkflowOutputEvent.data).
343
  """
344
  if not message:
345
  return ""
346
 
347
+ # Priority 0: Handle plain string messages (e.g., WorkflowOutputEvent.data)
348
+ if isinstance(message, str):
349
+ # Filter out obvious repr-style noise
350
+ if not (message.startswith("<") and "object at" in message):
351
+ return message
352
+ return ""
353
+
354
+ # Priority 1: .text (standard ChatMessage text content)
355
+ if hasattr(message, "text") and message.text:
356
+ text = message.text
357
+ # Verify it's actually a string, not the object itself
358
+ if isinstance(text, str) and not (text.startswith("<") and "object at" in text):
359
+ return text
360
+
361
+ # Priority 2: .contents (list of FunctionCallContent, TextContent, etc.)
362
+ # This handles tool call responses from HuggingFace
363
+ if hasattr(message, "contents") and message.contents:
364
+ parts = []
365
+ for content in message.contents:
366
+ # TextContent has .text
367
+ if hasattr(content, "text") and content.text:
368
+ parts.append(str(content.text))
369
+ # FunctionCallContent has .name and .arguments
370
+ elif hasattr(content, "name"):
371
+ parts.append(f"[Tool: {content.name}]")
372
+ if parts:
373
+ return " ".join(parts)
374
+
375
+ # Priority 3: .content (legacy - some frameworks use singular)
376
  if hasattr(message, "content") and message.content:
377
  content = message.content
378
+ if isinstance(content, str):
379
+ return content
380
  if isinstance(content, list):
381
  return " ".join([str(c.text) for c in content if hasattr(c, "text")])
 
 
 
 
 
 
 
 
 
 
 
382
 
383
+ # Fallback: Return empty string instead of repr
384
+ # The repr is useless for display purposes
385
+ return ""
386
 
387
  def _get_event_type_for_agent(self, agent_name: str) -> str:
388
  """Map agent name to appropriate event type.
 
476
 
477
  elif isinstance(event, WorkflowOutputEvent):
478
  if event.data:
479
+ # Use _extract_text to properly handle ChatMessage objects
480
+ text = self._extract_text(event.data)
481
  return AgentEvent(
482
  type="complete",
483
+ message=text if text else "Research complete (no synthesis)",
484
  iteration=iteration,
485
  )
486
 
tests/unit/clients/test_chat_client_factory.py CHANGED
@@ -154,10 +154,10 @@ class TestHuggingFaceChatClient:
154
 
155
  client = HuggingFaceChatClient()
156
 
157
- # Create mock messages
158
  messages = [
159
- MagicMock(spec=ChatMessage, role="user", text="Hello"),
160
- MagicMock(spec=ChatMessage, role="assistant", text="Hi there!"),
161
  ]
162
 
163
  result = client._convert_messages(messages)
@@ -189,6 +189,7 @@ class TestHuggingFaceChatClient:
189
  mock_msg = MagicMock(spec=ChatMessage)
190
  mock_msg.role = Role.USER # Enum, not string
191
  mock_msg.text = "Hello"
 
192
 
193
  result = client._convert_messages([mock_msg])
194
 
 
154
 
155
  client = HuggingFaceChatClient()
156
 
157
+ # Create mock messages (include contents=None for tool call processing)
158
  messages = [
159
+ MagicMock(spec=ChatMessage, role="user", text="Hello", contents=None),
160
+ MagicMock(spec=ChatMessage, role="assistant", text="Hi there!", contents=None),
161
  ]
162
 
163
  result = client._convert_messages(messages)
 
189
  mock_msg = MagicMock(spec=ChatMessage)
190
  mock_msg.role = Role.USER # Enum, not string
191
  mock_msg.text = "Hello"
192
+ mock_msg.contents = None # Required for tool call processing
193
 
194
  result = client._convert_messages([mock_msg])
195