bumblehive 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. bumblehive/__init__.py +47 -0
  2. bumblehive/agent/__init__.py +46 -0
  3. bumblehive/agent/context/__init__.py +33 -0
  4. bumblehive/agent/context/builder.py +363 -0
  5. bumblehive/agent/context/governor.py +109 -0
  6. bumblehive/agent/context/history.py +416 -0
  7. bumblehive/agent/context/prompts/__init__.py +1 -0
  8. bumblehive/agent/context/prompts/agent_instructions.md +23 -0
  9. bumblehive/agent/context/prompts/tool_use_instructions.md +42 -0
  10. bumblehive/agent/context/window.py +235 -0
  11. bumblehive/agent/loop.py +159 -0
  12. bumblehive/agent/runner.py +399 -0
  13. bumblehive/config/__init__.py +3 -0
  14. bumblehive/config/loader.py +37 -0
  15. bumblehive/config/schema.py +383 -0
  16. bumblehive/console.py +585 -0
  17. bumblehive/observability/__init__.py +96 -0
  18. bumblehive/observability/emitter.py +71 -0
  19. bumblehive/observability/emitters.py +243 -0
  20. bumblehive/observability/events.py +69 -0
  21. bumblehive/observability/hooks.py +95 -0
  22. bumblehive/observability/payloads.py +44 -0
  23. bumblehive/observability/recording.py +16 -0
  24. bumblehive/observability/streaming.py +105 -0
  25. bumblehive/paths.py +28 -0
  26. bumblehive/protocols/__init__.py +15 -0
  27. bumblehive/protocols/errors.py +10 -0
  28. bumblehive/protocols/generation.py +22 -0
  29. bumblehive/protocols/mcp.py +22 -0
  30. bumblehive/protocols/tool_calls.py +125 -0
  31. bumblehive/providers/__init__.py +23 -0
  32. bumblehive/providers/base.py +222 -0
  33. bumblehive/providers/manager.py +56 -0
  34. bumblehive/providers/openai_chat_completions.py +904 -0
  35. bumblehive/providers/streaming.py +48 -0
  36. bumblehive/runtime.py +395 -0
  37. bumblehive/session/__init__.py +9 -0
  38. bumblehive/session/manager.py +155 -0
  39. bumblehive/session/models.py +13 -0
  40. bumblehive/session/stores/__init__.py +3 -0
  41. bumblehive/session/stores/json_file.py +101 -0
  42. bumblehive/skills/__init__.py +13 -0
  43. bumblehive/skills/loader.py +74 -0
  44. bumblehive/skills/manager.py +82 -0
  45. bumblehive/skills/models.py +30 -0
  46. bumblehive/skills/render.py +47 -0
  47. bumblehive/tools/__init__.py +24 -0
  48. bumblehive/tools/adapters/__init__.py +6 -0
  49. bumblehive/tools/adapters/function.py +21 -0
  50. bumblehive/tools/adapters/mcp.py +89 -0
  51. bumblehive/tools/base.py +133 -0
  52. bumblehive/tools/builtins/__init__.py +59 -0
  53. bumblehive/tools/builtins/file.py +1074 -0
  54. bumblehive/tools/builtins/patch.py +359 -0
  55. bumblehive/tools/builtins/search.py +526 -0
  56. bumblehive/tools/builtins/shell.py +1086 -0
  57. bumblehive/tools/builtins/state.py +34 -0
  58. bumblehive/tools/builtins/workspace.py +267 -0
  59. bumblehive/tools/executor.py +122 -0
  60. bumblehive/tools/manager.py +289 -0
  61. bumblehive/tools/mcp/__init__.py +13 -0
  62. bumblehive/tools/mcp/manager.py +226 -0
  63. bumblehive/tools/registry.py +159 -0
  64. bumblehive/tools/scope.py +104 -0
  65. bumblehive-0.1.0.dist-info/METADATA +112 -0
  66. bumblehive-0.1.0.dist-info/RECORD +68 -0
  67. bumblehive-0.1.0.dist-info/WHEEL +4 -0
  68. bumblehive-0.1.0.dist-info/licenses/LICENSE +201 -0
