loopgain 0.1.4__tar.gz → 0.1.7__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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loopgain
3
- Version: 0.1.4
3
+ Version: 0.1.7
4
4
  Summary: Barkhausen stability monitor for AI agent loops. Real-time loop-gain (Aβ) monitoring with five named threshold bands, best-so-far rollback, and ETA prediction.
5
- Author-email: Dave Fitzsimmons <dave_fitz@icloud.com>
5
+ Author-email: Dave Fitzsimmons <hello@loopgain.ai>
6
6
  License: Apache-2.0
7
7
  Project-URL: Homepage, https://loopgain.ai
8
8
  Project-URL: Repository, https://github.com/loopgain-ai/loopgain
@@ -24,6 +24,16 @@ Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
25
  Provides-Extra: test
26
26
  Requires-Dist: pytest>=7.0; extra == "test"
27
+ Provides-Extra: langgraph
28
+ Requires-Dist: langgraph>=0.2; extra == "langgraph"
29
+ Provides-Extra: crewai
30
+ Requires-Dist: crewai>=0.30; extra == "crewai"
31
+ Provides-Extra: autogen
32
+ Requires-Dist: autogen-agentchat>=0.4; extra == "autogen"
33
+ Provides-Extra: all
34
+ Requires-Dist: langgraph>=0.2; extra == "all"
35
+ Requires-Dist: crewai>=0.30; extra == "all"
36
+ Requires-Dist: autogen-agentchat>=0.4; extra == "all"
27
37
  Dynamic: license-file
28
38
 
29
39
  # LoopGain
@@ -87,7 +97,14 @@ print(result.savings_vs_fixed_cap)
87
97
 
88
98
  ## How it works
89
99
 
90
- LoopGain measures empirical loop gain `Aβ = E(n) / E(n-1)` at every iteration. It smooths with a configurable EMA and classifies the result into five named bands:
100
+ LoopGain measures empirical loop gain at every iteration, then smooths it with an EMA:
101
+
102
+ ```
103
+ Aβ(n) = E(n) / E(n-1)
104
+ Aβ_smooth = EMA(Aβ, w=3)
105
+ ```
106
+
107
+ It classifies `Aβ_smooth` into five named bands:
91
108
 
92
109
  | `Aβ_smooth` range | State | Action |
93
110
  | --- | --- | --- |
@@ -205,6 +222,107 @@ The hosted endpoint at `telemetry.loopgain.ai` is one acceptable destination. Th
205
222
 
206
223
  ---
207
224
 
