fluxloop-cli 0.2.12__tar.gz → 0.2.14__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.
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/PKG-INFO +1 -1
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/__init__.py +1 -1
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/runner.py +146 -9
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli.egg-info/PKG-INFO +1 -1
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/pyproject.toml +1 -1
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/README.md +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/arg_binder.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/commands/__init__.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/commands/config.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/commands/generate.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/commands/init.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/commands/parse.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/commands/record.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/commands/run.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/commands/status.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/config_loader.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/config_schema.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/constants.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/input_generator.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/llm_generator.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/main.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/project_paths.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/target_loader.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/templates.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli/validators.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli.egg-info/SOURCES.txt +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli.egg-info/entry_points.txt +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli.egg-info/requires.txt +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/fluxloop_cli.egg-info/top_level.txt +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/setup.cfg +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/tests/test_arg_binder.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/tests/test_config_command.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/tests/test_input_generator.py +0 -0
- {fluxloop_cli-0.2.12 → fluxloop_cli-0.2.14}/tests/test_target_loader.py +0 -0
|
@@ -57,8 +57,17 @@ class ExperimentRunner:
|
|
|
57
57
|
|
|
58
58
|
offline_dir = output_base / "artifacts"
|
|
59
59
|
offline_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
# Ensure downstream load_env() calls don't re-enable collector unintentionally
|
|
61
|
+
should_use_collector = (not no_collector) and bool(config.collector_url)
|
|
62
|
+
if not should_use_collector:
|
|
63
|
+
os.environ["FLUXLOOP_USE_COLLECTOR"] = "false"
|
|
64
|
+
else:
|
|
65
|
+
os.environ["FLUXLOOP_USE_COLLECTOR"] = "true"
|
|
66
|
+
# Pin offline store dir in env so refresh_config respects our path
|
|
67
|
+
os.environ.setdefault("FLUXLOOP_OFFLINE_DIR", str(offline_dir))
|
|
68
|
+
os.environ.setdefault("FLUXLOOP_OFFLINE_ENABLED", "true")
|
|
60
69
|
fluxloop.configure(
|
|
61
|
-
use_collector=
|
|
70
|
+
use_collector=should_use_collector,
|
|
62
71
|
collector_url=config.collector_url or None,
|
|
63
72
|
api_key=config.collector_api_key,
|
|
64
73
|
offline_store_enabled=True,
|
|
@@ -347,6 +356,11 @@ class ExperimentRunner:
|
|
|
347
356
|
final_output = self._extract_final_output(callback_messages, observations)
|
|
348
357
|
if final_output is not None:
|
|
349
358
|
result = final_output
|
|
359
|
+
else:
|
|
360
|
+
# As a fallback, coerce async streams or objects into text
|
|
361
|
+
coerced = await self._coerce_result_to_text(result)
|
|
362
|
+
if coerced is not None:
|
|
363
|
+
result = coerced
|
|
350
364
|
|
|
351
365
|
# Mark successful
|
|
352
366
|
self.results["successful"] += 1
|
|
@@ -498,11 +512,15 @@ class ExperimentRunner:
|
|
|
498
512
|
"""Derive the final output from callbacks or observations."""
|
|
499
513
|
for observation in reversed(observations):
|
|
500
514
|
if observation.get("name") == "agent_final_response" and observation.get("output") is not None:
|
|
501
|
-
|
|
515
|
+
val = observation.get("output")
|
|
516
|
+
if not ExperimentRunner._looks_like_generator_repr(val):
|
|
517
|
+
return val
|
|
502
518
|
|
|
503
519
|
for observation in reversed(observations):
|
|
504
520
|
if observation.get("type") == "agent" and observation.get("output") is not None:
|
|
505
|
-
|
|
521
|
+
val = observation.get("output")
|
|
522
|
+
if not ExperimentRunner._looks_like_generator_repr(val):
|
|
523
|
+
return val
|
|
506
524
|
|
|
507
525
|
send_messages = callback_messages.get("send") if callback_messages else None
|
|
508
526
|
if send_messages:
|
|
@@ -511,6 +529,13 @@ class ExperimentRunner:
|
|
|
511
529
|
|
|
512
530
|
return None
|
|
513
531
|
|
|
532
|
+
@staticmethod
|
|
533
|
+
def _looks_like_generator_repr(value: Any) -> bool:
|
|
534
|
+
if not isinstance(value, str):
|
|
535
|
+
return False
|
|
536
|
+
s = value.strip().lower()
|
|
537
|
+
return s.startswith("<async_generator ") or s.startswith("<generator ") or " object at 0x" in s
|
|
538
|
+
|
|
514
539
|
async def _consume_async_gen(self, func: Callable, kwargs: Dict[str, Any]) -> Any:
|
|
515
540
|
"""Consume an async generator function by joining text chunks resolved from events."""
|
|
516
541
|
gen = func(**kwargs)
|
|
@@ -522,7 +547,8 @@ class ExperimentRunner:
|
|
|
522
547
|
Uses a copied contextvars context to ensure FluxLoop context is preserved
|
|
523
548
|
across async iteration boundaries created by upstream frameworks.
|
|
524
549
|
"""
|
|
525
|
-
path
|
|
550
|
+
# Prefer ChatKit-like path by default; configurable via runner.stream_output_path
|
|
551
|
+
path = (getattr(self.config.runner, "stream_output_path", None) or "update.delta").split(".")
|
|
526
552
|
chunks: List[str] = []
|
|
527
553
|
|
|
528
554
|
ctx = contextvars.copy_context()
|
|
@@ -585,7 +611,58 @@ class ExperimentRunner:
|
|
|
585
611
|
text_attr = getattr(event, "text", None)
|
|
586
612
|
if isinstance(text_attr, str) and text_attr:
|
|
587
613
|
return text_attr
|
|
614
|
+
# Final deep fallback: search common fields recursively
|
|
615
|
+
return ExperimentRunner._deep_extract_text(event)
|
|
588
616
|
|
|
617
|
+
@staticmethod
|
|
618
|
+
def _deep_extract_text(obj: Any, *, _depth: int = 0) -> Optional[str]:
|
|
619
|
+
if _depth > 3 or obj is None:
|
|
620
|
+
return None
|
|
621
|
+
if isinstance(obj, str):
|
|
622
|
+
return obj if obj else None
|
|
623
|
+
# dict-like
|
|
624
|
+
if isinstance(obj, dict):
|
|
625
|
+
for key in ("delta", "text"):
|
|
626
|
+
val = obj.get(key)
|
|
627
|
+
if isinstance(val, str) and val:
|
|
628
|
+
return val
|
|
629
|
+
# content as list of parts with text
|
|
630
|
+
content = obj.get("content")
|
|
631
|
+
if isinstance(content, list):
|
|
632
|
+
parts: List[str] = []
|
|
633
|
+
for piece in content:
|
|
634
|
+
txt = ExperimentRunner._deep_extract_text(piece, _depth=_depth + 1)
|
|
635
|
+
if txt:
|
|
636
|
+
parts.append(txt)
|
|
637
|
+
if parts:
|
|
638
|
+
return " ".join(parts)
|
|
639
|
+
# Recurse selected fields
|
|
640
|
+
for key in ("update", "message", "item", "data"):
|
|
641
|
+
val = obj.get(key)
|
|
642
|
+
txt = ExperimentRunner._deep_extract_text(val, _depth=_depth + 1)
|
|
643
|
+
if txt:
|
|
644
|
+
return txt
|
|
645
|
+
return None
|
|
646
|
+
# object with attributes
|
|
647
|
+
for attr in ("delta", "text"):
|
|
648
|
+
val = getattr(obj, attr, None)
|
|
649
|
+
if isinstance(val, str) and val:
|
|
650
|
+
return val
|
|
651
|
+
for attr in ("content",):
|
|
652
|
+
val = getattr(obj, attr, None)
|
|
653
|
+
if isinstance(val, list):
|
|
654
|
+
parts: List[str] = []
|
|
655
|
+
for piece in val:
|
|
656
|
+
txt = ExperimentRunner._deep_extract_text(piece, _depth=_depth + 1)
|
|
657
|
+
if txt:
|
|
658
|
+
parts.append(txt)
|
|
659
|
+
if parts:
|
|
660
|
+
return " ".join(parts)
|
|
661
|
+
for attr in ("update", "message", "item", "data"):
|
|
662
|
+
val = getattr(obj, attr, None)
|
|
663
|
+
txt = ExperimentRunner._deep_extract_text(val, _depth=_depth + 1)
|
|
664
|
+
if txt:
|
|
665
|
+
return txt
|
|
589
666
|
return None
|
|
590
667
|
|
|
591
668
|
@staticmethod
|
|
@@ -610,6 +687,33 @@ class ExperimentRunner:
|
|
|
610
687
|
"args": list(args),
|
|
611
688
|
"kwargs": kwargs,
|
|
612
689
|
}
|
|
690
|
+
|
|
691
|
+
async def _coerce_result_to_text(self, value: Any) -> Optional[str]:
|
|
692
|
+
"""Best-effort conversion of agent result into text for summaries.
|
|
693
|
+
|
|
694
|
+
Handles async generators/iterables, known streaming item shapes, and strings.
|
|
695
|
+
"""
|
|
696
|
+
import inspect as _inspect
|
|
697
|
+
|
|
698
|
+
if value is None:
|
|
699
|
+
return None
|
|
700
|
+
|
|
701
|
+
if isinstance(value, str):
|
|
702
|
+
return value
|
|
703
|
+
|
|
704
|
+
# Async iterable/generator → consume to text
|
|
705
|
+
if _inspect.isasyncgen(value) or hasattr(value, "__aiter__"):
|
|
706
|
+
try:
|
|
707
|
+
return await self._consume_async_iterable(value)
|
|
708
|
+
except Exception:
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
# Try extracting text from known event-like objects
|
|
712
|
+
fallback = self._extract_stream_text(value)
|
|
713
|
+
if fallback:
|
|
714
|
+
return fallback
|
|
715
|
+
|
|
716
|
+
return None
|
|
613
717
|
|
|
614
718
|
def _save_results(self) -> None:
|
|
615
719
|
"""Save results to output directory."""
|
|
@@ -676,16 +780,43 @@ class ExperimentRunner:
|
|
|
676
780
|
if not trace_ids:
|
|
677
781
|
return
|
|
678
782
|
|
|
679
|
-
|
|
680
|
-
|
|
783
|
+
# Gather candidate offline stores
|
|
784
|
+
candidates = []
|
|
785
|
+
try:
|
|
786
|
+
from fluxloop import get_config as _get_sdk_config # type: ignore
|
|
787
|
+
sdk_dir = Path(_get_sdk_config().offline_store_dir)
|
|
788
|
+
candidates.append(sdk_dir / "observations.jsonl")
|
|
789
|
+
except Exception:
|
|
790
|
+
pass
|
|
791
|
+
|
|
792
|
+
candidates.append(self.offline_dir / "observations.jsonl")
|
|
793
|
+
# Legacy fallback removed now that SDK defaults to experiments/artifacts
|
|
794
|
+
|
|
795
|
+
# Unique existing paths in priority order
|
|
796
|
+
seen = set()
|
|
797
|
+
existing: list[Path] = []
|
|
798
|
+
for p in candidates:
|
|
799
|
+
try:
|
|
800
|
+
rp = p.resolve()
|
|
801
|
+
except Exception:
|
|
802
|
+
continue
|
|
803
|
+
if rp in seen:
|
|
804
|
+
continue
|
|
805
|
+
seen.add(rp)
|
|
806
|
+
if rp.exists():
|
|
807
|
+
existing.append(rp)
|
|
808
|
+
|
|
809
|
+
if not existing:
|
|
681
810
|
return
|
|
682
811
|
|
|
683
812
|
destination = self.output_dir / "observations.jsonl"
|
|
684
813
|
copied = 0
|
|
814
|
+
seen_lines = set()
|
|
685
815
|
|
|
686
|
-
with
|
|
687
|
-
|
|
688
|
-
|
|
816
|
+
with destination.open("w", encoding="utf-8") as dst:
|
|
817
|
+
for source_path in existing:
|
|
818
|
+
try:
|
|
819
|
+
with source_path.open("r", encoding="utf-8") as src:
|
|
689
820
|
for line in src:
|
|
690
821
|
if not line.strip():
|
|
691
822
|
continue
|
|
@@ -694,8 +825,14 @@ class ExperimentRunner:
|
|
|
694
825
|
except json.JSONDecodeError:
|
|
695
826
|
continue
|
|
696
827
|
if record.get("trace_id") in trace_ids:
|
|
828
|
+
key = (record.get("id"), record.get("start_time"))
|
|
829
|
+
if key in seen_lines:
|
|
830
|
+
continue
|
|
697
831
|
dst.write(json.dumps(record) + "\n")
|
|
832
|
+
seen_lines.add(key)
|
|
698
833
|
copied += 1
|
|
834
|
+
except OSError:
|
|
835
|
+
continue
|
|
699
836
|
|
|
700
837
|
console.print(
|
|
701
838
|
f"[green]✅ Saved {copied} observations to {destination.name}[/green]"
|
|
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
|