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.
- bumblehive/__init__.py +47 -0
- bumblehive/agent/__init__.py +46 -0
- bumblehive/agent/context/__init__.py +33 -0
- bumblehive/agent/context/builder.py +363 -0
- bumblehive/agent/context/governor.py +109 -0
- bumblehive/agent/context/history.py +416 -0
- bumblehive/agent/context/prompts/__init__.py +1 -0
- bumblehive/agent/context/prompts/agent_instructions.md +23 -0
- bumblehive/agent/context/prompts/tool_use_instructions.md +42 -0
- bumblehive/agent/context/window.py +235 -0
- bumblehive/agent/loop.py +159 -0
- bumblehive/agent/runner.py +399 -0
- bumblehive/config/__init__.py +3 -0
- bumblehive/config/loader.py +37 -0
- bumblehive/config/schema.py +383 -0
- bumblehive/console.py +585 -0
- bumblehive/observability/__init__.py +96 -0
- bumblehive/observability/emitter.py +71 -0
- bumblehive/observability/emitters.py +243 -0
- bumblehive/observability/events.py +69 -0
- bumblehive/observability/hooks.py +95 -0
- bumblehive/observability/payloads.py +44 -0
- bumblehive/observability/recording.py +16 -0
- bumblehive/observability/streaming.py +105 -0
- bumblehive/paths.py +28 -0
- bumblehive/protocols/__init__.py +15 -0
- bumblehive/protocols/errors.py +10 -0
- bumblehive/protocols/generation.py +22 -0
- bumblehive/protocols/mcp.py +22 -0
- bumblehive/protocols/tool_calls.py +125 -0
- bumblehive/providers/__init__.py +23 -0
- bumblehive/providers/base.py +222 -0
- bumblehive/providers/manager.py +56 -0
- bumblehive/providers/openai_chat_completions.py +904 -0
- bumblehive/providers/streaming.py +48 -0
- bumblehive/runtime.py +395 -0
- bumblehive/session/__init__.py +9 -0
- bumblehive/session/manager.py +155 -0
- bumblehive/session/models.py +13 -0
- bumblehive/session/stores/__init__.py +3 -0
- bumblehive/session/stores/json_file.py +101 -0
- bumblehive/skills/__init__.py +13 -0
- bumblehive/skills/loader.py +74 -0
- bumblehive/skills/manager.py +82 -0
- bumblehive/skills/models.py +30 -0
- bumblehive/skills/render.py +47 -0
- bumblehive/tools/__init__.py +24 -0
- bumblehive/tools/adapters/__init__.py +6 -0
- bumblehive/tools/adapters/function.py +21 -0
- bumblehive/tools/adapters/mcp.py +89 -0
- bumblehive/tools/base.py +133 -0
- bumblehive/tools/builtins/__init__.py +59 -0
- bumblehive/tools/builtins/file.py +1074 -0
- bumblehive/tools/builtins/patch.py +359 -0
- bumblehive/tools/builtins/search.py +526 -0
- bumblehive/tools/builtins/shell.py +1086 -0
- bumblehive/tools/builtins/state.py +34 -0
- bumblehive/tools/builtins/workspace.py +267 -0
- bumblehive/tools/executor.py +122 -0
- bumblehive/tools/manager.py +289 -0
- bumblehive/tools/mcp/__init__.py +13 -0
- bumblehive/tools/mcp/manager.py +226 -0
- bumblehive/tools/registry.py +159 -0
- bumblehive/tools/scope.py +104 -0
- bumblehive-0.1.0.dist-info/METADATA +112 -0
- bumblehive-0.1.0.dist-info/RECORD +68 -0
- bumblehive-0.1.0.dist-info/WHEEL +4 -0
- 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
|