225
+ ## Framework adapters
226
+
227
+ Thin wrappers under `loopgain.integrations` drive each major agent framework's iteration with a `LoopGain` monitor and auto-stamp `framework="<name>"` on telemetry. The frameworks themselves are **optional dependencies** — install the extra you need:
228
+
229
+ ```bash
230
+ pip install 'loopgain[langgraph]' # LangGraph
231
+ pip install 'loopgain[crewai]' # CrewAI
232
+ pip install 'loopgain[autogen]' # AutoGen v0.4+
233
+ pip install 'loopgain[all]' # all three
234
+ ```
235
+
236
+ All adapters take a `LoopGain` instance plus an `error_fn` you provide — the framework doesn't know what your error signal is, so the adapter doesn't either. `error_fn` returns a non-negative number (or `None` to skip an iteration).
237
+
238
+ ### LangGraph
239
+
240
+ Drives `graph.stream(input, stream_mode="updates")`. Each update is one iteration.
241
+
242
+ ```python
243
+ from loopgain import LoopGain
244
+ from loopgain.integrations import LangGraphAdapter
245
+
246
+ graph = build_my_verify_revise_graph().compile()
247
+ lg = LoopGain(target_error=0.1, max_iterations=20)
248
+
249
+ adapter = LangGraphAdapter(
250
+ lg=lg,
251
+ error_fn=lambda update: len(update.get("verifier", {}).get("errors", [])),
252
+ )
253
+ final_state = adapter.run(graph, {"draft": initial})
254
+
255
+ lg.send_telemetry(
256
+ endpoint=os.environ["LOOPGAIN_TELEMETRY_ENDPOINT"],
257
+ token=os.environ["LOOPGAIN_TELEMETRY_TOKEN"],
258
+ workload_id="rag-rewrite",
259
+ framework=adapter.framework_name, # "langgraph", auto-stamped
260
+ )
261
+ ```
262
+
263
+ `adapter.stream(...)` yields each item if you want the full trace; `adapter.arun(...)` / `adapter.astream(...)` are the async counterparts and accept an async `error_fn`.
264
+
265
+ ### CrewAI
266
+
267
+ Installs `step_callback` and/or `task_callback` on a Crew. Pick whichever granularity matches your loop — `step_error_fn` for refinement *within* a Task, `task_error_fn` for refinement *across* Tasks.
268
+
269
+ ```python
270
+ from crewai import Crew
271
+ from loopgain import LoopGain
272
+ from loopgain.integrations import CrewAIAdapter
273
+
274
+ lg = LoopGain(target_error=0.1, max_iterations=20)
275
+ adapter = CrewAIAdapter(
276
+ lg=lg,
277
+ task_error_fn=lambda task_output: count_failed_checks(task_output.raw),
278
+ )
279
+ crew = Crew(agents=[...], tasks=[...])
280
+ adapter.install(crew)
281
+ result = crew.kickoff()
282
+ adapter.uninstall() # or use `with CrewAIAdapter(...) as a:` context
283
+
284
+ lg.send_telemetry(
285
+ endpoint=...,
286
+ token=...,
287
+ framework=adapter.framework_name, # "crewai"
288
+ )
289
+ ```
290
+
291
+ The adapter chains with any callback you already had installed — your existing instrumentation isn't overwritten.
292
+
293
+ ### AutoGen (v0.4+)
294
+
295
+ Wraps `team.run_stream(task=...)`. In a verify-revise rotation, filter to the verifier's messages with `observe_sources={"verifier"}` so only it drives `observe()`.
296
+
297
+ ```python
298
+ from autogen_agentchat.teams import RoundRobinGroupChat
299
+ from loopgain import LoopGain
300
+ from loopgain.integrations import AutoGenAdapter
301
+
302
+ team = RoundRobinGroupChat(participants=[generator, verifier])
303
+ lg = LoopGain(target_error=0.1, max_iterations=20)
304
+ adapter = AutoGenAdapter(
305
+ lg=lg,
306
+ error_fn=lambda msg: parse_verifier_score(msg.content),
307
+ observe_sources={"verifier"},
308
+ )
309
+ result = await adapter.run(team, task="...")
310
+
311
+ lg.send_telemetry(
312
+ endpoint=...,
313
+ token=...,
314
+ framework=adapter.framework_name, # "autogen"
315
+ )
316
+ ```
317
+
318
+ Pass a `cancellation_token` to `adapter.run(...)` and the adapter will cancel it when LoopGain reaches a terminal state (target met, oscillation, divergence). The legacy v0.2 `ConversableAgent.initiate_chat` API is **not** supported — use the v0.4 event-driven runtime.
319
+
320
+ ### Custom integrations
321
+
322
+ For frameworks without an adapter, the raw `LoopGain.observe()` API works against any iterable. The adapters are 100-200 lines each — copy one of `loopgain/integrations/{langgraph,crewai,autogen}.py` as a starting point.
323
+
324
+ ---
325
+
208
326
  ## Status
209
327
 
210
328
  **Initial public release.** Core library shipped (current version: see the PyPI badge at the top). Framework adapters (LangGraph, CrewAI, AutoGen) and the cloud-aggregator dashboard come in v0.2+. The math and the API surface are stable.
@@ -59,7 +59,14 @@ print(result.savings_vs_fixed_cap)
59
59
 
60
60
  ## How it works
61
61
 
