framework-agents 0.1.0__tar.gz → 0.2.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 (71) hide show
  1. {framework_agents-0.1.0 → framework_agents-0.2.0}/PKG-INFO +1 -1
  2. {framework_agents-0.1.0 → framework_agents-0.2.0}/pyproject.toml +1 -1
  3. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/agent.py +176 -33
  4. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/anthropic.py +88 -64
  5. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/openai_compatible.py +29 -0
  6. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_gaps.py +222 -1
  7. {framework_agents-0.1.0 → framework_agents-0.2.0}/.gitignore +0 -0
  8. {framework_agents-0.1.0 → framework_agents-0.2.0}/README.md +0 -0
  9. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/01-streaming-tools.py +0 -0
  10. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/02-orchestration.py +0 -0
  11. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/03-swarm.py +0 -0
  12. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/04-workflows.py +0 -0
  13. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/05-memory-sessions.py +0 -0
  14. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/06-hitl-resume.py +0 -0
  15. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/07-guardrails-budgets.py +0 -0
  16. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/08-semantic-graph.py +0 -0
  17. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/09-a2a-remote.py +0 -0
  18. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/10-middleware-suite.py +0 -0
  19. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/11-rag-retrieval.py +0 -0
  20. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/12-providers.py +0 -0
  21. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/13-serve-framework.py +0 -0
  22. {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/README.md +0 -0
  23. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/__init__.py +0 -0
  24. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/a2a_client.py +0 -0
  25. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/a2a_server.py +0 -0
  26. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/checkpoint.py +0 -0
  27. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/config.py +0 -0
  28. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/conversation.py +0 -0
  29. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/errors.py +0 -0
  30. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/evals.py +0 -0
  31. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/mcp_client.py +0 -0
  32. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/memory.py +0 -0
  33. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/memory_compaction.py +0 -0
  34. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/middleware.py +0 -0
  35. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/__init__.py +0 -0
  36. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/capabilities.py +0 -0
  37. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/ollama.py +0 -0
  38. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/observability.py +0 -0
  39. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/ontology_review.py +0 -0
  40. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/orchestration.py +0 -0
  41. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/pricing.py +0 -0
  42. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/prompts.py +0 -0
  43. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/rag.py +0 -0
  44. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/remote_sparql.py +0 -0
  45. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/runtime.py +0 -0
  46. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/sandbox.py +0 -0
  47. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/semantic.py +0 -0
  48. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/serve.py +0 -0
  49. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/sessions.py +0 -0
  50. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/skills.py +0 -0
  51. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/state.py +0 -0
  52. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/streaming.py +0 -0
  53. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/swarm.py +0 -0
  54. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/tools.py +0 -0
  55. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/tracing.py +0 -0
  56. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/types.py +0 -0
  57. {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/workflows.py +0 -0
  58. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/conftest.py +0 -0
  59. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_agent_live_together.py +0 -0
  60. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_anthropic_offline.py +0 -0
  61. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_conversation_sessions.py +0 -0
  62. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_core_parity.py +0 -0
  63. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_cross_language.py +0 -0
  64. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_live_providers.py +0 -0
  65. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_middleware.py +0 -0
  66. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_middleware_extra.py +0 -0
  67. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_multi_and_evals.py +0 -0
  68. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_review_fixes.py +0 -0
  69. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_semantic_parity.py +0 -0
  70. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_serve.py +0 -0
  71. {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_servers_and_clients.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: framework-agents
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python agent framework: ReAct loop, tools, memory, streaming, budgets, middleware, multi-agent, semantic graph, A2A serving - parity with the TypeScript package
5
5
  Project-URL: Homepage, https://github.com/fab679/framework
6
6
  Project-URL: Documentation, https://fab679.github.io/framework/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "framework-agents"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Python agent framework: ReAct loop, tools, memory, streaming, budgets, middleware, multi-agent, semantic graph, A2A serving - parity with the TypeScript package"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -12,8 +12,11 @@ from the FIXED versions):
12
12
  ends on a dangling tool-call tail).
13
13
  - Unknown tools / invalid args become model-visible feedback; loop continues.
14
14
  - result.usage.cost_usd set only when a price is known — never guessed.
15
- - Stream parity: token/thinking deltas batch per model call (as in TS), tool
16
- events yield live, budget errors pass through unretried.
15
+ - Stream parity: token/thinking deltas stream LIVE during the model call when
16
+ no middleware declares wrap_model/after_model; batched fallback (retry
17
+ compat) otherwise — tool events yield live, budget errors pass through
18
+ unretried. Providers stamp post-delta failures non-retryable so retries
19
+ never duplicate streamed text.
17
20
  - Per-stream results: AgentStream captures its OWN AgentResult at the done
18
21
  event; concurrent streams on one agent never cross-contaminate.
19
22
  - beforeModel REPLACEMENTS feed the model call only: the loop's message list
@@ -22,8 +25,11 @@ from the FIXED versions):
22
25
 
23
26
  from __future__ import annotations
24
27
 
28
+ import asyncio
25
29
  import json
30
+ import queue
26
31
  import secrets
32
+ import threading
27
33
  import time
28
34
  from dataclasses import dataclass, field
29
35
  from datetime import datetime, timezone
@@ -32,7 +38,7 @@ from typing import Any, AsyncIterator, Callable, Literal, Optional, Type
32
38
  from pydantic import BaseModel, ValidationError
33
39
 
34
40
  from .checkpoint import PendingCall, ThreadCheckpoint
35
- from .errors import AgentError
41
+ from .errors import AgentError, get_retryable, stamp_retryable
36
42
  from .memory import InMemoryMemory, Memory
37
43
  from .middleware import (
38
44
  HITL_DECISIONS_KEY,
@@ -409,6 +415,69 @@ class Agent:
409
415
  raise AgentError("RUN_INCOMPLETE", "Stream drained without a result", retryable=False)
410
416
  return result
411
417
 
418
+ def run_sync(self, input_text: str, **kwargs: Any) -> AgentResult:
419
+ """Blocking run() for scripts AND notebooks.
420
+
421
+ - Plain script (no event loop running): asyncio.run directly.
422
+ - Notebook / running loop: private loop on a worker thread —
423
+ asyncio.run() throws inside Jupyter's loop and nested loops cannot
424
+ be started either.
425
+
426
+ Do NOT call from `async def` code — there, `await agent.run(...)`.
427
+ """
428
+ try:
429
+ asyncio.get_running_loop()
430
+ except RuntimeError:
431
+ return asyncio.run(self.run(input_text, **kwargs))
432
+
433
+ box: dict[str, Any] = {"result": None, "err": None}
434
+
435
+ def _worker() -> None:
436
+ try:
437
+ box["result"] = asyncio.run(self.run(input_text, **kwargs))
438
+ except BaseException as err: # noqa: BLE001 - re-raised in caller
439
+ box["err"] = err
440
+
441
+ threading.Thread(target=_worker, daemon=True).start()
442
+ while box["result"] is None and box["err"] is None:
443
+ time.sleep(0.02)
444
+ if box["err"] is not None:
445
+ raise box["err"]
446
+ return box["result"] # type: ignore[no-any-return]
447
+
448
+ def stream_sync(self, input_text: str, **kwargs: Any) -> Generator[StreamEvent, None, None]:
449
+ """Blocking stream() — same loop handling as run_sync. Stopping the
450
+ iteration early stops pulling events; the run itself completes on the
451
+ worker and its result lands on self._last_result."""
452
+ try:
453
+ asyncio.get_running_loop()
454
+ except RuntimeError:
455
+ running = True
456
+ else:
457
+ running = False
458
+ q: "queue.Queue[StreamEvent | BaseException | None]" = queue.Queue()
459
+
460
+ async def _pump() -> None:
461
+ try:
462
+ async for event in self.stream(input_text, **kwargs):
463
+ q.put(event)
464
+ q.put(None)
465
+ except BaseException as err: # noqa: BLE001 - re-raised in caller
466
+ q.put(err)
467
+
468
+ if running:
469
+ threading.Thread(target=lambda: asyncio.run(_pump()), daemon=True).start()
470
+ else:
471
+ threading.Thread(target=lambda: asyncio.run(_pump()), daemon=True).start()
472
+
473
+ while True:
474
+ item = q.get()
475
+ if item is None:
476
+ return
477
+ if isinstance(item, BaseException):
478
+ raise item
479
+ yield item
480
+
412
481
  def stream(
413
482
  self,
414
483
  input_text: str | list[Any],
@@ -542,19 +611,25 @@ class Agent:
542
611
  rt.events and rt.events.on_step and rt.events.on_step(step)
543
612
  yield {"type": "step", "step": step}
544
613
 
545
- # Token/thinking deltas batch per model call (TS parity) while the
546
- # events hooks fire live inside provider.complete.
614
+ # Live delta streaming (parity: TS runLoop): when no middleware
615
+ # declares wrap_model (retrying an attempt whose deltas were
616
+ # already delivered would duplicate streamed text) or
617
+ # after_model (output scrubbing must run BEFORE tokens reach
618
+ # the UI), deltas stream LIVE via a queue while the model call
619
+ # is still running; the batched fallback buffers until it settles.
620
+ needs_batching = any(
621
+ getattr(m, "wrap_model", None) or getattr(m, "after_model", None) for m in mw
622
+ )
547
623
  pending: list[StreamEvent] = []
624
+ channel: asyncio.Queue = asyncio.Queue()
625
+ deltas_emitted = False
548
626
 
549
- def on_token(delta: str) -> None:
550
- if rt.events and rt.events.on_token:
551
- rt.events.on_token(delta)
552
- pending.append({"type": "token", "delta": delta})
553
-
554
- def on_thinking(delta: str) -> None:
555
- if rt.events and rt.events.on_thinking:
556
- rt.events.on_thinking(delta)
557
- pending.append({"type": "thinking", "delta": delta})
627
+ def emit_delta(kind: str, delta: str) -> None:
628
+ event = {"type": kind, "delta": delta}
629
+ if needs_batching:
630
+ pending.append(event)
631
+ else:
632
+ channel.put_nowait(event)
558
633
 
559
634
  response_format = None
560
635
  if strategy == "provider" and fmt is not None:
@@ -566,15 +641,40 @@ class Agent:
566
641
  ctx = HookContext(step=step, invocation_state=invocation_state)
567
642
 
568
643
  async def _call_model(req: ModelRequest) -> Any:
569
- return await rt.model.complete(
570
- CompleteOptions(
571
- messages=req.messages,
572
- tools=req.tools,
573
- response_format=req.response_format,
574
- on_token=on_token,
575
- on_thinking=on_thinking,
644
+ # One closure per attempt (the wrap chain re-invokes
645
+ # _call_model on every retry): deltas buffered by a FAILED
646
+ # attempt never flush — the retried call replaces them.
647
+ # Live path: deltas went onto the channel and were already
648
+ # delivered, so there is nothing to roll back by design.
649
+ start = len(pending)
650
+ failed = {"v": False}
651
+
652
+ def attempt_on_token(delta: str) -> None:
653
+ if rt.events and rt.events.on_token:
654
+ rt.events.on_token(delta)
655
+ if not failed["v"]:
656
+ emit_delta("token", delta)
657
+
658
+ def attempt_on_thinking(delta: str) -> None:
659
+ if rt.events and rt.events.on_thinking:
660
+ rt.events.on_thinking(delta)
661
+ if not failed["v"]:
662
+ emit_delta("thinking", delta)
663
+
664
+ try:
665
+ return await rt.model.complete(
666
+ CompleteOptions(
667
+ messages=req.messages,
668
+ tools=req.tools,
669
+ response_format=req.response_format,
670
+ on_token=attempt_on_token,
671
+ on_thinking=attempt_on_thinking,
672
+ )
576
673
  )
577
- )
674
+ except BaseException:
675
+ failed["v"] = True
676
+ del pending[start:]
677
+ raise
578
678
 
579
679
  request = ModelRequest(
580
680
  messages=messages,
@@ -586,18 +686,61 @@ class Agent:
586
686
  )
587
687
  if mw:
588
688
  request = await run_before_model(mw, request, ctx)
589
- completion = await compose_model_call(mw, _call_model, ctx)(request)
689
+ call = compose_model_call(mw, _call_model, ctx) if mw else _call_model
690
+
691
+ async def _call_and_close(req: ModelRequest) -> Any:
692
+ # Live path: the None sentinel must arrive AFTER every
693
+ # delta the attempt pushed — push it when the call settles
694
+ # (success or failure).
695
+ try:
696
+ return await call(req)
697
+ finally:
698
+ channel.put_nowait(None)
699
+
700
+ try:
701
+ if needs_batching:
702
+ completion = await call(request)
703
+ # A before_model hook may have REPLACED the message list for
704
+ # this call: replacements feed the model call only; history is
705
+ # preserved (TS parity). The loop's `messages` list stays
706
+ # authoritative for appends and the end-of-run save — adopting
707
+ # a shortened replacement here once destroyed long-term memory.
708
+ for event in pending:
709
+ if event.get("delta"):
710
+ yield event
711
+ else:
712
+ # LIVE: drain the queue concurrently with the running
713
+ # model call, yielding deltas as they arrive. If the
714
+ # run is torn down mid-call (abort/disconnect, the
715
+ # consumer walking away), the in-flight provider task
716
+ # is cancelled with it — deltas it would push are
717
+ # discarded by design.
718
+ task = asyncio.ensure_future(_call_and_close(request))
719
+ try:
720
+ while True:
721
+ event = await channel.get()
722
+ if event is None:
723
+ break
724
+ deltas_emitted = True
725
+ if event.get("delta"):
726
+ yield event
727
+ completion = await task
728
+ finally:
729
+ if not task.done():
730
+ task.cancel()
731
+ except Exception as err:
732
+ # Belt-and-braces for the batched path: nothing buffered
733
+ # from failed attempts may ever flush. A LIVE-path failure
734
+ # after deltas were delivered must not be retried (a retry
735
+ # would duplicate streamed text) — our providers stamp
736
+ # that (delivered-verdict); foreign providers without any
737
+ # verdict get it stamped here.
738
+ del pending[:]
739
+ if deltas_emitted and get_retryable(err) is None:
740
+ stamp_retryable(err, False)
741
+ raise
742
+ if mw:
590
743
  completion = await run_after_model(mw, completion, request, ctx)
591
- else:
592
- completion = await _call_model(request)
593
- # A before_model hook may have REPLACED the message list for
594
- # this call: replacements feed the model call only; history is
595
- # preserved (TS parity). The loop's `messages` list stays
596
- # authoritative for appends and the end-of-run save — adopting
597
- # a shortened replacement here once destroyed long-term memory.
598
- for event in pending:
599
- if event.get("delta"):
600
- yield event
601
744
 
602
745
  usage = usage + completion.usage
603
746
  self._check_budgets(rt.budgets, usage, started, rt.model.id)
@@ -237,70 +237,94 @@ class AnthropicProvider:
237
237
  json_input = ""
238
238
  buffer = ""
239
239
  event = ""
240
- async for chunk in res.aiter_text():
241
- buffer += chunk
242
- lines = buffer.split("\n")
243
- buffer = lines.pop()
244
- for line in lines:
245
- stripped = line.strip()
246
- if not stripped:
247
- continue
248
- if stripped.startswith("event:"):
249
- # CRITICAL: the event name must carry across chunk
250
- # boundaries — re-initializing per chunk silently drops
251
- # every delta whose event line landed in a prior packet.
252
- event = stripped[6:].strip()
253
- continue
254
- if not stripped.startswith("data:"):
255
- continue
256
- payload = stripped[5:].strip()
257
- if not payload:
258
- continue
259
- try:
260
- data = json.loads(payload)
261
- except ValueError:
262
- continue
263
- if event == "message_start":
264
- # message_start carries the input-side usage up front.
265
- message = data.get("message") or {}
266
- usage = message.get("usage") or {}
267
- if usage.get("input_tokens") is not None:
268
- input_tokens = usage["input_tokens"]
269
- elif event == "message_delta":
270
- # message_delta.usage.output_tokens is cumulative — assign.
271
- usage = data.get("usage") or {}
272
- if usage.get("output_tokens") is not None:
273
- output_tokens = usage["output_tokens"]
274
- delta = data.get("delta") or {}
275
- if delta.get("stop_reason"):
276
- stop_reason = _normalize_stop(delta["stop_reason"])
277
- elif event == "content_block_start":
278
- block = data.get("content_block") or {}
279
- current = {"type": block.get("type"), "id": block.get("id"), "name": block.get("name")}
280
- json_input = ""
281
- elif event == "content_block_delta":
282
- delta = data.get("delta") or {}
283
- if delta.get("text"):
284
- text += delta["text"]
285
- current = current or {"type": "text"}
286
- current["text"] = (current.get("text") or "") + delta["text"]
287
- options.on_token and options.on_token(delta["text"])
288
- elif delta.get("thinking"):
289
- thinking += delta["thinking"]
290
- current = current or {"type": "thinking"}
291
- current["thinking"] = (current.get("thinking") or "") + delta["thinking"]
292
- options.on_thinking and options.on_thinking(delta["thinking"])
293
- elif delta.get("partial_json"):
294
- json_input += delta["partial_json"]
295
- elif event == "content_block_stop":
296
- if current is not None:
297
- if current.get("type") == "tool_use":
298
- try:
299
- current["input"] = json.loads(json_input or "{}")
300
- except ValueError:
301
- current["input"] = {}
302
- blocks.append(current)
303
- current = None
240
+ # Retry-verdict rule: once a delta (text/thinking/tool fragment)
241
+ # reached the caller, a mid-stream failure is NON-retryable (re-running
242
+ # would duplicate already-streamed text); before any delta, failures
243
+ # stay retryable (nothing was delivered).
244
+ delivered = False
245
+ try:
246
+ async for chunk in res.aiter_text():
247
+ buffer += chunk
248
+ lines = buffer.split("\n")
249
+ buffer = lines.pop()
250
+ for line in lines:
251
+ stripped = line.strip()
252
+ if not stripped:
253
+ continue
254
+ if stripped.startswith("event:"):
255
+ # CRITICAL: the event name must carry across chunk
256
+ # boundaries — re-initializing per chunk silently drops
257
+ # every delta whose event line landed in a prior packet.
258
+ event = stripped[6:].strip()
259
+ continue
260
+ if not stripped.startswith("data:"):
261
+ continue
262
+ payload = stripped[5:].strip()
263
+ if not payload:
264
+ continue
265
+ try:
266
+ data = json.loads(payload)
267
+ except ValueError:
268
+ continue
269
+ if event == "message_start":
270
+ # message_start carries the input-side usage up front.
271
+ message = data.get("message") or {}
272
+ usage = message.get("usage") or {}
273
+ if usage.get("input_tokens") is not None:
274
+ input_tokens = usage["input_tokens"]
275
+ elif event == "message_delta":
276
+ # message_delta.usage.output_tokens is cumulative — assign.
277
+ usage = data.get("usage") or {}
278
+ if usage.get("output_tokens") is not None:
279
+ output_tokens = usage["output_tokens"]
280
+ delta = data.get("delta") or {}
281
+ if delta.get("stop_reason"):
282
+ stop_reason = _normalize_stop(delta["stop_reason"])
283
+ elif event == "content_block_start":
284
+ block = data.get("content_block") or {}
285
+ current = {"type": block.get("type"), "id": block.get("id"), "name": block.get("name")}
286
+ json_input = ""
287
+ elif event == "content_block_delta":
288
+ delta = data.get("delta") or {}
289
+ if delta.get("text"):
290
+ text += delta["text"]
291
+ delivered = True
292
+ current = current or {"type": "text"}
293
+ current["text"] = (current.get("text") or "") + delta["text"]
294
+ options.on_token and options.on_token(delta["text"])
295
+ elif delta.get("thinking"):
296
+ thinking += delta["thinking"]
297
+ # Thinking deltas reach the caller like text deltas
298
+ # do — the delivered verdict covers them (and the
299
+ # tool fragment below).
300
+ delivered = True
301
+ current = current or {"type": "thinking"}
302
+ current["thinking"] = (current.get("thinking") or "") + delta["thinking"]
303
+ options.on_thinking and options.on_thinking(delta["thinking"])
304
+ elif delta.get("partial_json"):
305
+ # Tool-call fragments are delivered state too: a
306
+ # retry would re-issue a call mid-assembly, so the
307
+ # attempt counts as spent.
308
+ delivered = True
309
+ json_input += delta["partial_json"]
310
+ elif event == "content_block_stop":
311
+ if current is not None:
312
+ if current.get("type") == "tool_use":
313
+ try:
314
+ current["input"] = json.loads(json_input or "{}")
315
+ except ValueError:
316
+ current["input"] = {}
317
+ blocks.append(current)
318
+ current = None
319
+ except Exception as err:
320
+ # Delivered-aware verdict: deltas already streamed => non-retryable
321
+ # (a retry would duplicate the delivered text); before any delta
322
+ # the failure stays retryable.
323
+ raise AgentError(
324
+ "MODEL_ERROR",
325
+ f"Stream broke mid-response{' (deltas already delivered)' if delivered else ''}: {err}",
326
+ retryable=not delivered,
327
+ ) from err
304
328
  if current is not None:
305
329
  blocks.append(current)
306
330
 
@@ -260,6 +260,12 @@ class OpenAICompatibleProvider:
260
260
  finish_reason: str | None = None
261
261
  tool_buffers: dict[int, dict[str, str]] = {}
262
262
 
263
+ # Retry-verdict rule: once a delta (content/thinking/tool fragment)
264
+ # reached the caller, a mid-stream failure is NON-retryable (re-running
265
+ # would duplicate already-streamed text); before any delta, failures
266
+ # stay retryable (nothing was delivered).
267
+ delivered = False
268
+
263
269
  # A line can SPLIT across TCP reads: the first fragment would fail
264
270
  # json.loads and the continuation would not start with "data:" — both
265
271
  # silently dropped. Accumulate a buffer and only parse COMPLETE
@@ -307,12 +313,21 @@ class OpenAICompatibleProvider:
307
313
  think = _thinking_of(delta)
308
314
  if think:
309
315
  thinking += think
316
+ # Thinking deltas reach the caller like token deltas
317
+ # do — the delivered verdict covers them (and tool
318
+ # fragments below).
319
+ delivered = True
310
320
  options.on_thinking and options.on_thinking(think)
311
321
  if delta.get("content"):
312
322
  text += delta["content"]
323
+ delivered = True
313
324
  options.on_token and options.on_token(delta["content"])
314
325
  for i, tc in enumerate(delta.get("tool_calls") or []):
315
326
  key = tc.get("index", i)
327
+ # Tool-call fragments are delivered state too: a retry
328
+ # would re-issue a call mid-assembly, so treat the
329
+ # attempt as spent.
330
+ delivered = True
316
331
  slot = tool_buffers.setdefault(key, {"id": "", "name": "", "args": ""})
317
332
  if tc.get("id"):
318
333
  slot["id"] = tc["id"]
@@ -321,6 +336,20 @@ class OpenAICompatibleProvider:
321
336
  slot["name"] = fn["name"]
322
337
  if fn.get("arguments"):
323
338
  slot["args"] += fn["arguments"]
339
+ except Exception as err:
340
+ # The run's own signal fired mid-request (abort): report ABORTED,
341
+ # never a stream error.
342
+ probe = getattr(options.signal, "is_set", None)
343
+ if callable(probe) and probe():
344
+ raise AgentError("ABORTED", "Run aborted", retryable=False) from err
345
+ # Delivered-aware verdict: deltas already streamed => non-retryable
346
+ # (a retry would duplicate the delivered text); before any delta
347
+ # the failure stays retryable.
348
+ raise AgentError(
349
+ "MODEL_ERROR",
350
+ f"Stream broke mid-response{' (deltas already delivered)' if delivered else ''}: {err}",
351
+ retryable=not delivered,
352
+ ) from err
324
353
  finally:
325
354
  if watchdog is not None:
326
355
  watchdog.cancel()
@@ -8,6 +8,7 @@ a2a_as_tool, ollama(), provider_from_env.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import asyncio
11
12
  import json
12
13
  import pathlib
13
14
  import sys
@@ -36,6 +37,8 @@ from framework_agents import ( # noqa: E402
36
37
  assert_can_handle,
37
38
  collect_stream,
38
39
  define_tool,
40
+ get_retryable,
41
+ model_retry,
39
42
  ollama,
40
43
  supports_vision,
41
44
  )
@@ -719,4 +722,222 @@ async def test_provider_from_env_shape():
719
722
  assert extra.opts.max_tokens == 512
720
723
 
721
724
  with pytest.raises(ValueError):
722
- provider_from_env({"baseUrl": "https://api.acme.test/v1", "model": "m"})
725
+ provider_from_env({"baseUrl": "https://api.acme.test/v1", "model": "m"})
726
+ # ---------------------------------------------------------------------------
727
+ # run_sync / stream_sync (notebook + script ergonomics)
728
+ # ---------------------------------------------------------------------------
729
+
730
+ class _OnceStub:
731
+ def __init__(self, text: str) -> None:
732
+ self.text = text
733
+
734
+ id = "stub"
735
+ capabilities = {"structured_output": False, "streaming": False}
736
+
737
+ async def complete(self, options: CompleteOptions) -> ModelCompletion:
738
+ return ModelCompletion(
739
+ text=self.text,
740
+ tool_calls=[],
741
+ usage=TokenUsage(input_tokens=1, output_tokens=1),
742
+ )
743
+
744
+
745
+ def test_run_sync_script_context():
746
+ from framework_agents import Agent, AgentOptions
747
+
748
+ agent = Agent(AgentOptions(model=_OnceStub("sync-ok")))
749
+ result = agent.run_sync("hi")
750
+ assert result.text == "sync-ok"
751
+
752
+
753
+ def test_run_sync_inside_running_loop_notebook_context():
754
+ """The Jupyter pattern: run_sync is CALLED while a loop is running. It
755
+ must delegate to a private loop on a worker instead of raising."""
756
+
757
+ async def notebook_cell() -> AgentResult:
758
+ agent = Agent(AgentOptions(model=_OnceStub("notebook-ok")))
759
+ # This coroutine runs ON the loop — exactly what a notebook cell is.
760
+ return agent.run_sync("hi")
761
+
762
+ result = asyncio.run(notebook_cell())
763
+ assert result.text == "notebook-ok"
764
+
765
+
766
+ def test_stream_sync_iteration_script_and_running_loop():
767
+ from framework_agents import Agent, AgentOptions
768
+
769
+ agent1 = Agent(AgentOptions(model=_OnceStub("e1")))
770
+ kinds1 = [e["type"] for e in agent1.stream_sync("hi")]
771
+ assert "done" in kinds1
772
+
773
+ async def notebook_cell() -> list[str]:
774
+ agent2 = Agent(AgentOptions(model=_OnceStub("e2")))
775
+ # Called ON a running loop (notebook): stream_sync must still work.
776
+ return [e["type"] for e in agent2.stream_sync("hi")]
777
+
778
+ kinds2 = asyncio.run(notebook_cell())
779
+ assert "done" in kinds2
780
+
781
+
782
+ # ---------------------------------------------------------------------------
783
+ # Live delta streaming (Path B, TS parity): deltas stream LIVE during the
784
+ # model call when no middleware declares wrap_model/after_model; batched
785
+ # fallback otherwise. Deltas from FAILED attempts never leak into the stream
786
+ # (delivered-verdict contract: our providers stamp post-delta failures
787
+ # non-retryable so retries never duplicate streamed text).
788
+ # ---------------------------------------------------------------------------
789
+
790
+
791
+ class _ScriptedStreamProvider:
792
+ """Runs a scripted behavior per attempt; records how many attempts ran."""
793
+
794
+ id = "scripted-stream"
795
+ capabilities = {"structured_output": False, "streaming": True}
796
+
797
+ def __init__(self, *behaviors: Any) -> None:
798
+ self._behaviors = list(behaviors)
799
+ self.calls = 0
800
+
801
+ async def complete(self, options: CompleteOptions) -> ModelCompletion:
802
+ self.calls += 1
803
+ behavior = self._behaviors[min(self.calls - 1, len(self._behaviors) - 1)]
804
+ outcome = behavior(options)
805
+ if asyncio.iscoroutine(outcome):
806
+ return await outcome
807
+ return outcome
808
+
809
+
810
+ def _clean_completion(options: CompleteOptions, text: str) -> ModelCompletion:
811
+ options.on_token(text)
812
+ return ModelCompletion(
813
+ text=text,
814
+ tool_calls=[],
815
+ usage=TokenUsage(input_tokens=1, output_tokens=len(text)),
816
+ )
817
+
818
+
819
+ async def _drain(agent) -> tuple[list[str], str | None, Any]:
820
+ tokens: list[str] = []
821
+ done_text: str | None = None
822
+ error: Any = None
823
+ try:
824
+ async for event in agent.stream("q"):
825
+ if event["type"] == "token":
826
+ tokens.append(event["delta"])
827
+ elif event["type"] == "done":
828
+ done_text = event.get("text")
829
+ except BaseException as err: # noqa: BLE001 - the contract under test
830
+ error = err
831
+ return tokens, done_text, error
832
+
833
+
834
+ @pytest.mark.asyncio
835
+ async def test_first_delta_yields_while_provider_call_is_in_flight():
836
+ finished = [False]
837
+
838
+ def early(options: CompleteOptions) -> Any:
839
+ options.on_token("early")
840
+ return _late(options)
841
+
842
+ async def _late(options: CompleteOptions) -> ModelCompletion:
843
+ await asyncio.sleep(0.05)
844
+ options.on_token("-late")
845
+ finished[0] = True
846
+ return ModelCompletion(
847
+ text="early-late",
848
+ tool_calls=[],
849
+ usage=TokenUsage(input_tokens=1, output_tokens=3),
850
+ )
851
+
852
+ provider = _ScriptedStreamProvider(early) # live path: no middleware
853
+ agent = Agent(AgentOptions(model=provider))
854
+ seen: list[tuple[str, bool]] = []
855
+ done_text: str | None = None
856
+ async for event in agent.stream("q"):
857
+ if event["type"] == "token":
858
+ # The flag is read AT yield time: "early" must arrive while the
859
+ # provider call is still running (its await has not resolved).
860
+ seen.append((event["delta"], finished[0]))
861
+ elif event["type"] == "done":
862
+ done_text = event.get("text")
863
+ assert seen == [("early", False), ("-late", True)]
864
+ assert done_text == "early-late"
865
+
866
+
867
+ @pytest.mark.asyncio
868
+ async def test_post_delta_failure_never_leaks_and_is_not_retried():
869
+ def attempt1(options: CompleteOptions) -> Any:
870
+ options.on_token("poison")
871
+ raise AgentError("MODEL_ERROR", "stream broke after deltas", retryable=False)
872
+
873
+ provider = _ScriptedStreamProvider(
874
+ attempt1,
875
+ lambda options: _clean_completion(options, "clean"),
876
+ )
877
+ agent = Agent(
878
+ AgentOptions(model=provider, middleware=[model_retry(max_retries=2, initial_delay_ms=0, jitter=False)])
879
+ )
880
+ tokens, _, error = await _drain(agent)
881
+ # The delivered-verdict stamps post-delta failures non-retryable: NO
882
+ # retry (a retry would duplicate the delivered text), nothing leaks.
883
+ assert isinstance(error, AgentError)
884
+ assert error.retryable is False
885
+ assert tokens == []
886
+ assert provider.calls == 1
887
+
888
+
889
+ @pytest.mark.asyncio
890
+ async def test_pre_delta_failure_is_retried_and_second_attempt_streams_cleanly():
891
+ def attempt1(options: CompleteOptions) -> Any:
892
+ # BEFORE any on_token: nothing was delivered, so a retry is safe.
893
+ raise AgentError("MODEL_ERROR", "connection refused", retryable=True)
894
+
895
+ provider = _ScriptedStreamProvider(
896
+ attempt1,
897
+ lambda options: _clean_completion(options, "clean"),
898
+ )
899
+ agent = Agent(
900
+ AgentOptions(model=provider, middleware=[model_retry(max_retries=2, initial_delay_ms=0, jitter=False)])
901
+ )
902
+ tokens, done_text, error = await _drain(agent)
903
+ assert error is None
904
+ assert tokens == ["clean"]
905
+ assert done_text == "clean"
906
+ assert provider.calls == 2
907
+
908
+
909
+ @pytest.mark.asyncio
910
+ async def test_batched_deltas_of_failed_attempt_roll_back_even_when_retryable():
911
+ def attempt1(options: CompleteOptions) -> Any:
912
+ options.on_token("poison")
913
+ raise AgentError("MODEL_ERROR", "mid-stream reset", retryable=True)
914
+
915
+ provider = _ScriptedStreamProvider(
916
+ attempt1,
917
+ lambda options: _clean_completion(options, "clean"),
918
+ )
919
+ agent = Agent(
920
+ AgentOptions(model=provider, middleware=[model_retry(max_retries=2, initial_delay_ms=0, jitter=False)])
921
+ )
922
+ tokens, done_text, error = await _drain(agent)
923
+ assert error is None
924
+ # The failed attempt's buffered delta never flushes; the retried call's
925
+ # output replaces it.
926
+ assert tokens == ["clean"]
927
+ assert done_text == "clean"
928
+ assert provider.calls == 2
929
+
930
+
931
+ @pytest.mark.asyncio
932
+ async def test_live_path_failure_after_deltas_gets_stamped_non_retryable():
933
+ def attempt1(options: CompleteOptions) -> Any:
934
+ options.on_token("part")
935
+ raise ValueError("socket exploded") # foreign provider: no verdict
936
+
937
+ provider = _ScriptedStreamProvider(attempt1) # live path: no middleware
938
+ agent = Agent(AgentOptions(model=provider))
939
+ tokens, _, error = await _drain(agent)
940
+ # Deltas were already delivered live (by design — they cannot be
941
+ # unsent); the loop stamps the verdictless error non-retryable.
942
+ assert tokens == ["part"]
943
+ assert get_retryable(error) is False