data-harness 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {data_harness-0.2.0 → data_harness-0.3.0}/PKG-INFO +1 -1
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/__init__.py +24 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/agent.py +17 -10
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/loop.py +40 -36
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/providers/anthropic.py +75 -9
- data_harness-0.3.0/data_harness/providers/base.py +133 -0
- data_harness-0.3.0/data_harness/streaming.py +186 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/pyproject.toml +1 -1
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_async_loop.py +37 -18
- data_harness-0.3.0/tests/test_providers.py +490 -0
- data_harness-0.3.0/tests/test_streaming.py +1994 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/uv.lock +1 -1
- data_harness-0.2.0/data_harness/providers/base.py +0 -67
- data_harness-0.2.0/tests/test_providers.py +0 -201
- {data_harness-0.2.0 → data_harness-0.3.0}/.github/workflows/ci.yml +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/.github/workflows/release-testpypi.yml +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/.github/workflows/release.yml +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/.gitignore +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/LICENSE +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/README.md +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/cache.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/exceptions.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/format.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/logger.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/observe.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/providers/__init__.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/providers/openai.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/result.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/schema.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/serialize.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/testing.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/__init__.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/connectors.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/interpreter.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/planner.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/subagent.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/variables.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/types.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/examples/advanced_wiring.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/examples/data/README.md +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/examples/data/fred_unrate_2024.csv +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/examples/inspect_run.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/examples/quickstart.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/__init__.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/_smoke_dashboard.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/conftest.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/smoke_tests.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_agent.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_cache.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_docs.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_docs_phase5.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_format.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_gaps.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_integration.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_logger.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_loop.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_loop_reminders.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_observe.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_providers_openai.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_result.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_review_fixes.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_schema.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_serialize.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_session_inspection.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_testing.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_annotations.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_connectors.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_interpreter.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_planner.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_subagent.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_variables.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_turn_summary.py +0 -0
- {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: data-harness
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Data agent with Python-native tools (no bash)
|
|
5
5
|
Project-URL: Homepage, https://github.com/maxkskhor/data-harness
|
|
6
6
|
Project-URL: Repository, https://github.com/maxkskhor/data-harness
|
|
@@ -12,6 +12,19 @@ from data_harness.providers.base import (
|
|
|
12
12
|
StopReason,
|
|
13
13
|
)
|
|
14
14
|
from data_harness.result import CacheStorageInfo, RunResult, Usage
|
|
15
|
+
from data_harness.streaming import (
|
|
16
|
+
ContentBlockDeltaEvent,
|
|
17
|
+
ContentBlockStartEvent,
|
|
18
|
+
ContentBlockStopEvent,
|
|
19
|
+
ContentDelta,
|
|
20
|
+
InputJSONDelta,
|
|
21
|
+
MessageDeltaEvent,
|
|
22
|
+
MessageStartEvent,
|
|
23
|
+
MessageStopEvent,
|
|
24
|
+
StreamEvent,
|
|
25
|
+
TextDelta,
|
|
26
|
+
ToolResultEvent,
|
|
27
|
+
)
|
|
15
28
|
from data_harness.types import (
|
|
16
29
|
ContentBlock,
|
|
17
30
|
Message,
|
|
@@ -31,17 +44,28 @@ __all__ = [
|
|
|
31
44
|
"AsyncProviderAdapter",
|
|
32
45
|
"CacheStorageInfo",
|
|
33
46
|
"ContentBlock",
|
|
47
|
+
"ContentBlockDeltaEvent",
|
|
48
|
+
"ContentBlockStartEvent",
|
|
49
|
+
"ContentBlockStopEvent",
|
|
50
|
+
"ContentDelta",
|
|
51
|
+
"InputJSONDelta",
|
|
34
52
|
"MaxTurnsExceeded",
|
|
35
53
|
"Message",
|
|
54
|
+
"MessageDeltaEvent",
|
|
55
|
+
"MessageStartEvent",
|
|
56
|
+
"MessageStopEvent",
|
|
36
57
|
"NormalizedResponse",
|
|
37
58
|
"ProviderAdapter",
|
|
38
59
|
"RunResult",
|
|
39
60
|
"StopReason",
|
|
61
|
+
"StreamEvent",
|
|
40
62
|
"SubagentRecursionError",
|
|
41
63
|
"TextBlock",
|
|
64
|
+
"TextDelta",
|
|
42
65
|
"ToolAnnotations",
|
|
43
66
|
"ToolNotFoundError",
|
|
44
67
|
"ToolResultBlock",
|
|
68
|
+
"ToolResultEvent",
|
|
45
69
|
"ToolSpec",
|
|
46
70
|
"ToolUseBlock",
|
|
47
71
|
"Usage",
|
|
@@ -20,6 +20,7 @@ from data_harness.loop import AsyncHarness, Harness
|
|
|
20
20
|
from data_harness.providers.base import AsyncProviderAdapter, ProviderAdapter
|
|
21
21
|
from data_harness.result import RunResult
|
|
22
22
|
from data_harness.schema import infer_input_schema
|
|
23
|
+
from data_harness.streaming import StreamEvent
|
|
23
24
|
from data_harness.tools.connectors import ConnectorRegistry
|
|
24
25
|
from data_harness.tools.interpreter import PythonInterpreter
|
|
25
26
|
from data_harness.tools.planner import Planner
|
|
@@ -385,18 +386,24 @@ class AsyncAgent:
|
|
|
385
386
|
raise RuntimeError(result.error or "unknown error")
|
|
386
387
|
return result.text
|
|
387
388
|
|
|
388
|
-
async def run_stream(self, user_message: str) -> AsyncGenerator[
|
|
389
|
-
"""Stream
|
|
389
|
+
async def run_stream(self, user_message: str) -> AsyncGenerator[StreamEvent, None]:
|
|
390
|
+
"""Stream events for a one-shot run.
|
|
391
|
+
|
|
392
|
+
Yields StreamEvent objects (message_start, content_block_*, message_delta,
|
|
393
|
+
message_stop, tool_result) following the Claude Agent SDK protocol.
|
|
390
394
|
|
|
391
395
|
Usage::
|
|
392
396
|
|
|
393
|
-
async for
|
|
394
|
-
|
|
397
|
+
async for event in agent.run_stream("hello"):
|
|
398
|
+
if event.type == "content_block_delta":
|
|
399
|
+
from data_harness.streaming import TextDelta
|
|
400
|
+
if isinstance(event.delta, TextDelta):
|
|
401
|
+
print(event.delta.text, end="", flush=True)
|
|
395
402
|
"""
|
|
396
403
|
harness = self._make_harness()
|
|
397
404
|
self._last_harness = harness
|
|
398
|
-
async for
|
|
399
|
-
yield
|
|
405
|
+
async for event in harness.run_stream(user_message):
|
|
406
|
+
yield event
|
|
400
407
|
self._last_run_file = harness.run_file
|
|
401
408
|
|
|
402
409
|
def _build_tools(
|
|
@@ -507,10 +514,10 @@ class AsyncAgentSession:
|
|
|
507
514
|
raise RuntimeError(result.error or "unknown error")
|
|
508
515
|
return result.text
|
|
509
516
|
|
|
510
|
-
async def ask_stream(self, user_message: str) -> AsyncGenerator[
|
|
511
|
-
"""Stream
|
|
512
|
-
async for
|
|
513
|
-
yield
|
|
517
|
+
async def ask_stream(self, user_message: str) -> AsyncGenerator[StreamEvent, None]:
|
|
518
|
+
"""Stream events for a follow-up turn."""
|
|
519
|
+
async for event in self._harness.ask_stream(user_message):
|
|
520
|
+
yield event
|
|
514
521
|
self._agent._last_harness = self._harness
|
|
515
522
|
self._agent._last_run_file = self._harness.run_file
|
|
516
523
|
|
|
@@ -18,6 +18,11 @@ from data_harness.providers.base import (
|
|
|
18
18
|
StopReason,
|
|
19
19
|
)
|
|
20
20
|
from data_harness.result import CacheStorageInfo, RunResult, Usage
|
|
21
|
+
from data_harness.streaming import (
|
|
22
|
+
StreamEvent,
|
|
23
|
+
ToolResultEvent,
|
|
24
|
+
accumulate_stream_events,
|
|
25
|
+
)
|
|
21
26
|
from data_harness.types import (
|
|
22
27
|
Message,
|
|
23
28
|
TextBlock,
|
|
@@ -355,27 +360,29 @@ class AsyncHarness:
|
|
|
355
360
|
raise RuntimeError(result.error or "unknown error")
|
|
356
361
|
return result.text
|
|
357
362
|
|
|
358
|
-
async def run_stream(self, user_message: str) -> AsyncGenerator[
|
|
359
|
-
"""Stream
|
|
363
|
+
async def run_stream(self, user_message: str) -> AsyncGenerator[StreamEvent, None]:
|
|
364
|
+
"""Stream events for a one-shot run.
|
|
360
365
|
|
|
361
|
-
Yields
|
|
362
|
-
|
|
363
|
-
|
|
366
|
+
Yields StreamEvent objects following the same protocol as the Claude
|
|
367
|
+
Agent SDK. Each provider turn emits message_start,
|
|
368
|
+
content_block_start/delta/stop, message_delta, and message_stop events.
|
|
369
|
+
After the harness dispatches a tool call a ToolResultEvent is emitted.
|
|
370
|
+
The JSONL logger records fully assembled messages, not individual events.
|
|
364
371
|
"""
|
|
365
372
|
self._run_file = setup_logger(self._run_dir)
|
|
366
373
|
self._messages = [Message(role="user", content=[TextBlock(text=user_message)])]
|
|
367
|
-
async for
|
|
368
|
-
yield
|
|
374
|
+
async for event in self._run_loop_stream():
|
|
375
|
+
yield event
|
|
369
376
|
|
|
370
|
-
async def ask_stream(self, user_message: str) -> AsyncGenerator[
|
|
371
|
-
"""Stream
|
|
377
|
+
async def ask_stream(self, user_message: str) -> AsyncGenerator[StreamEvent, None]:
|
|
378
|
+
"""Stream events for a follow-up turn in a session."""
|
|
372
379
|
if self._run_file is None:
|
|
373
380
|
self._run_file = setup_logger(self._run_dir)
|
|
374
381
|
self._messages.append(
|
|
375
382
|
Message(role="user", content=[TextBlock(text=user_message)])
|
|
376
383
|
)
|
|
377
|
-
async for
|
|
378
|
-
yield
|
|
384
|
+
async for event in self._run_loop_stream():
|
|
385
|
+
yield event
|
|
379
386
|
|
|
380
387
|
async def _run_loop_result(self) -> RunResult:
|
|
381
388
|
if self._run_file is None:
|
|
@@ -471,7 +478,7 @@ class AsyncHarness:
|
|
|
471
478
|
cache_storage=self._build_cache_storage(),
|
|
472
479
|
)
|
|
473
480
|
|
|
474
|
-
async def _run_loop_stream(self) -> AsyncGenerator[
|
|
481
|
+
async def _run_loop_stream(self) -> AsyncGenerator[StreamEvent, None]:
|
|
475
482
|
if self._run_file is None:
|
|
476
483
|
raise RuntimeError("run_file must be initialised before running the loop")
|
|
477
484
|
|
|
@@ -481,35 +488,17 @@ class AsyncHarness:
|
|
|
481
488
|
self._apply_reminders(turn)
|
|
482
489
|
visible_tools = [t for t in self._tools if t.visible]
|
|
483
490
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
async def on_chunk(text: str) -> None:
|
|
487
|
-
await chunk_queue.put(text)
|
|
491
|
+
events_this_turn: list[StreamEvent] = []
|
|
488
492
|
|
|
489
|
-
|
|
493
|
+
with time_block() as tb:
|
|
490
494
|
try:
|
|
491
|
-
|
|
495
|
+
async for evt in self._adapter.stream_events(
|
|
492
496
|
system=self._system,
|
|
493
497
|
messages=self._messages,
|
|
494
498
|
tools=visible_tools,
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
await chunk_queue.put(None) # sentinel: stream is done
|
|
499
|
-
|
|
500
|
-
with time_block() as tb:
|
|
501
|
-
stream_task: asyncio.Task[NormalizedResponse] = asyncio.create_task(
|
|
502
|
-
do_stream()
|
|
503
|
-
)
|
|
504
|
-
|
|
505
|
-
while True:
|
|
506
|
-
chunk = await chunk_queue.get()
|
|
507
|
-
if chunk is None:
|
|
508
|
-
break
|
|
509
|
-
yield chunk
|
|
510
|
-
|
|
511
|
-
try:
|
|
512
|
-
response = await stream_task
|
|
499
|
+
):
|
|
500
|
+
events_this_turn.append(evt)
|
|
501
|
+
yield evt
|
|
513
502
|
except Exception as exc:
|
|
514
503
|
log_error_turn(
|
|
515
504
|
turn=turn,
|
|
@@ -521,6 +510,7 @@ class AsyncHarness:
|
|
|
521
510
|
return
|
|
522
511
|
|
|
523
512
|
latency = tb.elapsed_ms
|
|
513
|
+
response = accumulate_stream_events(events_this_turn)
|
|
524
514
|
|
|
525
515
|
total_usage = total_usage + Usage(
|
|
526
516
|
input_tokens=response.input_tokens,
|
|
@@ -535,6 +525,20 @@ class AsyncHarness:
|
|
|
535
525
|
|
|
536
526
|
if response.stop_reason == StopReason.TOOL_USE:
|
|
537
527
|
tool_results = await self._dispatch_tools(response.content)
|
|
528
|
+
|
|
529
|
+
tool_name_map = {
|
|
530
|
+
b.tool_use_id: b.tool_name
|
|
531
|
+
for b in response.content
|
|
532
|
+
if isinstance(b, ToolUseBlock)
|
|
533
|
+
}
|
|
534
|
+
for result in tool_results:
|
|
535
|
+
yield ToolResultEvent(
|
|
536
|
+
tool_use_id=result.tool_use_id,
|
|
537
|
+
tool_name=tool_name_map.get(result.tool_use_id, ""),
|
|
538
|
+
content=result.content,
|
|
539
|
+
is_error=result.is_error,
|
|
540
|
+
)
|
|
541
|
+
|
|
538
542
|
self._messages.append(Message(role="user", content=list(tool_results)))
|
|
539
543
|
|
|
540
544
|
tool_error_count = sum(1 for r in tool_results if r.is_error)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
|
-
from collections.abc import
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
5
6
|
|
|
6
7
|
import anthropic
|
|
7
8
|
|
|
@@ -19,6 +20,9 @@ from data_harness.types import (
|
|
|
19
20
|
ToolUseBlock,
|
|
20
21
|
)
|
|
21
22
|
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from data_harness.streaming import StreamEvent
|
|
25
|
+
|
|
22
26
|
_STOP_REASON_MAP = {
|
|
23
27
|
"end_turn": StopReason.END_TURN,
|
|
24
28
|
"tool_use": StopReason.TOOL_USE,
|
|
@@ -156,14 +160,24 @@ class AsyncAnthropicAdapter(_AnthropicHelpers, AsyncProviderAdapter):
|
|
|
156
160
|
)
|
|
157
161
|
return self._normalize_response(resp)
|
|
158
162
|
|
|
159
|
-
async def
|
|
163
|
+
async def stream_events(
|
|
160
164
|
self,
|
|
161
165
|
system: str,
|
|
162
166
|
messages: list[Message],
|
|
163
167
|
tools: list[ToolSpec],
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
169
|
+
"""Yield StreamEvents by mapping raw Anthropic SSE events."""
|
|
170
|
+
from data_harness.streaming import (
|
|
171
|
+
ContentBlockDeltaEvent,
|
|
172
|
+
ContentBlockStartEvent,
|
|
173
|
+
ContentBlockStopEvent,
|
|
174
|
+
InputJSONDelta,
|
|
175
|
+
MessageDeltaEvent,
|
|
176
|
+
MessageStartEvent,
|
|
177
|
+
MessageStopEvent,
|
|
178
|
+
TextDelta,
|
|
179
|
+
)
|
|
180
|
+
|
|
167
181
|
api_system = self._build_system(system)
|
|
168
182
|
api_messages = self._build_messages(messages)
|
|
169
183
|
api_tools = self._build_tools(tools)
|
|
@@ -175,8 +189,60 @@ class AsyncAnthropicAdapter(_AnthropicHelpers, AsyncProviderAdapter):
|
|
|
175
189
|
messages=api_messages,
|
|
176
190
|
tools=api_tools or anthropic.NOT_GIVEN,
|
|
177
191
|
) as stream:
|
|
178
|
-
async for
|
|
179
|
-
|
|
180
|
-
|
|
192
|
+
async for event in stream:
|
|
193
|
+
if event.type == "message_start":
|
|
194
|
+
yield MessageStartEvent()
|
|
195
|
+
|
|
196
|
+
elif event.type == "content_block_start":
|
|
197
|
+
block = event.content_block
|
|
198
|
+
if block.type == "text":
|
|
199
|
+
yield ContentBlockStartEvent(
|
|
200
|
+
index=event.index,
|
|
201
|
+
content_block=TextBlock(text=""),
|
|
202
|
+
)
|
|
203
|
+
elif block.type == "tool_use":
|
|
204
|
+
yield ContentBlockStartEvent(
|
|
205
|
+
index=event.index,
|
|
206
|
+
content_block=ToolUseBlock(
|
|
207
|
+
tool_use_id=block.id,
|
|
208
|
+
tool_name=block.name,
|
|
209
|
+
tool_input={},
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
elif event.type == "content_block_delta":
|
|
214
|
+
delta = event.delta
|
|
215
|
+
if delta.type == "text_delta":
|
|
216
|
+
yield ContentBlockDeltaEvent(
|
|
217
|
+
index=event.index,
|
|
218
|
+
delta=TextDelta(text=delta.text),
|
|
219
|
+
)
|
|
220
|
+
elif delta.type == "input_json_delta":
|
|
221
|
+
yield ContentBlockDeltaEvent(
|
|
222
|
+
index=event.index,
|
|
223
|
+
delta=InputJSONDelta(partial_json=delta.partial_json),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
elif event.type == "content_block_stop":
|
|
227
|
+
yield ContentBlockStopEvent(index=event.index)
|
|
228
|
+
|
|
229
|
+
elif event.type == "message_delta":
|
|
230
|
+
stop_reason = _STOP_REASON_MAP.get(
|
|
231
|
+
event.delta.stop_reason, StopReason.END_TURN
|
|
232
|
+
)
|
|
233
|
+
yield MessageDeltaEvent(
|
|
234
|
+
stop_reason=stop_reason,
|
|
235
|
+
input_tokens=event.usage.input_tokens,
|
|
236
|
+
output_tokens=event.usage.output_tokens,
|
|
237
|
+
cache_read_tokens=getattr(
|
|
238
|
+
event.usage, "cache_read_input_tokens", 0
|
|
239
|
+
)
|
|
240
|
+
or 0,
|
|
241
|
+
cache_write_tokens=getattr(
|
|
242
|
+
event.usage, "cache_creation_input_tokens", 0
|
|
243
|
+
)
|
|
244
|
+
or 0,
|
|
245
|
+
)
|
|
181
246
|
|
|
182
|
-
|
|
247
|
+
elif event.type == "message_stop":
|
|
248
|
+
yield MessageStopEvent()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from data_harness.types import ContentBlock, Message, TextBlock, ToolSpec, ToolUseBlock
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from data_harness.streaming import StreamEvent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StopReason(Enum):
|
|
17
|
+
END_TURN = "end_turn"
|
|
18
|
+
TOOL_USE = "tool_use"
|
|
19
|
+
MAX_TOKENS = "max_tokens"
|
|
20
|
+
STOP_SEQUENCE = "stop_sequence"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class NormalizedResponse:
|
|
25
|
+
stop_reason: StopReason
|
|
26
|
+
content: list[ContentBlock]
|
|
27
|
+
input_tokens: int
|
|
28
|
+
output_tokens: int
|
|
29
|
+
cache_read_tokens: int
|
|
30
|
+
cache_write_tokens: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ProviderAdapter(ABC):
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def chat(
|
|
36
|
+
self,
|
|
37
|
+
system: str,
|
|
38
|
+
messages: list[Message],
|
|
39
|
+
tools: list[ToolSpec],
|
|
40
|
+
) -> NormalizedResponse: ...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def format_cache_control(self, obj: dict) -> dict: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AsyncProviderAdapter(ABC):
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def chat(
|
|
49
|
+
self,
|
|
50
|
+
system: str,
|
|
51
|
+
messages: list[Message],
|
|
52
|
+
tools: list[ToolSpec],
|
|
53
|
+
) -> NormalizedResponse: ...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def format_cache_control(self, obj: dict) -> dict: ...
|
|
57
|
+
|
|
58
|
+
async def stream_events(
|
|
59
|
+
self,
|
|
60
|
+
system: str,
|
|
61
|
+
messages: list[Message],
|
|
62
|
+
tools: list[ToolSpec],
|
|
63
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
64
|
+
"""Yield stream events for one provider turn.
|
|
65
|
+
|
|
66
|
+
The default implementation calls chat() and synthesises the six
|
|
67
|
+
standard event types from the assembled response. Override in
|
|
68
|
+
provider subclasses to emit real token-level events.
|
|
69
|
+
"""
|
|
70
|
+
from data_harness.streaming import (
|
|
71
|
+
ContentBlockDeltaEvent,
|
|
72
|
+
ContentBlockStartEvent,
|
|
73
|
+
ContentBlockStopEvent,
|
|
74
|
+
InputJSONDelta,
|
|
75
|
+
MessageDeltaEvent,
|
|
76
|
+
MessageStartEvent,
|
|
77
|
+
MessageStopEvent,
|
|
78
|
+
TextDelta,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
response = await self.chat(system, messages, tools)
|
|
82
|
+
yield MessageStartEvent()
|
|
83
|
+
for i, block in enumerate(response.content):
|
|
84
|
+
if isinstance(block, TextBlock):
|
|
85
|
+
yield ContentBlockStartEvent(index=i, content_block=TextBlock(text=""))
|
|
86
|
+
yield ContentBlockDeltaEvent(index=i, delta=TextDelta(text=block.text))
|
|
87
|
+
yield ContentBlockStopEvent(index=i)
|
|
88
|
+
elif isinstance(block, ToolUseBlock):
|
|
89
|
+
yield ContentBlockStartEvent(
|
|
90
|
+
index=i,
|
|
91
|
+
content_block=ToolUseBlock(
|
|
92
|
+
tool_use_id=block.tool_use_id,
|
|
93
|
+
tool_name=block.tool_name,
|
|
94
|
+
tool_input={},
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
yield ContentBlockDeltaEvent(
|
|
98
|
+
index=i,
|
|
99
|
+
delta=InputJSONDelta(partial_json=json.dumps(block.tool_input)),
|
|
100
|
+
)
|
|
101
|
+
yield ContentBlockStopEvent(index=i)
|
|
102
|
+
yield MessageDeltaEvent(
|
|
103
|
+
stop_reason=response.stop_reason,
|
|
104
|
+
input_tokens=response.input_tokens,
|
|
105
|
+
output_tokens=response.output_tokens,
|
|
106
|
+
cache_read_tokens=response.cache_read_tokens,
|
|
107
|
+
cache_write_tokens=response.cache_write_tokens,
|
|
108
|
+
)
|
|
109
|
+
yield MessageStopEvent()
|
|
110
|
+
|
|
111
|
+
async def stream(
|
|
112
|
+
self,
|
|
113
|
+
system: str,
|
|
114
|
+
messages: list[Message],
|
|
115
|
+
tools: list[ToolSpec],
|
|
116
|
+
*,
|
|
117
|
+
on_chunk: Callable[[str], Awaitable[None]],
|
|
118
|
+
) -> NormalizedResponse:
|
|
119
|
+
"""Backward-compat text-only streaming; calls stream_events() internally."""
|
|
120
|
+
from data_harness.streaming import (
|
|
121
|
+
ContentBlockDeltaEvent,
|
|
122
|
+
TextDelta,
|
|
123
|
+
accumulate_stream_events,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
events = []
|
|
127
|
+
async for evt in self.stream_events(system, messages, tools):
|
|
128
|
+
events.append(evt)
|
|
129
|
+
if isinstance(evt, ContentBlockDeltaEvent) and isinstance(
|
|
130
|
+
evt.delta, TextDelta
|
|
131
|
+
):
|
|
132
|
+
await on_chunk(evt.delta.text)
|
|
133
|
+
return accumulate_stream_events(events)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Stream event types for AsyncHarness.run_stream() and AsyncAgent.run_stream().
|
|
2
|
+
|
|
3
|
+
The event protocol mirrors the Claude Agent SDK's raw SSE event shape so that
|
|
4
|
+
callers get the same discriminated-union stream whether they use data-harness or
|
|
5
|
+
the Anthropic SDK directly. An additional ToolResultEvent is emitted after the
|
|
6
|
+
harness dispatches a tool call — it has no equivalent in the raw provider stream.
|
|
7
|
+
|
|
8
|
+
Typical iteration pattern::
|
|
9
|
+
|
|
10
|
+
async for event in agent.run_stream("..."):
|
|
11
|
+
match event.type:
|
|
12
|
+
case "content_block_delta":
|
|
13
|
+
if isinstance(event.delta, TextDelta):
|
|
14
|
+
print(event.delta.text, end="", flush=True)
|
|
15
|
+
case "tool_result":
|
|
16
|
+
print(f"[{event.tool_name}] → {event.content[:80]}")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
from data_harness.providers.base import NormalizedResponse, StopReason
|
|
26
|
+
from data_harness.types import TextBlock, ToolUseBlock
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Delta types (carried by ContentBlockDeltaEvent)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TextDelta:
|
|
35
|
+
text: str
|
|
36
|
+
type: Literal["text_delta"] = field(default="text_delta", init=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class InputJSONDelta:
|
|
41
|
+
partial_json: str
|
|
42
|
+
type: Literal["input_json_delta"] = field(default="input_json_delta", init=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ContentDelta = TextDelta | InputJSONDelta
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Stream event types (same discriminator strings as the Anthropic SDK)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class MessageStartEvent:
|
|
55
|
+
type: Literal["message_start"] = field(default="message_start", init=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ContentBlockStartEvent:
|
|
60
|
+
index: int
|
|
61
|
+
content_block: TextBlock | ToolUseBlock
|
|
62
|
+
type: Literal["content_block_start"] = field(
|
|
63
|
+
default="content_block_start", init=False
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ContentBlockDeltaEvent:
|
|
69
|
+
index: int
|
|
70
|
+
delta: TextDelta | InputJSONDelta
|
|
71
|
+
type: Literal["content_block_delta"] = field(
|
|
72
|
+
default="content_block_delta", init=False
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ContentBlockStopEvent:
|
|
78
|
+
index: int
|
|
79
|
+
type: Literal["content_block_stop"] = field(
|
|
80
|
+
default="content_block_stop", init=False
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class MessageDeltaEvent:
|
|
86
|
+
stop_reason: StopReason
|
|
87
|
+
input_tokens: int
|
|
88
|
+
output_tokens: int
|
|
89
|
+
cache_read_tokens: int
|
|
90
|
+
cache_write_tokens: int
|
|
91
|
+
type: Literal["message_delta"] = field(default="message_delta", init=False)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class MessageStopEvent:
|
|
96
|
+
type: Literal["message_stop"] = field(default="message_stop", init=False)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class ToolResultEvent:
|
|
101
|
+
"""Emitted by the harness after a tool call is dispatched and returns.
|
|
102
|
+
|
|
103
|
+
This event has no equivalent in the raw provider stream. It signals that
|
|
104
|
+
the harness has finished executing the tool and the next turn is starting.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
tool_use_id: str
|
|
108
|
+
tool_name: str
|
|
109
|
+
content: str
|
|
110
|
+
is_error: bool
|
|
111
|
+
type: Literal["tool_result"] = field(default="tool_result", init=False)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
StreamEvent = (
|
|
115
|
+
MessageStartEvent
|
|
116
|
+
| ContentBlockStartEvent
|
|
117
|
+
| ContentBlockDeltaEvent
|
|
118
|
+
| ContentBlockStopEvent
|
|
119
|
+
| MessageDeltaEvent
|
|
120
|
+
| MessageStopEvent
|
|
121
|
+
| ToolResultEvent
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Helper: reconstruct a NormalizedResponse from accumulated stream events
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def accumulate_stream_events(events: list[StreamEvent]) -> NormalizedResponse:
|
|
131
|
+
"""Build a NormalizedResponse by replaying a list of StreamEvents."""
|
|
132
|
+
block_by_index: dict[int, TextBlock | ToolUseBlock] = {}
|
|
133
|
+
json_by_index: dict[int, str] = {}
|
|
134
|
+
stop_reason = StopReason.END_TURN
|
|
135
|
+
input_tokens = output_tokens = cache_read = cache_write = 0
|
|
136
|
+
|
|
137
|
+
for evt in events:
|
|
138
|
+
if isinstance(evt, ContentBlockStartEvent):
|
|
139
|
+
if isinstance(evt.content_block, TextBlock):
|
|
140
|
+
block_by_index[evt.index] = TextBlock(text="")
|
|
141
|
+
elif isinstance(evt.content_block, ToolUseBlock):
|
|
142
|
+
cb = evt.content_block
|
|
143
|
+
block_by_index[evt.index] = ToolUseBlock(
|
|
144
|
+
tool_use_id=cb.tool_use_id,
|
|
145
|
+
tool_name=cb.tool_name,
|
|
146
|
+
tool_input={},
|
|
147
|
+
)
|
|
148
|
+
json_by_index[evt.index] = ""
|
|
149
|
+
|
|
150
|
+
elif isinstance(evt, ContentBlockDeltaEvent):
|
|
151
|
+
if isinstance(evt.delta, TextDelta):
|
|
152
|
+
b = block_by_index.get(evt.index)
|
|
153
|
+
if isinstance(b, TextBlock):
|
|
154
|
+
b.text += evt.delta.text
|
|
155
|
+
elif isinstance(evt.delta, InputJSONDelta):
|
|
156
|
+
json_by_index[evt.index] = (
|
|
157
|
+
json_by_index.get(evt.index, "") + evt.delta.partial_json
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
elif isinstance(evt, ContentBlockStopEvent):
|
|
161
|
+
if evt.index in json_by_index:
|
|
162
|
+
b = block_by_index.get(evt.index)
|
|
163
|
+
if isinstance(b, ToolUseBlock):
|
|
164
|
+
raw = json_by_index[evt.index]
|
|
165
|
+
try:
|
|
166
|
+
b.tool_input = json.loads(raw) if raw else {}
|
|
167
|
+
except json.JSONDecodeError:
|
|
168
|
+
b.tool_input = {}
|
|
169
|
+
|
|
170
|
+
elif isinstance(evt, MessageDeltaEvent):
|
|
171
|
+
stop_reason = evt.stop_reason
|
|
172
|
+
input_tokens = evt.input_tokens
|
|
173
|
+
output_tokens = evt.output_tokens
|
|
174
|
+
cache_read = evt.cache_read_tokens
|
|
175
|
+
cache_write = evt.cache_write_tokens
|
|
176
|
+
|
|
177
|
+
content = [block_by_index[i] for i in sorted(block_by_index.keys())]
|
|
178
|
+
|
|
179
|
+
return NormalizedResponse(
|
|
180
|
+
stop_reason=stop_reason,
|
|
181
|
+
content=content,
|
|
182
|
+
input_tokens=input_tokens,
|
|
183
|
+
output_tokens=output_tokens,
|
|
184
|
+
cache_read_tokens=cache_read,
|
|
185
|
+
cache_write_tokens=cache_write,
|
|
186
|
+
)
|