bumblehive/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """Bumblehive agent framework."""
2
+
3
+ from .agent import AgentLoop, MessageHistory, ToolCallingRunner
4
+ from .config.schema import (
5
+ AgentConfig,
6
+ BumblehiveConfig,
7
+ ProviderConfig,
8
+ RuntimeArguments,
9
+ RuntimeConfig,
10
+ )
11
+ from .observability import (
12
+ AgentEvent,
13
+ AgentHook,
14
+ AsyncEventStream,
15
+ AsyncEventStreamHook,
16
+ EventEmitter,
17
+ EventRecorder,
18
+ )
19
+ from .paths import get_sessions_path, get_workspace_path
20
+ from .providers import ProviderManager
21
+ from .runtime import BumblehiveRuntime, from_config
22
+ from .skills import SkillsManager
23
+ from .tools.manager import ToolManager
24
+
25
+ __all__ = [
26
+ "AgentConfig",
27
+ "AgentEvent",
28
+ "AgentHook",
29
+ "AsyncEventStream",
30
+ "AsyncEventStreamHook",
31
+ "AgentLoop",
32
+ "BumblehiveConfig",
33
+ "BumblehiveRuntime",
34
+ "EventRecorder",
35
+ "EventEmitter",
36
+ "MessageHistory",
37
+ "ProviderConfig",
38
+ "RuntimeArguments",
39
+ "RuntimeConfig",
40
+ "ToolCallingRunner",
41
+ "from_config",
42
+ "get_sessions_path",
43
+ "get_workspace_path",
44
+ "ProviderManager",
45
+ "SkillsManager",
46
+ "ToolManager",
47
+ ]
@@ -0,0 +1,46 @@
1
+ """Agent context assembly primitives."""
2
+
3
+ from .context import (
4
+ ContextBuilder,
5
+ ContextGovernanceConfig,
6
+ ContextGovernor,
7
+ DynamicValue,
8
+ MessageHistory,
9
+ backfill_missing_tool_results,
10
+ drop_empty_messages,
11
+ drop_orphan_tool_results,
12
+ merge_consecutive_text_messages,
13
+ prepare_history,
14
+ repair_message_sequence,
15
+ run_messages_to_history,
16
+ sanitize_messages,
17
+ truncate_tool_results,
18
+ )
19
+ from .loop import AgentLoop
20
+ from .runner import AgentRunResult, CheckpointCallback, ToolCallingRunner
21
+ from ..observability import AgentEvent, AgentHook, EventEmitter, EventRecorder
22
+
23
+ __all__ = [
24
+ "AgentLoop",
25
+ "AgentRunResult",
26
+ "CheckpointCallback",
27
+ "AgentEvent",
28
+ "AgentHook",
29
+ "ToolCallingRunner",
30
+ "ContextGovernanceConfig",
31
+ "ContextGovernor",
32
+ "ContextBuilder",
33
+ "DynamicValue",
34
+ "EventRecorder",
35
+ "EventEmitter",
36
+ "MessageHistory",
37
+ "backfill_missing_tool_results",
38
+ "drop_empty_messages",
39
+ "drop_orphan_tool_results",
40
+ "merge_consecutive_text_messages",
41
+ "prepare_history",
42
+ "repair_message_sequence",
43
+ "run_messages_to_history",
44
+ "sanitize_messages",
45
+ "truncate_tool_results",
46
+ ]
@@ -0,0 +1,33 @@
1
+ """Agent context assembly, history preparation, and window management."""
2
+
3
+ from .builder import ContextBuilder, DynamicValue
4
+ from .governor import ContextGovernanceConfig, ContextGovernor
5
+ from .history import (
6
+ MessageHistory,
7
+ backfill_missing_tool_results,
8
+ drop_empty_messages,
9
+ drop_orphan_tool_results,
10
+ merge_consecutive_text_messages,
11
+ prepare_history,
12
+ repair_message_sequence,
13
+ run_messages_to_history,
14
+ sanitize_messages,
15
+ truncate_tool_results,
16
+ )
17
+
18
+ __all__ = [
19
+ "ContextBuilder",
20
+ "ContextGovernanceConfig",
21
+ "ContextGovernor",
22
+ "DynamicValue",
23
+ "MessageHistory",
24
+ "backfill_missing_tool_results",
25
+ "drop_empty_messages",
26
+ "drop_orphan_tool_results",
27
+ "merge_consecutive_text_messages",
28
+ "prepare_history",
29
+ "repair_message_sequence",
30
+ "run_messages_to_history",
31
+ "sanitize_messages",
32
+ "truncate_tool_results",
33
+ ]
@@ -0,0 +1,363 @@
1
+ import os
2
+ import platform
3
+ import re
4
+ import sys
5
+ from datetime import datetime
6
+ from functools import lru_cache
7
+ from html import escape
8
+ from importlib.resources import files
9
+ from pathlib import Path, PureWindowsPath
10
+ from typing import Any, Mapping, Sequence
11
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
12
+
13
+ from ...paths import get_workspace_path
14
+
15
+
16
+ Message = dict[str, Any]
17
+ DynamicValue = str | int | float | bool | None | dict[str, Any] | list[Any]
18
+
19
+
20
+ @lru_cache(maxsize=None)
21
+ def load_prompt(name: str) -> str:
22
+ return (
23
+ files(f"{__package__}.prompts")
24
+ .joinpath(name)
25
+ .read_text(encoding="utf-8")
26
+ .strip()
27
+ )
28
+
29
+
30
+ _DEFAULT_AGENT_INSTRUCTIONS = load_prompt("agent_instructions.md")
31
+ _TOOL_USE_INSTRUCTIONS = load_prompt("tool_use_instructions.md")
32
+
33
+
34
+ class ContextBuilder:
35
+ """Build model request context from caller-provided capabilities."""
36
+
37
+ def __init__(
38
+ self,
39
+ workspace: Path | str | None = None,
40
+ *,
41
+ timezone: str | None = None,
42
+ ) -> None:
43
+ self.workspace = (
44
+ get_workspace_path(workspace)
45
+ if workspace is not None
46
+ else None
47
+ )
48
+ self.timezone = timezone
49
+
50
+ def build(
51
+ self,
52
+ *,
53
+ current_user_message: str,
54
+ workspace: Path | str | None = None,
55
+ timezone: str | None = None,
56
+ dynamic_context: Mapping[str, DynamicValue] | None = None,
57
+ history: Sequence[Message] | None = None,
58
+ agent_instructions: str | None = None,
59
+ available_skills: str = "",
60
+ ) -> list[Message]:
61
+ """Build the messages for one model request.
62
+
63
+ ``workspace``, ``timezone``, and ``dynamic_context`` carry per-turn
64
+ runtime values. When omitted, the builder defaults provide workspace
65
+ and timezone.
66
+ """
67
+ active_workspace = self._resolve_workspace(workspace)
68
+ active_timezone = timezone if timezone is not None else self.timezone
69
+ system_content = self._build_system_content(
70
+ workspace=active_workspace,
71
+ agent_instructions=agent_instructions or _DEFAULT_AGENT_INSTRUCTIONS,
72
+ available_skills=available_skills,
73
+ )
74
+ runtime_context = self._build_runtime_context(
75
+ dynamic_context,
76
+ timezone=active_timezone,
77
+ )
78
+ current_user_content = "\n\n".join(
79
+ part for part in (current_user_message, runtime_context) if part
80
+ )
81
+
82
+ messages: list[Message] = [
83
+ {"role": "system", "content": system_content},
84
+ *list(history or []),
85
+ {"role": "user", "content": current_user_content},
86
+ ]
87
+
88
+ return messages
89
+
90
+ def _resolve_workspace(
91
+ self,
92
+ workspace: Path | str | None,
93
+ ) -> Path:
94
+ if workspace is not None:
95
+ return get_workspace_path(workspace)
96
+
97
+ return self.workspace or get_workspace_path()
98
+
99
+ def _build_system_content(
100
+ self,
101
+ *,
102
+ workspace: Path,
103
+ agent_instructions: str,
104
+ available_skills: str,
105
+ ) -> str:
106
+ return "\n\n---\n\n".join(
107
+ part
108
+ for part in (
109
+ self._render_agent_instructions(agent_instructions),
110
+ self._build_platform_policy(),
111
+ self._build_capability_context(available_skills),
112
+ self._build_workspace_context(workspace),
113
+ )
114
+ if part
115
+ )
116
+
117
+ def _render_agent_instructions(self, text: str) -> str:
118
+ return self._wrap_text("agent_instructions", text)
119
+
120
+ def _build_capability_context(self, available_skills: str) -> str:
121
+ parts = [self._build_tool_use()]
122
+ if available_skills.strip():
123
+ parts.append(available_skills.strip())
124
+
125
+ body = "\n\n".join(self._indent(part, 2) for part in parts)
126
+ return f"<capability_context>\n{body}\n</capability_context>"
127
+
128
+ def _build_tool_use(self) -> str:
129
+ instructions = self._wrap_text("instructions", _TOOL_USE_INSTRUCTIONS)
130
+ return (
131
+ "<tool_use>\n"
132
+ f"{self._indent(instructions, 2)}\n"
133
+ "</tool_use>"
134
+ )
135
+
136
+ def _build_platform_policy(self) -> str:
137
+ system = platform.system() or sys.platform
138
+ if system == "Windows":
139
+ family = "windows"
140
+ instructions = [
141
+ "Do not assume GNU tools like grep, sed, or awk are available.",
142
+ "Prefer structured file tools or Windows-native commands when they are more reliable.",
143
+ "If terminal output is garbled, retry with UTF-8 output enabled.",
144
+ ]
145
+ else:
146
+ family = "posix"
147
+ instructions = [
148
+ "Prefer UTF-8 and standard POSIX shell behavior.",
149
+ "Use structured file tools when they are simpler or safer than shell commands.",
150
+ "Use command execution for tests, builds, package commands, git commands, and project-specific CLIs.",
151
+ ]
152
+
153
+ lines = [
154
+ "<platform_policy>",
155
+ f" <system>{escape(self._format_os_name())}</system>",
156
+ f" <family>{family}</family>",
157
+ " <instructions>",
158
+ ]
159
+ lines.extend(
160
+ f" <item>{escape(instruction)}</item>"
161
+ for instruction in instructions
162
+ )
163
+ lines.extend(
164
+ [
165
+ " </instructions>",
166
+ "</platform_policy>",
167
+ ]
168
+ )
169
+ return "\n".join(lines)
170
+
171
+ def _build_workspace_context(self, workspace: Path) -> str:
172
+ return "\n".join(
173
+ [
174
+ "<workspace_context>",
175
+ " <workspace>",
176
+ f" <cwd>{escape(workspace.as_posix())}</cwd>",
177
+ f" <os>{escape(self._format_os_name())}</os>",
178
+ f" <architecture>{escape(platform.machine())}</architecture>",
179
+ f" <python_version>{escape(platform.python_version())}</python_version>",
180
+ f" <shell>{escape(self._detect_shell())}</shell>",
181
+ " </workspace>",
182
+ "</workspace_context>",
183
+ ]
184
+ )
185
+
186
+ def _build_runtime_context(
187
+ self,
188
+ dynamic_context: Mapping[str, DynamicValue] | None,
189
+ *,
190
+ timezone: str | None,
191
+ ) -> str:
192
+ parts = [self._build_environment_context(timezone)]
193
+ if dynamic_context:
194
+ rendered_dynamic = self._render_dynamic_context(dynamic_context)
195
+ if rendered_dynamic:
196
+ parts.append(rendered_dynamic)
197
+
198
+ body = "\n\n".join(self._indent(part, 2) for part in parts)
199
+ return f"<runtime_context>\n{body}\n</runtime_context>"
200
+
201
+ def _build_environment_context(self, timezone: str | None) -> str:
202
+ timezone_info, timezone_name = self._effective_timezone(timezone)
203
+ now = (
204
+ datetime.now(timezone_info)
205
+ if timezone_info is not None
206
+ else datetime.now().astimezone()
207
+ )
208
+ offset = now.strftime("%z")
209
+ utc_offset = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
210
+ current_time = (
211
+ f"{now.strftime('%Y-%m-%d %H:%M (%A)')} "
212
+ f"({timezone_name}, UTC{utc_offset})"
213
+ )
214
+ return "\n".join(
215
+ [
216
+ "<environment_context>",
217
+ f" <current_time>{escape(current_time)}</current_time>",
218
+ "</environment_context>",
219
+ ]
220
+ )
221
+
222
+ @staticmethod
223
+ def _effective_timezone(timezone: str | None) -> tuple[ZoneInfo | None, str]:
224
+ timezone_info = ContextBuilder._resolve_timezone(timezone)
225
+ if timezone_info is not None and timezone is not None:
226
+ return timezone_info, timezone
227
+
228
+ detected = ContextBuilder._detect_timezone()
229
+ return ContextBuilder._resolve_timezone(detected), detected
230
+
231
+ def _render_dynamic_context(self, data: Mapping[str, DynamicValue]) -> str:
232
+ lines = ["<dynamic_context>"]
233
+ for key, value in data.items():
234
+ self._render_dynamic_value(lines, key, value, indent=2)
235
+ lines.append("</dynamic_context>")
236
+
237
+ if len(lines) == 2:
238
+ return ""
239
+ return "\n".join(lines)
240
+
241
+ def _render_dynamic_value(
242
+ self,
243
+ lines: list[str],
244
+ key: str,
245
+ value: DynamicValue,
246
+ *,
247
+ indent: int,
248
+ ) -> None:
249
+ if value is None:
250
+ return
251
+
252
+ tag = self._safe_tag_name(key)
253
+ space = " " * indent
254
+
255
+ if isinstance(value, dict):
256
+ lines.append(f"{space}<{tag}>")
257
+ for child_key, child_value in value.items():
258
+ self._render_dynamic_value(
259
+ lines,
260
+ child_key,
261
+ child_value,
262
+ indent=indent + 2,
263
+ )
264
+ lines.append(f"{space}</{tag}>")
265
+ return
266
+
267
+ if isinstance(value, list):
268
+ lines.append(f"{space}<{tag}>")
269
+ for item in value:
270
+ self._render_dynamic_value(lines, "item", item, indent=indent + 2)
271
+ lines.append(f"{space}</{tag}>")
272
+ return
273
+
274
+ lines.append(f"{space}<{tag}>{escape(str(value))}</{tag}>")
275
+
276
+ @staticmethod
277
+ def _wrap_text(tag: str, text: str) -> str:
278
+ return f"<{tag}>\n{escape(text.strip())}\n</{tag}>"
279
+
280
+ @staticmethod
281
+ def _safe_tag_name(key: str) -> str:
282
+ tag = re.sub(r"[^a-zA-Z0-9_-]", "_", key.strip())
283
+ if not tag or tag[0].isdigit():
284
+ tag = f"field_{tag}"
285
+ return tag
286
+
287
+ @staticmethod
288
+ def _indent(text: str, spaces: int) -> str:
289
+ prefix = " " * spaces
290
+ return "\n".join(prefix + line if line else line for line in text.splitlines())
291
+
292
+ @staticmethod
293
+ def _format_os_name() -> str:
294
+ system = platform.system()
295
+ if system == "Darwin":
296
+ return "macOS"
297
+ return system or sys.platform
298
+
299
+ @staticmethod
300
+ def _detect_shell() -> str:
301
+ for variable in ("SHELL", "COMSPEC"):
302
+ value = os.environ.get(variable)
303
+ if value:
304
+ return ContextBuilder._shell_name(value)
305
+
306
+ return "unknown"
307
+
308
+ @staticmethod
309
+ def _shell_name(value: str) -> str:
310
+ if "\\" in value or re.match(r"^[a-zA-Z]:", value):
311
+ return PureWindowsPath(value).name
312
+ return Path(value).name
313
+
314
+ @staticmethod
315
+ def _resolve_timezone(timezone: str | None) -> ZoneInfo | None:
316
+ if timezone is None:
317
+ return None
318
+ try:
319
+ return ZoneInfo(timezone)
320
+ except (ZoneInfoNotFoundError, ValueError):
321
+ return None
322
+
323
+ @staticmethod
324
+ def _detect_timezone() -> str:
325
+ env_tz = os.environ.get("TZ")
326
+ if env_tz and ContextBuilder._is_valid_timezone(env_tz):
327
+ return env_tz
328
+
329
+ localtime_tz = ContextBuilder._detect_timezone_from_localtime()
330
+ if localtime_tz:
331
+ return localtime_tz
332
+
333
+ tzinfo = datetime.now().astimezone().tzinfo
334
+ key = getattr(tzinfo, "key", None)
335
+ if isinstance(key, str) and key:
336
+ return key
337
+ name = datetime.now().astimezone().tzname()
338
+ return name or "local"
339
+
340
+ @staticmethod
341
+ def _detect_timezone_from_localtime() -> str | None:
342
+ try:
343
+ target = Path("/etc/localtime").resolve()
344
+ except OSError:
345
+ return None
346
+ return ContextBuilder._timezone_from_zoneinfo_path(target.as_posix())
347
+
348
+ @staticmethod
349
+ def _timezone_from_zoneinfo_path(path: str) -> str | None:
350
+ marker = "/zoneinfo/"
351
+ if marker not in path:
352
+ return None
353
+
354
+ timezone = path.split(marker, 1)[1]
355
+ return timezone if ContextBuilder._is_valid_timezone(timezone) else None
356
+
357
+ @staticmethod
358
+ def _is_valid_timezone(timezone: str) -> bool:
359
+ try:
360
+ ZoneInfo(timezone)
361
+ except (ZoneInfoNotFoundError, ValueError):
362
+ return False
363
+ return True
@@ -0,0 +1,109 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from .history import prepare_history, repair_message_sequence
5
+ from .window import fit_context_window
6
+
7
+
8
+ Message = dict[str, Any]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ContextGovernanceConfig:
13
+ """Settings for preparing a model-facing copy of run messages."""
14
+
15
+ provider: Any
16
+ model: str
17
+ tools: list[dict[str, Any]]
18
+ context_window_tokens: int | None
19
+ max_completion_tokens: int
20
+ max_tool_result_chars: int | None = None
21
+
22
+
23
+ class ContextGovernor:
24
+ """Prepare model-facing messages without mutating run history."""
25
+
26
+ @staticmethod
27
+ def prepare_for_model(
28
+ messages: list[Message],
29
+ *,
30
+ config: ContextGovernanceConfig,
31
+ ) -> list[Message]:
32
+ repaired = _strip_malformed_tool_calls(messages)
33
+ prepared = prepare_history(
34
+ repaired,
35
+ max_tool_result_chars=config.max_tool_result_chars,
36
+ )
37
+ fitted = fit_context_window(
38
+ provider=config.provider,
39
+ model=config.model,
40
+ messages=prepared,
41
+ tools=config.tools,
42
+ context_window_tokens=config.context_window_tokens,
43
+ max_completion_tokens=config.max_completion_tokens,
44
+ )
45
+ return repair_message_sequence(fitted)
46
+
47
+
48
+ def _strip_malformed_tool_calls(
49
+ messages: list[Message],
50
+ ) -> list[Message]:
51
+ """Drop invalid assistant tool calls from a model-facing history copy."""
52
+ repaired: list[Message] = []
53
+ for message in messages:
54
+ current = dict(message)
55
+ if current.get("role") != "assistant" or not current.get("tool_calls"):
56
+ repaired.append(current)
57
+ continue
58
+
59
+ calls = current.get("tool_calls")
60
+ if not isinstance(calls, list):
61
+ current.pop("tool_calls", None)
62
+ if _has_assistant_payload(current):
63
+ repaired.append(current)
64
+ continue
65
+
66
+ kept = [call for call in calls if _is_valid_tool_call(call)]
67
+ if kept:
68
+ current["tool_calls"] = kept
69
+ repaired.append(current)
70
+ continue
71
+
72
+ current.pop("tool_calls", None)
73
+ if _has_assistant_payload(current):
74
+ repaired.append(current)
75
+
76
+ return repaired
77
+
78
+
79
+ def _is_valid_tool_call(call: Any) -> bool:
80
+ if not isinstance(call, dict):
81
+ return False
82
+
83
+ call_id = call.get("id")
84
+ if not isinstance(call_id, str) or not call_id:
85
+ return False
86
+
87
+ function = call.get("function")
88
+ if isinstance(function, dict):
89
+ name = function.get("name")
90
+ else:
91
+ name = call.get("name")
92
+
93
+ return isinstance(name, str) and bool(name)
94
+
95
+
96
+ def _has_assistant_payload(message: Message) -> bool:
97
+ if _has_content(message.get("content")):
98
+ return True
99
+ return _has_content(message.get("reasoning_content"))
100
+
101
+
102
+ def _has_content(content: Any) -> bool:
103
+ if content is None:
104
+ return False
105
+ if isinstance(content, str):
106
+ return bool(content.strip())
107
+ if isinstance(content, list):
108
+ return bool(content)
109
+ return True