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.
- easyharness/__init__.py +18 -0
- easyharness/_internal/__init__.py +6 -0
- easyharness/_internal/conversation.py +173 -0
- easyharness/_internal/model.py +198 -0
- easyharness/_internal/runtime.py +500 -0
- easyharness/_internal/tools.py +447 -0
- easyharness/_internal/types.py +75 -0
- easyharness/toolset/__init__.py +10 -0
- easyharness/toolset/fileglide.py +818 -0
- easyharness-0.1.0.dist-info/METADATA +252 -0
- easyharness-0.1.0.dist-info/RECORD +14 -0
- easyharness-0.1.0.dist-info/WHEEL +5 -0
- easyharness-0.1.0.dist-info/licenses/LICENSE +21 -0
- easyharness-0.1.0.dist-info/top_level.txt +1 -0
easyharness/__init__.py
ADDED
|
@@ -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
|
+
)
|