62
- LoopGain measures empirical loop gain `Aβ = E(n) / E(n-1)` at every iteration. It smooths with a configurable EMA and classifies the result into five named bands:
62
+ LoopGain measures empirical loop gain at every iteration, then smooths it with an EMA:
63
+
64
+ ```
65
+ Aβ(n) = E(n) / E(n-1)
66
+ Aβ_smooth = EMA(Aβ, w=3)
67
+ ```
68
+
69
+ It classifies `Aβ_smooth` into five named bands:
63
70
 
64
71
  | `Aβ_smooth` range | State | Action |
65
72
  | --- | --- | --- |
@@ -177,6 +184,107 @@ The hosted endpoint at `telemetry.loopgain.ai` is one acceptable destination. Th
177
184
 
178
185
  ---
179
186
 
187
+ ## Framework adapters
188
+
189
+ Thin wrappers under `loopgain.integrations` drive each major agent framework's iteration with a `LoopGain` monitor and auto-stamp `framework="<name>"` on telemetry. The frameworks themselves are **optional dependencies** — install the extra you need:
190
+
191
+ ```bash
192
+ pip install 'loopgain[langgraph]' # LangGraph
193
+ pip install 'loopgain[crewai]' # CrewAI
194
+ pip install 'loopgain[autogen]' # AutoGen v0.4+
195
+ pip install 'loopgain[all]' # all three
196
+ ```
197
+
198
+ All adapters take a `LoopGain` instance plus an `error_fn` you provide — the framework doesn't know what your error signal is, so the adapter doesn't either. `error_fn` returns a non-negative number (or `None` to skip an iteration).
199
+
200
+ ### LangGraph
201
+
202
+ Drives `graph.stream(input, stream_mode="updates")`. Each update is one iteration.
203
+
204
+ ```python
205
+ from loopgain import LoopGain
206
+ from loopgain.integrations import LangGraphAdapter
207
+
208
+ graph = build_my_verify_revise_graph().compile()
209
+ lg = LoopGain(target_error=0.1, max_iterations=20)
210
+
211
+ adapter = LangGraphAdapter(
212
+ lg=lg,
213
+ error_fn=lambda update: len(update.get("verifier", {}).get("errors", [])),
214
+ )
215
+ final_state = adapter.run(graph, {"draft": initial})
216
+
217
+ lg.send_telemetry(
218
+ endpoint=os.environ["LOOPGAIN_TELEMETRY_ENDPOINT"],
219
+ token=os.environ["LOOPGAIN_TELEMETRY_TOKEN"],
220
+ workload_id="rag-rewrite",
221
+ framework=adapter.framework_name, # "langgraph", auto-stamped
222
+ )
223
+ ```
224
+
225
+ `adapter.stream(...)` yields each item if you want the full trace; `adapter.arun(...)` / `adapter.astream(...)` are the async counterparts and accept an async `error_fn`.
226
+
227
+ ### CrewAI
228
+
229
+ Installs `step_callback` and/or `task_callback` on a Crew. Pick whichever granularity matches your loop — `step_error_fn` for refinement *within* a Task, `task_error_fn` for refinement *across* Tasks.
230
+
231
+ ```python
232
+ from crewai import Crew
233
+ from loopgain import LoopGain
234
+ from loopgain.integrations import CrewAIAdapter
235
+
236
+ lg = LoopGain(target_error=0.1, max_iterations=20)
237
+ adapter = CrewAIAdapter(
238
+ lg=lg,
239
+ task_error_fn=lambda task_output: count_failed_checks(task_output.raw),
240
+ )
241
+ crew = Crew(agents=[...], tasks=[...])
242
+ adapter.install(crew)
243
+ result = crew.kickoff()
244
+ adapter.uninstall() # or use `with CrewAIAdapter(...) as a:` context
245
+
246
+ lg.send_telemetry(
247
+ endpoint=...,
248
+ token=...,
249
+ framework=adapter.framework_name, # "crewai"
250
+ )
251
+ ```
252
+
253
+ The adapter chains with any callback you already had installed — your existing instrumentation isn't overwritten.
254
+
255
+ ### AutoGen (v0.4+)
256
+
257
+ Wraps `team.run_stream(task=...)`. In a verify-revise rotation, filter to the verifier's messages with `observe_sources={"verifier"}` so only it drives `observe()`.
258
+
259
+ ```python
260
+ from autogen_agentchat.teams import RoundRobinGroupChat
261
+ from loopgain import LoopGain
262
+ from loopgain.integrations import AutoGenAdapter
263
+
264
+ team = RoundRobinGroupChat(participants=[generator, verifier])
265
+ lg = LoopGain(target_error=0.1, max_iterations=20)
266
+ adapter = AutoGenAdapter(
267
+ lg=lg,
268
+ error_fn=lambda msg: parse_verifier_score(msg.content),
269
+ observe_sources={"verifier"},
270
+ )
271
+ result = await adapter.run(team, task="...")
272
+
273
+ lg.send_telemetry(
274
+ endpoint=...,
275
+ token=...,
276
+ framework=adapter.framework_name, # "autogen"
277
+ )
278
+ ```
279
+
280
+ Pass a `cancellation_token` to `adapter.run(...)` and the adapter will cancel it when LoopGain reaches a terminal state (target met, oscillation, divergence). The legacy v0.2 `ConversableAgent.initiate_chat` API is **not** supported — use the v0.4 event-driven runtime.
281
+
282
+ ### Custom integrations
283
+
284
+ For frameworks without an adapter, the raw `LoopGain.observe()` API works against any iterable. The adapters are 100-200 lines each — copy one of `loopgain/integrations/{langgraph,crewai,autogen}.py` as a starting point.
285
+
286
+ ---
287
+
180
288
  ## Status
