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.
Files changed (73) hide show
  1. {data_harness-0.2.0 → data_harness-0.3.0}/PKG-INFO +1 -1
  2. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/__init__.py +24 -0
  3. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/agent.py +17 -10
  4. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/loop.py +40 -36
  5. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/providers/anthropic.py +75 -9
  6. data_harness-0.3.0/data_harness/providers/base.py +133 -0
  7. data_harness-0.3.0/data_harness/streaming.py +186 -0
  8. {data_harness-0.2.0 → data_harness-0.3.0}/pyproject.toml +1 -1
  9. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_async_loop.py +37 -18
  10. data_harness-0.3.0/tests/test_providers.py +490 -0
  11. data_harness-0.3.0/tests/test_streaming.py +1994 -0
  12. {data_harness-0.2.0 → data_harness-0.3.0}/uv.lock +1 -1
  13. data_harness-0.2.0/data_harness/providers/base.py +0 -67
  14. data_harness-0.2.0/tests/test_providers.py +0 -201
  15. {data_harness-0.2.0 → data_harness-0.3.0}/.github/workflows/ci.yml +0 -0
  16. {data_harness-0.2.0 → data_harness-0.3.0}/.github/workflows/release-testpypi.yml +0 -0
  17. {data_harness-0.2.0 → data_harness-0.3.0}/.github/workflows/release.yml +0 -0
  18. {data_harness-0.2.0 → data_harness-0.3.0}/.gitignore +0 -0
  19. {data_harness-0.2.0 → data_harness-0.3.0}/LICENSE +0 -0
  20. {data_harness-0.2.0 → data_harness-0.3.0}/README.md +0 -0
  21. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/cache.py +0 -0
  22. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/exceptions.py +0 -0
  23. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/format.py +0 -0
  24. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/logger.py +0 -0
  25. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/observe.py +0 -0
  26. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/providers/__init__.py +0 -0
  27. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/providers/openai.py +0 -0
  28. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/result.py +0 -0
  29. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/schema.py +0 -0
  30. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/serialize.py +0 -0
  31. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/testing.py +0 -0
  32. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/__init__.py +0 -0
  33. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/connectors.py +0 -0
  34. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/interpreter.py +0 -0
  35. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/planner.py +0 -0
  36. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/subagent.py +0 -0
  37. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/tools/variables.py +0 -0
  38. {data_harness-0.2.0 → data_harness-0.3.0}/data_harness/types.py +0 -0
  39. {data_harness-0.2.0 → data_harness-0.3.0}/examples/advanced_wiring.py +0 -0
  40. {data_harness-0.2.0 → data_harness-0.3.0}/examples/data/README.md +0 -0
  41. {data_harness-0.2.0 → data_harness-0.3.0}/examples/data/fred_unrate_2024.csv +0 -0
  42. {data_harness-0.2.0 → data_harness-0.3.0}/examples/inspect_run.py +0 -0
  43. {data_harness-0.2.0 → data_harness-0.3.0}/examples/quickstart.py +0 -0
  44. {data_harness-0.2.0 → data_harness-0.3.0}/tests/__init__.py +0 -0
  45. {data_harness-0.2.0 → data_harness-0.3.0}/tests/_smoke_dashboard.py +0 -0
  46. {data_harness-0.2.0 → data_harness-0.3.0}/tests/conftest.py +0 -0
  47. {data_harness-0.2.0 → data_harness-0.3.0}/tests/smoke_tests.py +0 -0
  48. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_agent.py +0 -0
  49. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_cache.py +0 -0
  50. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_docs.py +0 -0
  51. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_docs_phase5.py +0 -0
  52. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_format.py +0 -0
  53. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_gaps.py +0 -0
  54. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_integration.py +0 -0
  55. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_logger.py +0 -0
  56. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_loop.py +0 -0
  57. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_loop_reminders.py +0 -0
  58. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_observe.py +0 -0
  59. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_providers_openai.py +0 -0
  60. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_result.py +0 -0
  61. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_review_fixes.py +0 -0
  62. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_schema.py +0 -0
  63. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_serialize.py +0 -0
  64. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_session_inspection.py +0 -0
  65. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_testing.py +0 -0
  66. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_annotations.py +0 -0
  67. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_connectors.py +0 -0
  68. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_interpreter.py +0 -0
  69. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_planner.py +0 -0
  70. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_subagent.py +0 -0
  71. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_tool_variables.py +0 -0
  72. {data_harness-0.2.0 → data_harness-0.3.0}/tests/test_turn_summary.py +0 -0
  73. {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.2.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[str, None]:
389
- """Stream assistant tokens for a one-shot run.
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 chunk in agent.run_stream("hello"):
394
- print(chunk, end="", flush=True)
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 chunk in harness.run_stream(user_message):
399
- yield chunk
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[str, None]:
511
- """Stream assistant tokens for a follow-up turn."""
512
- async for chunk in self._harness.ask_stream(user_message):
513
- yield chunk
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[str, None]:
359
- """Stream assistant tokens for a one-shot run.
363
+ async def run_stream(self, user_message: str) -> AsyncGenerator[StreamEvent, None]:
364
+ """Stream events for a one-shot run.
360
365
 
361
- Yields text chunks as they arrive. Tool-use turns are dispatched
362
- internally; only final-answer text reaches the caller.
363
- The JSONL logger records fully assembled messages, not individual chunks.
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 chunk in self._run_loop_stream():
368
- yield chunk
374
+ async for event in self._run_loop_stream():
375
+ yield event
369
376
 
370
- async def ask_stream(self, user_message: str) -> AsyncGenerator[str, None]:
371
- """Stream assistant tokens for a follow-up turn in a session."""
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 chunk in self._run_loop_stream():
378
- yield chunk
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[str, None]:
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
- chunk_queue: asyncio.Queue[str | None] = asyncio.Queue()
485
-
486
- async def on_chunk(text: str) -> None:
487
- await chunk_queue.put(text)
491
+ events_this_turn: list[StreamEvent] = []
488
492
 
489
- async def do_stream() -> NormalizedResponse:
493
+ with time_block() as tb:
490
494
  try:
491
- return await self._adapter.stream(
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
- on_chunk=on_chunk,
496
- )
497
- finally:
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 Awaitable, Callable
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 stream(
163
+ async def stream_events(
160
164
  self,
161
165
  system: str,
162
166
  messages: list[Message],
163
167
  tools: list[ToolSpec],
164
- *,
165
- on_chunk: Callable[[str], Awaitable[None]],
166
- ) -> NormalizedResponse:
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 text in stream.text_stream:
179
- await on_chunk(text)
180
- final = await stream.get_final_message()
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
- return self._normalize_response(final)
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
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "data-harness"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Data agent with Python-native tools (no bash)"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }