framework-agents 0.1.1__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.1 → framework_agents-0.2.0}/PKG-INFO +1 -1
- {framework_agents-0.1.1 → framework_agents-0.2.0}/pyproject.toml +1 -1
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/agent.py +110 -33
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/models/anthropic.py +88 -64
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/models/openai_compatible.py +29 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_gaps.py +166 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/.gitignore +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/README.md +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/01-streaming-tools.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/02-orchestration.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/03-swarm.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/04-workflows.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/05-memory-sessions.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/06-hitl-resume.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/07-guardrails-budgets.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/08-semantic-graph.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/09-a2a-remote.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/10-middleware-suite.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/11-rag-retrieval.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/12-providers.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/13-serve-framework.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/examples/README.md +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/__init__.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/a2a_client.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/a2a_server.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/checkpoint.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/config.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/conversation.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/errors.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/evals.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/mcp_client.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/memory.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/memory_compaction.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/middleware.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/models/__init__.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/models/capabilities.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/models/ollama.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/observability.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/ontology_review.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/orchestration.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/pricing.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/prompts.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/rag.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/remote_sparql.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/runtime.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/sandbox.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/semantic.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/serve.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/sessions.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/skills.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/state.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/streaming.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/swarm.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/tools.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/tracing.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/types.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/src/framework_agents/workflows.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/conftest.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_agent_live_together.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_anthropic_offline.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_conversation_sessions.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_core_parity.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_cross_language.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_live_providers.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_middleware.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_middleware_extra.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_multi_and_evals.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_review_fixes.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_semantic_parity.py +0 -0
- {framework_agents-0.1.1 → framework_agents-0.2.0}/tests/test_serve.py +0 -0
- {framework_agents-0.1.1 → 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
|
|
@@ -35,7 +38,7 @@ from typing import Any, AsyncIterator, Callable, Literal, Optional, Type
|
|
|
35
38
|
from pydantic import BaseModel, ValidationError
|
|
36
39
|
|
|
37
40
|
from .checkpoint import PendingCall, ThreadCheckpoint
|
|
38
|
-
from .errors import AgentError
|
|
41
|
+
from .errors import AgentError, get_retryable, stamp_retryable
|
|
39
42
|
from .memory import InMemoryMemory, Memory
|
|
40
43
|
from .middleware import (
|
|
41
44
|
HITL_DECISIONS_KEY,
|
|
@@ -608,19 +611,25 @@ class Agent:
|
|
|
608
611
|
rt.events and rt.events.on_step and rt.events.on_step(step)
|
|
609
612
|
yield {"type": "step", "step": step}
|
|
610
613
|
|
|
611
|
-
#
|
|
612
|
-
#
|
|
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
|
+
)
|
|
613
623
|
pending: list[StreamEvent] = []
|
|
624
|
+
channel: asyncio.Queue = asyncio.Queue()
|
|
625
|
+
deltas_emitted = False
|
|
614
626
|
|
|
615
|
-
def
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if rt.events and rt.events.on_thinking:
|
|
622
|
-
rt.events.on_thinking(delta)
|
|
623
|
-
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)
|
|
624
633
|
|
|
625
634
|
response_format = None
|
|
626
635
|
if strategy == "provider" and fmt is not None:
|
|
@@ -632,15 +641,40 @@ class Agent:
|
|
|
632
641
|
ctx = HookContext(step=step, invocation_state=invocation_state)
|
|
633
642
|
|
|
634
643
|
async def _call_model(req: ModelRequest) -> Any:
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
+
)
|
|
642
673
|
)
|
|
643
|
-
|
|
674
|
+
except BaseException:
|
|
675
|
+
failed["v"] = True
|
|
676
|
+
del pending[start:]
|
|
677
|
+
raise
|
|
644
678
|
|
|
645
679
|
request = ModelRequest(
|
|
646
680
|
messages=messages,
|
|
@@ -652,18 +686,61 @@ class Agent:
|
|
|
652
686
|
)
|
|
653
687
|
if mw:
|
|
654
688
|
request = await run_before_model(mw, request, ctx)
|
|
655
|
-
|
|
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:
|
|
656
743
|
completion = await run_after_model(mw, completion, request, ctx)
|
|
657
|
-
else:
|
|
658
|
-
completion = await _call_model(request)
|
|
659
|
-
# A before_model hook may have REPLACED the message list for
|
|
660
|
-
# this call: replacements feed the model call only; history is
|
|
661
|
-
# preserved (TS parity). The loop's `messages` list stays
|
|
662
|
-
# authoritative for appends and the end-of-run save — adopting
|
|
663
|
-
# a shortened replacement here once destroyed long-term memory.
|
|
664
|
-
for event in pending:
|
|
665
|
-
if event.get("delta"):
|
|
666
|
-
yield event
|
|
667
744
|
|
|
668
745
|
usage = usage + completion.usage
|
|
669
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.1 → 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()
|
|
@@ -37,6 +37,8 @@ from framework_agents import ( # noqa: E402
|
|
|
37
37
|
assert_can_handle,
|
|
38
38
|
collect_stream,
|
|
39
39
|
define_tool,
|
|
40
|
+
get_retryable,
|
|
41
|
+
model_retry,
|
|
40
42
|
ollama,
|
|
41
43
|
supports_vision,
|
|
42
44
|
)
|
|
@@ -775,3 +777,167 @@ def test_stream_sync_iteration_script_and_running_loop():
|
|
|
775
777
|
|
|
776
778
|
kinds2 = asyncio.run(notebook_cell())
|
|
777
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.1 → 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
|