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.
- {framework_agents-0.1.0 → framework_agents-0.2.0}/PKG-INFO +1 -1
- {framework_agents-0.1.0 → framework_agents-0.2.0}/pyproject.toml +1 -1
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/agent.py +176 -33
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/anthropic.py +88 -64
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/openai_compatible.py +29 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_gaps.py +222 -1
- {framework_agents-0.1.0 → framework_agents-0.2.0}/.gitignore +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/README.md +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/01-streaming-tools.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/02-orchestration.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/03-swarm.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/04-workflows.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/05-memory-sessions.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/06-hitl-resume.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/07-guardrails-budgets.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/08-semantic-graph.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/09-a2a-remote.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/10-middleware-suite.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/11-rag-retrieval.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/12-providers.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/13-serve-framework.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/examples/README.md +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/__init__.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/a2a_client.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/a2a_server.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/checkpoint.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/config.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/conversation.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/errors.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/evals.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/mcp_client.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/memory.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/memory_compaction.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/middleware.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/__init__.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/capabilities.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/ollama.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/observability.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/ontology_review.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/orchestration.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/pricing.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/prompts.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/rag.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/remote_sparql.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/runtime.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/sandbox.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/semantic.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/serve.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/sessions.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/skills.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/state.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/streaming.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/swarm.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/tools.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/tracing.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/types.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/workflows.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/conftest.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_agent_live_together.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_anthropic_offline.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_conversation_sessions.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_core_parity.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_cross_language.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_live_providers.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_middleware.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_middleware_extra.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_multi_and_evals.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_review_fixes.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_semantic_parity.py +0 -0
- {framework_agents-0.1.0 → framework_agents-0.2.0}/tests/test_serve.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
16
|
-
|
|
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
|
-
#
|
|
546
|
-
#
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
current =
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
{framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/openai_compatible.py
RENAMED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{framework_agents-0.1.0 → framework_agents-0.2.0}/src/framework_agents/models/capabilities.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|