easyharness 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.
@@ -0,0 +1,18 @@
1
+ """Minimal public SDK surface for EasyHarness.
2
+
3
+ The package exposes only five public names: `Agent`, `ModelConfig`,
4
+ `AgentEvent`, `ToolOutput`, and `tool`. Runtime bridging, tool contract
5
+ validation, event mapping, and conversation compression stay inside private
6
+ modules so ordinary callers never have to touch internal objects.
7
+ """
8
+
9
+ from ._internal.runtime import Agent
10
+ from ._internal.tools import tool
11
+ from ._internal.types import AgentEvent, ModelConfig, ToolOutput
12
+
13
+ __all__ = ["Agent", "ModelConfig", "AgentEvent", "ToolOutput", "tool"]
14
+
15
+ __AUTHOR__ = '吴子豪 / Vortez Wohl'
16
+ __EMAIL__ = 'vortez.wohl@gmail.com'
17
+ __GITHUB__ = 'https://github.com/vortezwohl'
18
+ __BLOG__ = 'https://vortezwohl.github.io'
@@ -0,0 +1,6 @@
1
+ """Private implementation package for EasyHarness.
2
+
3
+ It contains the runtime bridge, tool contracts, event mapping, and
4
+ conversation compression internals. Callers should depend on the stable public
5
+ entry points exposed from top-level `easyharness` instead of these modules.
6
+ """
@@ -0,0 +1,173 @@
1
+ """Default conversation compression manager and event integration points.
2
+
3
+ This module adds a thin wrapper around Strands
4
+ `SummarizingConversationManager` so compression start, completion, and failure
5
+ emit unified upper-layer events without changing the original
6
+ reactive/proactive control-flow semantics.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from copy import deepcopy
14
+ from datetime import datetime, timezone
15
+ from typing import TYPE_CHECKING, Callable, Protocol
16
+
17
+ from strands.agent.conversation_manager import (
18
+ ConversationManager,
19
+ SummarizingConversationManager,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from strands.agent.agent import Agent as StrandsAgent
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ InternalEventSink = Callable[[dict[str, object]], None]
28
+
29
+
30
+ class SupportsEventSink(Protocol):
31
+ """Protocol for managers that optionally support event sink binding."""
32
+
33
+ def bind_event_sink(self, sink: InternalEventSink | None) -> None:
34
+ """Bind or clear an internal event sink."""
35
+
36
+
37
+ def utc_now_iso() -> str:
38
+ """Return the current UTC time as an ISO 8601 string."""
39
+
40
+ return datetime.now(timezone.utc).isoformat()
41
+
42
+
43
+ class EventingSummarizingConversationManager(SummarizingConversationManager):
44
+ """Default summarizing conversation manager with compression events."""
45
+
46
+ def __init__(self, *args: object, **kwargs: object) -> None:
47
+ """Initialize the default summarizing conversation manager."""
48
+
49
+ super().__init__(*args, **kwargs)
50
+ self._event_sink: InternalEventSink | None = None
51
+
52
+ def bind_event_sink(self, sink: InternalEventSink | None) -> None:
53
+ """Bind or clear the runtime event sink.
54
+
55
+ Args:
56
+ sink: Callback for internal compression events; `None` unbinds it.
57
+ """
58
+
59
+ self._event_sink = sink
60
+
61
+ def _emit(
62
+ self,
63
+ status: str,
64
+ *,
65
+ mode: str,
66
+ started_at: str,
67
+ duration_ms: int | None = None,
68
+ error: str | None = None,
69
+ ) -> None:
70
+ """Emit an internal compression event to the upper layer."""
71
+
72
+ if self._event_sink is None:
73
+ return
74
+
75
+ payload: dict[str, object] = {
76
+ "easyharness_compress": {
77
+ "status": status,
78
+ "started_at": started_at,
79
+ "duration_ms": duration_ms,
80
+ "mode": mode,
81
+ }
82
+ }
83
+ if error is not None:
84
+ payload["easyharness_compress"]["error"] = error
85
+ self._event_sink(payload)
86
+
87
+ def reduce_context(
88
+ self,
89
+ agent: StrandsAgent,
90
+ e: Exception | None = None,
91
+ **kwargs: object,
92
+ ) -> None:
93
+ """Compress context and emit started/completed/failed events.
94
+
95
+ Args:
96
+ agent: Current Strands agent.
97
+ e: Triggering exception; `None` means proactive compression.
98
+ **kwargs: Reserved for compatibility with the underlying API.
99
+
100
+ Raises:
101
+ Exception: Propagated when reactive compression also fails.
102
+ """
103
+
104
+ del kwargs
105
+ mode = "reactive" if e is not None else "proactive"
106
+ started_at = utc_now_iso()
107
+ start = time.perf_counter()
108
+ self._emit("started", mode=mode, started_at=started_at)
109
+
110
+ try:
111
+ self._summarize_oldest(agent)
112
+ except Exception as summarization_error:
113
+ duration_ms = int((time.perf_counter() - start) * 1000)
114
+ self._emit(
115
+ "failed",
116
+ mode=mode,
117
+ started_at=started_at,
118
+ duration_ms=duration_ms,
119
+ error=str(summarization_error),
120
+ )
121
+ if e is not None:
122
+ logger.error("Summarization failed: %s", summarization_error)
123
+ raise summarization_error from e
124
+ logger.warning(
125
+ "Proactive summarization failed, continuing: %s",
126
+ summarization_error,
127
+ )
128
+ else:
129
+ duration_ms = int((time.perf_counter() - start) * 1000)
130
+ self._emit(
131
+ "completed",
132
+ mode=mode,
133
+ started_at=started_at,
134
+ duration_ms=duration_ms,
135
+ )
136
+
137
+
138
+ def clone_conversation_manager(
139
+ conversation_manager: ConversationManager | None,
140
+ ) -> ConversationManager:
141
+ """Clone a conversation manager with the smallest practical cost.
142
+
143
+ Args:
144
+ conversation_manager: Caller-provided custom manager; falls back to the
145
+ default summarizing manager when omitted.
146
+
147
+ Returns:
148
+ A conversation manager instance ready for the current session.
149
+ """
150
+
151
+ if conversation_manager is None:
152
+ return EventingSummarizingConversationManager()
153
+
154
+ try:
155
+ return deepcopy(conversation_manager)
156
+ except Exception:
157
+ return conversation_manager
158
+
159
+
160
+ def bind_event_sink_if_supported(
161
+ conversation_manager: ConversationManager,
162
+ sink: InternalEventSink | None,
163
+ ) -> None:
164
+ """Bind an internal event sink when the manager supports it.
165
+
166
+ Args:
167
+ conversation_manager: Manager used by the current session.
168
+ sink: Event sink to bind; `None` clears the binding.
169
+ """
170
+
171
+ binder = getattr(conversation_manager, "bind_event_sink", None)
172
+ if callable(binder):
173
+ binder(sink)
@@ -0,0 +1,198 @@
1
+ """Bridge from public model config to the Strands model runtime.
2
+
3
+ This module turns public `ModelConfig` values into a concrete
4
+ `LiteLLMModel`. The SDK relies only on explicit inputs and does not read
5
+ environment variables or introduce extra channel/profile indirection.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any
12
+
13
+ from strands.models.litellm import LiteLLMModel
14
+ from strands.models._validation import _has_location_source
15
+ from strands.types.content import ContentBlock, Messages, SystemContentBlock
16
+
17
+ from easyharness._internal.types import ModelConfig
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _model_mentions_deepseek(model_id: str) -> bool:
23
+ """Return whether the configured model name clearly targets DeepSeek."""
24
+
25
+ normalized = model_id.strip().lower()
26
+ if not normalized:
27
+ return False
28
+ if normalized.startswith("deepseek/"):
29
+ return True
30
+ if "/" in normalized:
31
+ _, normalized = normalized.split("/", 1)
32
+ return "deepseek" in normalized
33
+
34
+
35
+ def _base_url_targets_deepseek(base_url: str) -> bool:
36
+ """Return whether the configured base URL points at DeepSeek."""
37
+
38
+ return "api.deepseek.com" in base_url.strip().lower()
39
+
40
+
41
+ def _should_use_deepseek_compat(config: ModelConfig) -> bool:
42
+ """Return whether the runtime should enable the DeepSeek compatibility path."""
43
+
44
+ model_id = config.model.strip()
45
+ provider = model_id.split("/", 1)[0].lower() if "/" in model_id else None
46
+
47
+ if _model_mentions_deepseek(model_id):
48
+ return True
49
+
50
+ if provider not in (None, "openai"):
51
+ return False
52
+
53
+ return provider is None and _base_url_targets_deepseek(config.base_url)
54
+
55
+
56
+ def _extract_reasoning_text(contents: list[ContentBlock]) -> str | None:
57
+ """Collapse reasoning text blocks into the string expected by DeepSeek."""
58
+
59
+ chunks: list[str] = []
60
+ for content in contents:
61
+ reasoning = content.get("reasoningContent")
62
+ if not reasoning:
63
+ continue
64
+
65
+ reasoning_text = reasoning.get("reasoningText")
66
+ if reasoning_text and reasoning_text.get("text"):
67
+ chunks.append(reasoning_text["text"])
68
+
69
+ text = "".join(chunks).strip()
70
+ return text or None
71
+
72
+
73
+ class _DeepSeekLiteLLMModel(LiteLLMModel):
74
+ """Minimal LiteLLM variant that preserves DeepSeek tool-call reasoning."""
75
+
76
+ @classmethod
77
+ def _format_regular_messages(
78
+ cls, messages: Messages, **kwargs: Any
79
+ ) -> list[dict[str, Any]]:
80
+ """Format messages without dropping reasoning tied to tool calls."""
81
+
82
+ del kwargs
83
+ formatted_messages: list[dict[str, Any]] = []
84
+
85
+ for message in messages:
86
+ contents = message["content"]
87
+
88
+ filtered_contents = []
89
+ for content in contents:
90
+ if any(
91
+ block_type in content
92
+ for block_type in ["toolResult", "toolUse", "reasoningContent"]
93
+ ):
94
+ continue
95
+ if _has_location_source(content):
96
+ logger.warning(
97
+ "Location sources are not supported by OpenAI | skipping content block"
98
+ )
99
+ continue
100
+ filtered_contents.append(content)
101
+
102
+ formatted_contents = [
103
+ cls.format_request_message_content(content)
104
+ for content in filtered_contents
105
+ ]
106
+ formatted_tool_calls = [
107
+ cls.format_request_message_tool_call(content["toolUse"])
108
+ for content in contents
109
+ if "toolUse" in content
110
+ ]
111
+ formatted_tool_messages = [
112
+ cls.format_request_tool_message(content["toolResult"])
113
+ for content in contents
114
+ if "toolResult" in content
115
+ ]
116
+
117
+ formatted_message: dict[str, Any] = {
118
+ "role": message["role"],
119
+ **({"content": formatted_contents} if formatted_contents else {}),
120
+ **({"tool_calls": formatted_tool_calls} if formatted_tool_calls else {}),
121
+ }
122
+
123
+ if message["role"] == "assistant" and formatted_tool_calls:
124
+ reasoning_text = _extract_reasoning_text(contents)
125
+ if reasoning_text is not None:
126
+ formatted_message["reasoning_content"] = reasoning_text
127
+ formatted_message.setdefault("content", "")
128
+
129
+ formatted_messages.append(formatted_message)
130
+
131
+ user_messages_with_images = []
132
+ for tool_msg in formatted_tool_messages:
133
+ tool_msg_clean, user_msg_with_images = cls._split_tool_message_images(
134
+ tool_msg
135
+ )
136
+ formatted_messages.append(tool_msg_clean)
137
+ if user_msg_with_images:
138
+ user_messages_with_images.append(user_msg_with_images)
139
+ formatted_messages.extend(user_messages_with_images)
140
+
141
+ return formatted_messages
142
+
143
+ @classmethod
144
+ def format_request_messages(
145
+ cls,
146
+ messages: Messages,
147
+ system_prompt: str | None = None,
148
+ *,
149
+ system_prompt_content: list[SystemContentBlock] | None = None,
150
+ **kwargs: Any,
151
+ ) -> list[dict[str, Any]]:
152
+ """Format a DeepSeek-compatible messages array."""
153
+
154
+ formatted_messages = cls._format_system_messages(
155
+ system_prompt,
156
+ system_prompt_content=system_prompt_content,
157
+ )
158
+ formatted_messages.extend(cls._format_regular_messages(messages, **kwargs))
159
+ return [
160
+ message
161
+ for message in formatted_messages
162
+ if "content" in message or "tool_calls" in message
163
+ ]
164
+
165
+
166
+ def build_runtime_model(config: ModelConfig) -> LiteLLMModel:
167
+ """Build the underlying LiteLLM model from public configuration.
168
+
169
+ Args:
170
+ config: Public SDK model configuration.
171
+
172
+ Returns:
173
+ A configured `LiteLLMModel` instance.
174
+ """
175
+
176
+ params: dict[str, object] = {
177
+ "temperature": config.temperature,
178
+ "top_p": config.top_p,
179
+ }
180
+ if config.seed is not None:
181
+ params["seed"] = config.seed
182
+
183
+ model_cls = (
184
+ _DeepSeekLiteLLMModel
185
+ if _should_use_deepseek_compat(config)
186
+ else LiteLLMModel
187
+ )
188
+
189
+ return model_cls(
190
+ client_args={
191
+ "api_key": config.api_key,
192
+ "base_url": config.base_url,
193
+ "custom_llm_provider": "openai",
194
+ },
195
+ model_id=config.model,
196
+ params=params,
197
+ stream=True,
198
+ )