181
289
 
182
290
  **Initial public release.** Core library shipped (current version: see the PyPI badge at the top). Framework adapters (LangGraph, CrewAI, AutoGen) and the cloud-aggregator dashboard come in v0.2+. The math and the API surface are stable.
@@ -9,6 +9,7 @@ Public API:
9
9
  result = lg.result
10
10
  """
11
11
 
12
+ from loopgain._version import __version__
12
13
  from loopgain.core import (
13
14
  LoopGain,
14
15
  LoopGainResult,
@@ -24,8 +25,6 @@ from loopgain.core import (
24
25
  )
25
26
  from loopgain.telemetry import build_payload as build_telemetry_payload
26
27
 
27
- __version__ = "0.1.4"
28
-
29
28
  __all__ = [
30
29
  "LoopGain",
31
30
  "LoopGainResult",
@@ -0,0 +1,9 @@
1
+ """Single source of truth for the package version.
2
+
3
+ Both ``loopgain/__init__.py`` and ``loopgain/telemetry.py`` import
4
+ ``__version__`` from here so the value never drifts between
5
+ ``__version__`` and the ``library_version`` field on telemetry payloads.
6
+ Update this file (and ``pyproject.toml``) for each release.
7
+ """
8
+
9
+ __version__ = "0.1.7"
@@ -398,24 +398,41 @@ class LoopGain:
398
398
  token: str,
399
399
  workload_id: Optional[str] = None,
400
400
  timeout: float = 2.0,
401
+ allow_insecure: bool = False,
402
+ framework: Optional[str] = None,
403
+ loop_type: Optional[str] = None,
404
+ team: Optional[str] = None,
405
+ include_per_iteration: bool = True,
401
406
  ) -> bool:
402
407
  """Send anonymized telemetry to a receiver endpoint.
403
408
 
404
409
  Opt-in. Call once after the loop terminates. Sends only structural
405
- statistics (state transitions, summary, gain margin, rollback
406
- flag, library version, optional opaque workload label). Never sends
407
- prompts, completions, error contents, or customer identity beyond
408
- the bearer token.
410
+ statistics values, error magnitudes, state transitions, gain
411
+ margin, rollback flag, library version, and optional opaque labels.
412
+ Never sends prompts, completions, error contents, or customer
413
+ identity beyond the bearer token.
409
414
 
410
415
  Best-effort: errors are swallowed; never raises. Safe to call from
411
416
  within an exception handler or finally block.
412
417
 
413
418
  Args:
414
- endpoint: Telemetry receiver URL.
419
+ endpoint: Telemetry receiver URL. Must use ``https://``;
420
+ ``http://`` is rejected unless ``allow_insecure`` is ``True``.
415
421
  token: Bearer token issued by the receiver (rotatable).
416
422
  workload_id: Optional opaque label that groups related loops in
417
423
  the dashboard. Never used to identify the customer.
418
424
  timeout: Per-request timeout in seconds. Default 2.0.
425
+ allow_insecure: If ``True``, permit ``http://`` endpoints (for
426
+ local development). Default ``False``.
427
+ framework: Optional classification — agent framework name
428
+ (``"langgraph"``, ``"crewai"``, etc.). Adapters auto-stamp.
429
+ loop_type: Optional classification — loop pattern name
430
+ (``"verify_revise"``, ``"rag_refine"``, etc.).
431
+ team: Optional classification — team or environment label.
432
+ include_per_iteration: If ``True`` (default), include the
433
+ per-iteration Aβ + error trajectories (capped) so the
434
+ dashboard's Loop Detail scrubber works. Set ``False`` to
435
+ send only aggregate summary stats.
419
436
 
420
437
  Returns:
421
438
  ``True`` on 2xx response, ``False`` otherwise.
@@ -429,9 +446,20 @@ class LoopGain:
429
446
  ... endpoint="https://telemetry.loopgain.ai/v1/aggregate",
430
447
  ... token="your-token-here",
431
448
  ... workload_id="my-rag-pipeline",
449
+ ... framework="langgraph",
450
+ ... loop_type="verify_revise",
432
451
  ... )
433
452
  """
434
453
  from loopgain.telemetry import build_payload, send_payload
435
454
 
436
- payload = build_payload(self, workload_id=workload_id)
437
- return send_payload(endpoint, token, payload, timeout=timeout)
455
+ payload = build_payload(
456
+ self,
457
+ workload_id=workload_id,
458
+ framework=framework,
459
+ loop_type=loop_type,
460
+ team=team,
461
+ include_per_iteration=include_per_iteration,
462
+ )
463
+ return send_payload(
464
+ endpoint, token, payload, timeout=timeout, allow_insecure=allow_insecure
465
+ )
@@ -0,0 +1,66 @@
1
+ """Framework integration adapters for LoopGain.
2
+
3
+ Each adapter is a thin wrapper that drives the host framework's iteration,
4
+ calls ``LoopGain.observe()`` on each step with an error magnitude derived
5
+ from a user-provided ``error_fn``, and (optionally) sends telemetry on
6
+ completion with ``framework="<name>"`` auto-stamped.
7
+
8
+ Adapters are isolated submodules so the host frameworks (langgraph, crewai,
9
+ autogen) remain *optional* dependencies. Importing this package does not
10
+ import any framework — each adapter only imports its framework when its
11
+ class is instantiated, and surfaces a clear ``ImportError`` if missing.
12
+
13
+ Install adapter extras::
14
+
15
+ pip install 'loopgain[langgraph]' # LangGraph
16
+ pip install 'loopgain[crewai]' # CrewAI
17
+ pip install 'loopgain[autogen]' # AutoGen v0.4+
18
+ pip install 'loopgain[all]' # all of the above
19
+
20
+ Common pattern::
21
+
22
+ from loopgain import LoopGain
23
+ from loopgain.integrations import LangGraphAdapter # or CrewAIAdapter, AutoGenAdapter
24
+
25
+ lg = LoopGain(target_error=0.1, max_iterations=20)
26
+ adapter = LangGraphAdapter(
27
+ lg=lg,
28
+ error_fn=lambda update: len(update.get("verifier_errors") or []),
29
+ )
30
+ final_state = adapter.run(graph, input_state)
31
+
32
+ # Optional: ship telemetry with framework auto-stamped.
33
+ lg.send_telemetry(
34
+ endpoint="https://telemetry.loopgain.ai/v1/aggregate",
35
+ token=os.environ["LOOPGAIN_TELEMETRY_TOKEN"],
36
+ workload_id="rag-rewrite",
37
+ framework=adapter.framework_name, # "langgraph"
38
+ )
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ # Adapters are imported lazily so importing this package does NOT pull in
44
+ # langgraph / crewai / autogen. Each name resolves on first attribute access
45
+ # and surfaces a clear ImportError if its host framework isn't installed.
46
+ __all__ = [
47
+ "LangGraphAdapter",
48
+ "CrewAIAdapter",
49
+ "AutoGenAdapter",
50
+ ]
51
+
52
+
53
+ def __getattr__(name: str):
54
+ if name == "LangGraphAdapter":
55
+ from loopgain.integrations.langgraph import LangGraphAdapter
56
+
57
+ return LangGraphAdapter
58
+ if name == "CrewAIAdapter":
59
+ from loopgain.integrations.crewai import CrewAIAdapter
60
+
61
+ return CrewAIAdapter
62
+ if name == "AutoGenAdapter":
63
+ from loopgain.integrations.autogen import AutoGenAdapter
64
+
65
+ return AutoGenAdapter
66
+ raise AttributeError(f"module 'loopgain.integrations' has no attribute {name!r}")
@@ -0,0 +1,156 @@
1
+ """AutoGen v0.4+ adapter for LoopGain.
2
+
3
+ AutoGen v0.4 reorganized around an event-driven async runtime: a Team
4
+ (``RoundRobinGroupChat``, ``SocietyOfMindAgent``, ``Swarm``, etc.) exposes
5
+ ``run_stream(task=...)`` which yields ``BaseAgentEvent | BaseChatMessage``
6
+ items per message, terminating with a ``TaskResult``.
7
+
8
+ In a verify-revise pattern the Team is typically a 2-agent rotation
9
+ (generator → verifier → generator → ...). The verifier's most recent
10
+ message carries the error signal; the user's ``error_fn`` extracts it
11
+ and the adapter feeds it to LoopGain.
12
+
13
+ Reference: https://microsoft.github.io/autogen/stable/_modules/autogen_agentchat/teams/_group_chat/_base_group_chat.html
14
+
15
+ The adapter does NOT support the legacy v0.2 ``ConversableAgent.initiate_chat``
16
+ API. v0.2 is in maintenance mode upstream; users on the old runtime
17
+ should upgrade or fall back to the raw ``LoopGain.observe()`` loop.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, List, Optional
24
+
25
+ if TYPE_CHECKING:
26
+ from loopgain.core import LoopGain
27
+
28
+
29
+ AsyncMessageErrorFn = Callable[[Any], Awaitable[Optional[float]]]
30
+ MessageErrorFn = Callable[[Any], Optional[float]]
31
+
32
+
33
+ class AutoGenAdapter:
34
+ """Drive an AutoGen v0.4+ Team with a LoopGain monitor.
35
+
36
+ Args:
37
+ lg: A ``LoopGain`` instance to drive.
38
+ error_fn: Maps one streamed message/event to an error magnitude.
39
+ Return ``None`` to skip (e.g. for non-verifier messages).
40
+ Both sync and async callables are accepted; if async, await
41
+ in the function body — the adapter will detect and handle.
42
+ observe_sources: Optional set of agent ``source`` names to observe.
43
+ If provided, messages from other sources are passed through
44
+ without invoking ``error_fn``. Useful when only the verifier
45
+ agent's messages carry an error signal.
46
+
47
+ Example::
48
+
49
+ from autogen_agentchat.teams import RoundRobinGroupChat
50
+ from loopgain import LoopGain
51
+ from loopgain.integrations import AutoGenAdapter
52
+
53
+ team = RoundRobinGroupChat(participants=[generator, verifier])
54
+ lg = LoopGain(target_error=0.1, max_iterations=20)
55
+ adapter = AutoGenAdapter(
56
+ lg=lg,
57
+ error_fn=lambda msg: parse_verifier_score(msg.content),
58
+ observe_sources={"verifier"},
59
+ )
60
+ result = await adapter.run(team, task="Write a haiku about loops.")
61
+ """
62
+
63
+ framework_name = "autogen"
64
+
65
+ def __init__(
66
+ self,
67
+ lg: "LoopGain",
68
+ error_fn: MessageErrorFn,
69
+ observe_sources: Optional[set[str]] = None,
70
+ ) -> None:
71
+ self.lg = lg
72
+ self.error_fn = error_fn
73
+ self.observe_sources = observe_sources
74
+
75
+ async def run(
76
+ self,
77
+ team: Any,
78
+ task: Any,
79
+ cancellation_token: Optional[Any] = None,
80
+ ) -> List[Any]:
81
+ """Drive ``team.run_stream(task=...)`` to completion, returning
82
+ the full list of yielded messages/events (including the terminal
83
+ ``TaskResult``).
84
+
85
+ If LoopGain reaches a terminal state mid-stream, the team is
86
+ cancelled via the supplied ``cancellation_token`` (if one was
87
+ provided) — AutoGen has no way to interrupt a stream from outside
88
+ the cancellation-token mechanism.
89
+ """
90
+ out: List[Any] = []
91
+ async for item in self.stream(team, task, cancellation_token=cancellation_token):
92
+ out.append(item)
93
+ return out
94
+
95
+ async def stream(
96
+ self,
97
+ team: Any,
98
+ task: Any,
99
+ cancellation_token: Optional[Any] = None,
100
+ ) -> AsyncIterator[Any]:
101
+ """Yield each message/event from ``team.run_stream`` while driving
102
+ LoopGain. Cancels the team's cancellation_token when LoopGain
103
+ signals a terminal state, then breaks out of the iteration.
104
+
105
+ AutoGen's runtime raises ``asyncio.CancelledError`` from its own
106
+ internal tasks once the token is cancelled. The adapter catches
107
+ that error iff *we* initiated the cancellation, so callers see a
108
+ clean termination instead of an exception they didn't ask for."""
109
+ kwargs: dict[str, Any] = {"task": task}
110
+ if cancellation_token is not None:
111
+ kwargs["cancellation_token"] = cancellation_token
112
+
113
+ we_cancelled = False
114
+ iterator = team.run_stream(**kwargs).__aiter__()
115
+ while True:
116
+ try:
117
+ message = await iterator.__anext__()
118
+ except StopAsyncIteration:
119
+ break
120
+ except asyncio.CancelledError:
121
+ if we_cancelled:
122
+ break
123
+ raise
124
+
125
+ yield message
126
+
127
+ # Don't observe the terminal TaskResult — it's a wrapper, not
128
+ # a per-iteration event. Detect by duck-typing on the
129
+ # `messages` + `stop_reason` attributes (AutoGen's TaskResult
130
+ # shape) so we don't have to import the framework.
131
+ if hasattr(message, "messages") and hasattr(message, "stop_reason"):
132
+ continue
133
+
134
+ # Source-filter: skip messages we're not configured to observe.
135
+ if self.observe_sources is not None:
136
+ source = getattr(message, "source", None)
137
+ if source not in self.observe_sources:
138
+ continue
139
+
140
+ magnitude = self.error_fn(message)
141
+ # Allow the user to write either a sync or async error_fn.
142
+ if hasattr(magnitude, "__await__"):
143
+ magnitude = await magnitude # type: ignore[assignment]
144
+
145
+ if magnitude is not None:
146
+ self.lg.observe(magnitude, output=message)
147
+
148
+ if not self.lg.should_continue() and cancellation_token is not None:
149
+ # Best-effort: AutoGen uses the cancellation token to abort.
150
+ cancel = getattr(cancellation_token, "cancel", None)
151
+ if callable(cancel):
152
+ cancel()
153
+ we_cancelled = True
154
+ # Stop pulling from the iterator. The next __anext__
155
+ # would raise CancelledError once the runtime tears down.
156
+ break