cmpnd 0.4.1__tar.gz → 0.4.3__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.
- {cmpnd-0.4.1 → cmpnd-0.4.3}/PKG-INFO +1 -1
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/__init__.py +6 -0
- cmpnd-0.4.3/cmpnd/_rlm_patch.py +134 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/callback.py +18 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/models.py +5 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/pyproject.toml +1 -1
- {cmpnd-0.4.1 → cmpnd-0.4.3}/scripts/seed_review_run.py +118 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/conftest.py +1 -1
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/stress/test_deploy_programs.py +1 -1
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/stress/test_multi_client_stress.py +1 -1
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/stress/test_real_provider_twins.py +1 -1
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_callback.py +64 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_models.py +10 -0
- cmpnd-0.4.3/tests/test_rlm_patch.py +180 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/uv.lock +62 -62
- {cmpnd-0.4.1 → cmpnd-0.4.3}/.gitignore +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/CLAUDE.md +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/README.md +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/_gepa_patch.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/_program_patch.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/api_client.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/admin.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/auth.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/datasets.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/deployments.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/evals.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/login.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/optimizations.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/org.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/programs.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/top.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/commands/traces.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/credentials.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/shell.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/cli/verbs.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/configuration.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/context.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/dataset_sync.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/datasets.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/decorators.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/deployment.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/eval_handler.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/execution.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/exporter.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/helpers.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/identity.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/ir/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/ir/decode.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/ir/encode.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/ir/hash.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/ir/program.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/ir/reflection.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/ir/types.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/optimization.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/optimizers/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/optimizers/gepa.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/optimizers/gepa_callback.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/optimizers/resume.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/optimizers/tracker.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/cmpnd/packaging.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/docs/2026-04-02-code-review.md +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/docs/sdk-instrumentation.md +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/examples/local_ollama.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/scripts/capture_review_run.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/scripts/fixtures/review_run.json.gz +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/scripts/gepa_resume_bug.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/stubs/dspy/__init__.pyi +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/stubs/dspy/primitives/__init__.pyi +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/stubs/dspy/primitives/prediction.pyi +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/stubs/dspy/utils/__init__.pyi +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/stubs/dspy/utils/callback.pyi +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/_exporter_helpers.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/_identity_helpers.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/committee_task.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/data/committee_train.ndjson.gz +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/data/committee_val.ndjson.gz +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/fake_lm.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/helpers.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_capture_prompts.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_composite_module.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_dataset_auto_creation.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_deploy_execute.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_deploy_programs.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_edge_cases.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_example_hash.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_gepa_optimization.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_lm_accuracy.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_module_tracing.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_optimization_lineage.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_optimization_metric_identity.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_optimize_failures.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_optimize_loop.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_optimize_review_committee.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_parallel_export.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_project_attribution.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_react_tool_spans.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_save_load.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_trace_structure.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/e2e/test_user_attribution.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/01_predict_qa.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/04_predict_richer_types.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/06_multichaincomparison.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/07_program_of_thought.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/08_opaque_module.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/10_react.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/11_refine.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/12_retrieve.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/13_knn.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/14_parallel.json +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/identity_vectors/README.md +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/integration/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/integration/conftest.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/integration/test_cli_deploy_smoketest.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/integration/test_logs_renders_traces.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/integration/test_optimizing_progress.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/integration/test_unhappy_path.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/conftest.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/seed_parity.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_auth.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_datasets.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_deployments.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_evals.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_health.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_modules.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_optimizations.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_programs.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_signatures.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_trace_streaming.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/parity/test_traces.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/stress/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/stress/_reliability.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/stress/test_optimize_client_committee.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/stress/test_optimize_loop.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/stress/test_optimize_stress.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/stress/test_reliability.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/conftest.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_admin.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_api_client.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_auth.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_credentials.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_datasets.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_deployments.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_evals.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_login.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_main.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_optimizations.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_org.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_parity.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_programs.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_shell.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_top.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_cli/test_traces.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_clustering/__init__.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_clustering/conftest.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_clustering/test_edge_cases.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_clustering/test_hash_consistency.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_clustering/test_type_conversion.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_configuration.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_context.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_context_propagation.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_dataset_polling.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_dataset_sync.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_datasets.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_decorators.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_deploy.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_eval.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_eval_start.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_exception_paths.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_execute.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_exporter.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_exporter_real.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_fake_lm.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_gepa_callback.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_gepa_integration.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_gepa_resume.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_identity.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_integration.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_ir_encoder_properties.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_ir_golden_vectors.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_ir_roundtrip.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_optimization_start.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_optimize.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_optimize_lift.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_optimize_terminal_failure.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_packaging.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_packaging_callable_source.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_packaging_program_source.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_program_lineage_persist.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_project_payload.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_reflector_properties.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_reflector_smoke.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_repro_reactv2_bugs.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_schemas.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_seed_review_run.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_stats_export.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_tracked_gepa.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_tracker_enrich.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/tests/test_truncation.py +0 -0
- {cmpnd-0.4.1 → cmpnd-0.4.3}/todo.md +0 -0
|
@@ -136,4 +136,10 @@ def auto_instrument() -> CmpndCallback:
|
|
|
136
136
|
|
|
137
137
|
_program_patch.patch_program_state()
|
|
138
138
|
|
|
139
|
+
# Emit tool spans for dspy.RLM tool calls, which otherwise bypass
|
|
140
|
+
# dspy.Tool.__call__'s callbacks and produce no spans (CMP-187).
|
|
141
|
+
from . import _rlm_patch
|
|
142
|
+
|
|
143
|
+
_rlm_patch.patch_rlm_tools()
|
|
144
|
+
|
|
139
145
|
return callback
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Emit tool spans for ``dspy.RLM`` tool calls (CMP-187).
|
|
2
|
+
|
|
3
|
+
cmpnd creates a ``TOOL`` span only from DSPy's ``on_tool_start``/``on_tool_end``
|
|
4
|
+
callbacks, and those fire only through ``dspy.Tool.__call__`` (decorated
|
|
5
|
+
``@with_callbacks``). ``dspy.RLM._prepare_execution_tools`` hands the code
|
|
6
|
+
interpreter the **bare** ``tool.func`` for each user tool
|
|
7
|
+
(``{name: tool.func for name, tool in self._user_tools.items()}`` in
|
|
8
|
+
``dspy/predict/rlm.py``), so ``Tool.__call__`` is bypassed and no span is
|
|
9
|
+
created — unlike ``dspy.ReAct``, which calls the ``Tool`` object and gets tool
|
|
10
|
+
spans for free.
|
|
11
|
+
|
|
12
|
+
``patch_rlm_tools()`` wraps ``_prepare_execution_tools`` so each user tool's
|
|
13
|
+
func is replaced by a shim that dispatches the tool lifecycle to the active
|
|
14
|
+
``CmpndCallback``\\ (s) around the original func call. Routing through the
|
|
15
|
+
callback (rather than through ``Tool.__call__``) keeps execution identical to
|
|
16
|
+
today's bare-func path — no re-validation / pydantic coercion of the
|
|
17
|
+
interpreter's native kwargs — while still emitting the span.
|
|
18
|
+
|
|
19
|
+
``PythonInterpreter.execute`` dispatches tool calls inline on the calling
|
|
20
|
+
(forward) thread, so the shim runs with cmpnd's ``_span_stack`` contextvar
|
|
21
|
+
intact: the tool span nests under ``RLM.forward`` and any ``dspy.Predict`` run
|
|
22
|
+
inside the tool re-parents under it.
|
|
23
|
+
|
|
24
|
+
Signature preservation is mandatory: ``PythonInterpreter._extract_parameters``
|
|
25
|
+
builds its sandbox tool stub from ``inspect.signature(fn)``. ``functools.wraps``
|
|
26
|
+
sets ``__wrapped__``, which ``inspect.signature`` follows, so the real params
|
|
27
|
+
survive the wrap (guarded by a test).
|
|
28
|
+
|
|
29
|
+
Scope is user tools only; the ``llm_query`` / ``llm_query_batched`` builtins
|
|
30
|
+
share the gap but ``llm_query_batched`` fans out over its own thread pool (a
|
|
31
|
+
separate context-propagation problem), so they're left out here.
|
|
32
|
+
|
|
33
|
+
Installed lazily from ``cmpnd.auto_instrument()`` alongside ``patch_gepa()`` /
|
|
34
|
+
``patch_program_state()``. Idempotent; a no-op when dspy isn't installed.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import functools
|
|
40
|
+
import uuid
|
|
41
|
+
from typing import Any, Callable
|
|
42
|
+
|
|
43
|
+
_patched = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _active_cmpnd_callback() -> Any | None:
|
|
47
|
+
"""The single registered CmpndCallback (or None).
|
|
48
|
+
|
|
49
|
+
cmpnd is built around exactly one callback — auto_instrument() refuses to
|
|
50
|
+
add a second — and the callbacks share one span-stack contextvar. So we
|
|
51
|
+
dispatch the tool lifecycle to just the first CmpndCallback: pushing a span
|
|
52
|
+
per callback would nest one tool span under another for a single tool call
|
|
53
|
+
and desync on_tool_end's pop from the stack.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
import dspy
|
|
57
|
+
except ImportError:
|
|
58
|
+
return None
|
|
59
|
+
from . import callback
|
|
60
|
+
|
|
61
|
+
registered = dspy.settings.get("callbacks", []) or []
|
|
62
|
+
for cb in registered:
|
|
63
|
+
if isinstance(cb, callback.CmpndCallback):
|
|
64
|
+
return cb
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _wrap_tool_func(tool_obj: Any) -> Callable[..., Any]:
|
|
69
|
+
"""Wrap a dspy.Tool's func so a call emits a TOOL span around it.
|
|
70
|
+
|
|
71
|
+
The wrapper carries the original func's signature (``functools.wraps`` sets
|
|
72
|
+
``__wrapped__``) so the interpreter's sandbox stub sees the real params. It
|
|
73
|
+
dispatches to the active cmpnd callback only; with none registered it's a
|
|
74
|
+
transparent passthrough.
|
|
75
|
+
"""
|
|
76
|
+
func = tool_obj.func
|
|
77
|
+
|
|
78
|
+
@functools.wraps(func)
|
|
79
|
+
def shim(**kwargs: Any) -> Any:
|
|
80
|
+
cb = _active_cmpnd_callback()
|
|
81
|
+
if cb is None:
|
|
82
|
+
return func(**kwargs)
|
|
83
|
+
|
|
84
|
+
# on_tool_start (which creates + pushes the span) must succeed before we
|
|
85
|
+
# enter the try; on_tool_end is a no-op for an unknown call_id, so a
|
|
86
|
+
# failed start can't leave the span stack unbalanced.
|
|
87
|
+
call_id = uuid.uuid4().hex
|
|
88
|
+
cb.on_tool_start(call_id, tool_obj, kwargs)
|
|
89
|
+
output = None
|
|
90
|
+
exception = None
|
|
91
|
+
try:
|
|
92
|
+
output = func(**kwargs)
|
|
93
|
+
return output
|
|
94
|
+
except Exception as exc: # noqa: BLE001 - re-raised after closing the span
|
|
95
|
+
exception = exc
|
|
96
|
+
raise
|
|
97
|
+
finally:
|
|
98
|
+
cb.on_tool_end(call_id, output, exception)
|
|
99
|
+
|
|
100
|
+
return shim
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def patch_rlm_tools() -> None:
|
|
104
|
+
"""Wrap ``RLM._prepare_execution_tools`` so user tools emit spans.
|
|
105
|
+
|
|
106
|
+
Idempotent (a module-level flag guards double-patching); a no-op when dspy
|
|
107
|
+
or ``dspy.RLM`` isn't available.
|
|
108
|
+
"""
|
|
109
|
+
global _patched
|
|
110
|
+
if _patched:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
import dspy
|
|
115
|
+
except ImportError:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
rlm_cls = getattr(dspy, "RLM", None)
|
|
119
|
+
if rlm_cls is None or not hasattr(rlm_cls, "_prepare_execution_tools"):
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
orig = getattr(rlm_cls, "_prepare_execution_tools")
|
|
123
|
+
|
|
124
|
+
@functools.wraps(orig)
|
|
125
|
+
def _prepare_execution_tools(self: Any) -> dict[str, Callable[..., Any]]:
|
|
126
|
+
tools = orig(self)
|
|
127
|
+
user_tools = getattr(self, "_user_tools", {}) or {}
|
|
128
|
+
for name, tool_obj in user_tools.items():
|
|
129
|
+
if name in tools:
|
|
130
|
+
tools[name] = _wrap_tool_func(tool_obj)
|
|
131
|
+
return tools
|
|
132
|
+
|
|
133
|
+
setattr(rlm_cls, "_prepare_execution_tools", _prepare_execution_tools)
|
|
134
|
+
_patched = True
|
|
@@ -182,6 +182,10 @@ class _ModuleMixin(_CallbackBase):
|
|
|
182
182
|
trace = self._create_trace(instance, inputs)
|
|
183
183
|
context.set_current_trace(trace)
|
|
184
184
|
|
|
185
|
+
# Carry the root module's type so serve can classify the trace at
|
|
186
|
+
# start (CMP-537) — before any span lands — instead of inferring it
|
|
187
|
+
# from span shape later.
|
|
188
|
+
trace.root_span_type = span_type.value
|
|
185
189
|
# RLM traces use streaming export; everything else batches
|
|
186
190
|
if span_type == models.SpanType.RLM:
|
|
187
191
|
trace.streaming = True
|
|
@@ -230,6 +234,20 @@ class _ModuleMixin(_CallbackBase):
|
|
|
230
234
|
if rh_input is not None and hasattr(rh_input, "entries") and rh_input.entries:
|
|
231
235
|
last_output = rh_input.entries[-1].output
|
|
232
236
|
rlm_parent.repl_history[-1]["output"] = last_output
|
|
237
|
+
# Surface the previous turn's code output on THIS (turn N+1)
|
|
238
|
+
# span so a streaming reader can fill in turn N's notebook
|
|
239
|
+
# output mid-run, rather than waiting for the whole trajectory
|
|
240
|
+
# on the RLM parent span at finalize (CMP-537). The prior
|
|
241
|
+
# turn's span has already ended + been exported, so it rides
|
|
242
|
+
# here instead; the RLM's final turn (no successor) still
|
|
243
|
+
# resolves at finalize. Capped like an output field so a large
|
|
244
|
+
# stdout can't bloat the streamed span.
|
|
245
|
+
if last_output is not None:
|
|
246
|
+
prev = last_output if isinstance(last_output, str) else str(last_output)
|
|
247
|
+
cap = self._max_output_field_size
|
|
248
|
+
if len(prev) > cap:
|
|
249
|
+
prev = prev[:cap] + f"\n\n… (truncated, {len(prev)} chars total)"
|
|
250
|
+
span.attributes["rlm.prev_output"] = prev
|
|
233
251
|
|
|
234
252
|
# Push to context stack
|
|
235
253
|
context.push_span(span)
|
|
@@ -387,6 +387,10 @@ class TraceBuilder:
|
|
|
387
387
|
|
|
388
388
|
# Export routing: True = stream spans individually, False = batch at completion
|
|
389
389
|
streaming: bool = False
|
|
390
|
+
# The root module's span type (e.g. "rlm"), sent in the /start payload so
|
|
391
|
+
# serve can classify a streaming trace the moment it starts — before any
|
|
392
|
+
# span has landed — rather than inferring it from span shape later (CMP-537).
|
|
393
|
+
root_span_type: str | None = None
|
|
390
394
|
_batch_spans: list["SpanBuilder"] = field(default_factory=list)
|
|
391
395
|
|
|
392
396
|
tags: dict[str, str] = field(default_factory=dict)
|
|
@@ -443,6 +447,7 @@ class TraceBuilder:
|
|
|
443
447
|
"program_signature_hash": identity.format_signature_hash(self.program_signature_hash),
|
|
444
448
|
"signature_summary": self.signature_summary,
|
|
445
449
|
"component_signatures": self.component_signatures,
|
|
450
|
+
"root_span_type": self.root_span_type,
|
|
446
451
|
"project": self.project_tags[0] if self.project_tags else None,
|
|
447
452
|
"project_tags": self.project_tags if self.project_tags else [],
|
|
448
453
|
}
|
|
@@ -5,7 +5,7 @@ name = "cmpnd"
|
|
|
5
5
|
# it's new) — no git tags. Bump with `uv version --bump {minor,patch}` or by
|
|
6
6
|
# hand. Runtime `cmpnd.__version__` reads installed metadata, so it tracks
|
|
7
7
|
# this automatically. See docs/plans/2026-07-02-sdk-pypi-tag-free-publish.md.
|
|
8
|
-
version = "0.4.
|
|
8
|
+
version = "0.4.3"
|
|
9
9
|
description = "DSPy observability and deployment SDK for cmpnd"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -236,6 +236,19 @@ _RLM_FIXTURE = (
|
|
|
236
236
|
pathlib.Path(__file__).resolve().parents[2] / "serve" / "scripts" / "seed_fixtures" / "rlm_district_counter.json"
|
|
237
237
|
)
|
|
238
238
|
|
|
239
|
+
# Two flat-replay fixtures (CMP-187), shared with the local seeder
|
|
240
|
+
# (serve/scripts/seed_fixtures/traces.py make_source_categorizer_trace /
|
|
241
|
+
# make_tool_kind_demo_trace). Unlike _RLM_FIXTURE (pre-digested turns), these
|
|
242
|
+
# are a flat list of spans carrying lid/parent/start_ns + ingest fields, so
|
|
243
|
+
# _replay_flat_fixture just remaps ids + offsets timings. Loaded lazily so a
|
|
244
|
+
# missing file degrades to skipping that seed.
|
|
245
|
+
_SOURCE_CATEGORIZER_FIXTURE = (
|
|
246
|
+
pathlib.Path(__file__).resolve().parents[2] / "serve" / "scripts" / "seed_fixtures" / "rlm_source_categorizer.json"
|
|
247
|
+
)
|
|
248
|
+
_TOOL_KIND_DEMO_FIXTURE = (
|
|
249
|
+
pathlib.Path(__file__).resolve().parents[2] / "serve" / "scripts" / "seed_fixtures" / "tool_kind_demo.json"
|
|
250
|
+
)
|
|
251
|
+
|
|
239
252
|
|
|
240
253
|
def _notebook_trace(now: datetime.datetime) -> dict:
|
|
241
254
|
"""The captured "DistrictCounter" dspy.RLM run as a review-app seed trace
|
|
@@ -378,6 +391,75 @@ def _notebook_trace(now: datetime.datetime) -> dict:
|
|
|
378
391
|
}
|
|
379
392
|
|
|
380
393
|
|
|
394
|
+
def _replay_flat_fixture(
|
|
395
|
+
fixture_path: pathlib.Path,
|
|
396
|
+
now: datetime.datetime,
|
|
397
|
+
program_name: str,
|
|
398
|
+
project_tags: list[str],
|
|
399
|
+
request_preview: str,
|
|
400
|
+
response_preview: str,
|
|
401
|
+
) -> dict:
|
|
402
|
+
"""Replay a flat-format fixture (list of spans with lid/parent/start_ns/
|
|
403
|
+
duration_ns + ingest fields) as a review-app seed trace. Mirrors
|
|
404
|
+
make_source_categorizer_trace / make_tool_kind_demo_trace in the local
|
|
405
|
+
seeder, parameterized so both CMP-187 fixtures share one path. Mints a
|
|
406
|
+
fresh trace_id, maps every local id to a fresh uuid (parent_span_id via
|
|
407
|
+
that map, null for the root), offsets each span's start_ns by `now` with
|
|
408
|
+
the same at() helper the other seeds use, and copies the remaining fields
|
|
409
|
+
verbatim into the ingest span shape. Returns the same trace-payload dict
|
|
410
|
+
shape as _notebook_trace.
|
|
411
|
+
"""
|
|
412
|
+
fx = json.loads(fixture_path.read_text())
|
|
413
|
+
root = fx[0]
|
|
414
|
+
trace_id = str(uuid.uuid4())
|
|
415
|
+
total_ns = root["duration_ns"]
|
|
416
|
+
|
|
417
|
+
def at(ns: int) -> str:
|
|
418
|
+
return (now + datetime.timedelta(microseconds=ns / 1000)).isoformat()
|
|
419
|
+
|
|
420
|
+
id_map = {s["lid"]: str(uuid.uuid4()) for s in fx}
|
|
421
|
+
skip_keys = {"lid", "parent", "start_ns", "signature_summary"}
|
|
422
|
+
|
|
423
|
+
spans: list[dict] = []
|
|
424
|
+
total_tokens = 0
|
|
425
|
+
for s in fx:
|
|
426
|
+
parent_lid = s["parent"]
|
|
427
|
+
span: dict = {
|
|
428
|
+
"span_id": id_map[s["lid"]],
|
|
429
|
+
"trace_id": trace_id,
|
|
430
|
+
"parent_span_id": id_map[parent_lid] if parent_lid is not None else None,
|
|
431
|
+
"start_time": at(s["start_ns"]),
|
|
432
|
+
"end_time": at(s["start_ns"] + s["duration_ns"]),
|
|
433
|
+
}
|
|
434
|
+
for k, v in s.items():
|
|
435
|
+
if k not in skip_keys:
|
|
436
|
+
span[k] = v
|
|
437
|
+
if s["span_type"] == "lm_call":
|
|
438
|
+
total_tokens += s.get("total_tokens") or 0
|
|
439
|
+
spans.append(span)
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
"trace_id": trace_id,
|
|
443
|
+
"start_time": at(0),
|
|
444
|
+
"end_time": at(total_ns),
|
|
445
|
+
"duration_ns": total_ns,
|
|
446
|
+
"status": "ok",
|
|
447
|
+
"program_name": program_name, # DSPy module class → programs.module_class → sidebar badge
|
|
448
|
+
"program_version": "0.1.0",
|
|
449
|
+
"program_signature_hash": root["program_signature_hash"],
|
|
450
|
+
"signature_name": root["signature_name"],
|
|
451
|
+
"signature_input_fields": root["signature_input_fields"],
|
|
452
|
+
"signature_output_fields": root["signature_output_fields"],
|
|
453
|
+
"adapter_type": "ChatAdapter",
|
|
454
|
+
"request_preview": request_preview,
|
|
455
|
+
"response_preview": response_preview,
|
|
456
|
+
"project_tags": project_tags,
|
|
457
|
+
"total_tokens": total_tokens,
|
|
458
|
+
"span_count": len(spans),
|
|
459
|
+
"spans": spans,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
|
|
381
463
|
def _post(endpoint: str, path: str, payload, api_key: str):
|
|
382
464
|
body = json.dumps(payload).encode("utf-8")
|
|
383
465
|
req = urllib.request.Request(
|
|
@@ -494,6 +576,42 @@ def main() -> int:
|
|
|
494
576
|
# optimization run has none. Unlinked to any optimization run.
|
|
495
577
|
code, _ = _post(endpoint, "/api/v1/traces/batch", [_notebook_trace(now)], api_key)
|
|
496
578
|
log.info("seeded RLM notebook trace (status %s)", code)
|
|
579
|
+
|
|
580
|
+
# 4b) Plant the two CMP-187 tool-span demo traces so every review app
|
|
581
|
+
# shows RLM tool spans (SourceCategorizer, with a vision sub-LLM nested
|
|
582
|
+
# under Tool.inspect_page) and the function-tool vs module-tool
|
|
583
|
+
# differentiation (ToolKindDemo). Each degrades to a skip if its shared
|
|
584
|
+
# fixture is absent, rather than taking the seed down.
|
|
585
|
+
tool_demos: list[dict] = []
|
|
586
|
+
if _SOURCE_CATEGORIZER_FIXTURE.exists():
|
|
587
|
+
tool_demos.append(
|
|
588
|
+
_replay_flat_fixture(
|
|
589
|
+
_SOURCE_CATEGORIZER_FIXTURE,
|
|
590
|
+
now,
|
|
591
|
+
"RLM",
|
|
592
|
+
_SEED_TAGS + ["tool-demo"],
|
|
593
|
+
"vector_pdf source_table/glenn-pdf-p15.pdf (page 1)",
|
|
594
|
+
"candidate_columns, precinct",
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
else:
|
|
598
|
+
log.warning("skipping SourceCategorizer seed: fixture missing at %s", _SOURCE_CATEGORIZER_FIXTURE)
|
|
599
|
+
if _TOOL_KIND_DEMO_FIXTURE.exists():
|
|
600
|
+
tool_demos.append(
|
|
601
|
+
_replay_flat_fixture(
|
|
602
|
+
_TOOL_KIND_DEMO_FIXTURE,
|
|
603
|
+
now,
|
|
604
|
+
"ToolKindDemo",
|
|
605
|
+
_SEED_TAGS + ["tool-demo"],
|
|
606
|
+
"espresso place review (function-tool + module-tool)",
|
|
607
|
+
"The speaker loved the new downtown espresso place and its incredible pastries.",
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
else:
|
|
611
|
+
log.warning("skipping ToolKindDemo seed: fixture missing at %s", _TOOL_KIND_DEMO_FIXTURE)
|
|
612
|
+
if tool_demos:
|
|
613
|
+
code, _ = _post(endpoint, "/api/v1/traces/batch", tool_demos, api_key)
|
|
614
|
+
log.info("seeded %d CMP-187 tool-demo trace(s) (status %s)", len(tool_demos), code)
|
|
497
615
|
except Exception as exc: # noqa: BLE001 - non-gating; review apps are ephemeral
|
|
498
616
|
log.warning("review seed failed (non-gating): %s", exc)
|
|
499
617
|
|
|
@@ -32,7 +32,7 @@ from tests.e2e import fake_lm
|
|
|
32
32
|
E2E_ENDPOINT = os.getenv("CMPND_E2E_ENDPOINT", "http://localhost:3000")
|
|
33
33
|
CMPND_E2E_API_KEY = os.getenv("CMPND_E2E_API_KEY")
|
|
34
34
|
CMPND_E2E_GROQ_KEY = os.getenv("CMPND_E2E_GROQ_KEY")
|
|
35
|
-
E2E_MODEL = "groq/
|
|
35
|
+
E2E_MODEL = "groq/llama-3.3-70b-versatile"
|
|
36
36
|
|
|
37
37
|
# ---------------------------------------------------------------------------
|
|
38
38
|
# Skip logic — skip entire module when env vars are missing
|
|
@@ -80,7 +80,7 @@ class TestDeployReActWikipedia:
|
|
|
80
80
|
@pytest.fixture(scope="class")
|
|
81
81
|
def deployment(self):
|
|
82
82
|
lm = dspy.LM(
|
|
83
|
-
"groq/
|
|
83
|
+
"groq/llama-3.3-70b-versatile",
|
|
84
84
|
api_key=PLATFORM_GROQ_KEY or "",
|
|
85
85
|
max_tokens=300,
|
|
86
86
|
cache=False,
|
|
@@ -41,7 +41,7 @@ from tests._identity_helpers import program_hash_for_sig
|
|
|
41
41
|
PLATFORM_API_KEY = os.environ.get("PLATFORM_API_KEY")
|
|
42
42
|
PLATFORM_GROQ_KEY = os.environ.get("PLATFORM_GROQ_KEY")
|
|
43
43
|
PLATFORM_ENDPOINT = os.environ.get("PLATFORM_ENDPOINT", "http://localhost:3000")
|
|
44
|
-
STRESS_MODEL = "groq/
|
|
44
|
+
STRESS_MODEL = "groq/llama-3.3-70b-versatile"
|
|
45
45
|
|
|
46
46
|
NUM_THREADS = 5
|
|
47
47
|
ITERATIONS_PER_RUN = 3
|
|
@@ -34,7 +34,7 @@ from tests.stress._reliability import soft_content_check, tolerate_external_outa
|
|
|
34
34
|
PLATFORM_API_KEY = os.environ.get("PLATFORM_API_KEY")
|
|
35
35
|
PLATFORM_GROQ_KEY = os.environ.get("PLATFORM_GROQ_KEY")
|
|
36
36
|
PLATFORM_ENDPOINT = os.environ.get("PLATFORM_ENDPOINT", "http://localhost:3000")
|
|
37
|
-
STRESS_MODEL = "groq/
|
|
37
|
+
STRESS_MODEL = "groq/llama-3.3-70b-versatile"
|
|
38
38
|
|
|
39
39
|
pytestmark = pytest.mark.skipif(
|
|
40
40
|
not PLATFORM_API_KEY or not PLATFORM_GROQ_KEY,
|
|
@@ -1372,6 +1372,70 @@ class TestReplHistory:
|
|
|
1372
1372
|
assert "SUBMIT" in rlm_span.repl_history[1]["code"]
|
|
1373
1373
|
assert "output" not in rlm_span.repl_history[1]
|
|
1374
1374
|
|
|
1375
|
+
def test_rlm_predict_carries_previous_turn_output(self):
|
|
1376
|
+
"""Each RLM turn's PREDICT span carries the PRIOR turn's code output as
|
|
1377
|
+
the rlm.prev_output attribute, so a streaming reader can fill in the
|
|
1378
|
+
earlier notebook cells' output mid-run instead of waiting for the whole
|
|
1379
|
+
trajectory at finalize (CMP-537). The first turn has no predecessor, so
|
|
1380
|
+
it carries none; the final turn's output resolves at finalize."""
|
|
1381
|
+
from dspy.predict.rlm import REPLHistory
|
|
1382
|
+
|
|
1383
|
+
callback = self._make_callback()
|
|
1384
|
+
exported_spans = []
|
|
1385
|
+
|
|
1386
|
+
class MockExporter:
|
|
1387
|
+
def export_span(self, span):
|
|
1388
|
+
exported_spans.append(span)
|
|
1389
|
+
|
|
1390
|
+
def export_trace_start(self, trace):
|
|
1391
|
+
pass
|
|
1392
|
+
|
|
1393
|
+
def export_trace_finalize(self, trace):
|
|
1394
|
+
pass
|
|
1395
|
+
|
|
1396
|
+
def export_trace_stats(self, trace):
|
|
1397
|
+
pass
|
|
1398
|
+
|
|
1399
|
+
empty_history = REPLHistory()
|
|
1400
|
+
history_after_step0 = empty_history.append(
|
|
1401
|
+
reasoning="look at the data",
|
|
1402
|
+
code="print(context)",
|
|
1403
|
+
output="Sunny, 72F",
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
with mock.patch("cmpnd.exporter.get_exporter", return_value=MockExporter()):
|
|
1407
|
+
callback.on_module_start(
|
|
1408
|
+
call_id="rlm_root",
|
|
1409
|
+
instance=self._make_mock_rlm(),
|
|
1410
|
+
inputs={"question": "What is the weather?"},
|
|
1411
|
+
)
|
|
1412
|
+
callback.on_module_start(
|
|
1413
|
+
call_id="predict_0",
|
|
1414
|
+
instance=self._make_mock_predict(),
|
|
1415
|
+
inputs={"repl_history": empty_history},
|
|
1416
|
+
)
|
|
1417
|
+
callback.on_module_end(
|
|
1418
|
+
call_id="predict_0",
|
|
1419
|
+
outputs=self._make_prediction({"reasoning": "look at the data", "code": "print(context)"}),
|
|
1420
|
+
)
|
|
1421
|
+
callback.on_module_start(
|
|
1422
|
+
call_id="predict_1",
|
|
1423
|
+
instance=self._make_mock_predict(),
|
|
1424
|
+
inputs={"repl_history": history_after_step0},
|
|
1425
|
+
)
|
|
1426
|
+
callback.on_module_end(
|
|
1427
|
+
call_id="predict_1",
|
|
1428
|
+
outputs=self._make_prediction({"reasoning": "submit now", "code": "SUBMIT('Sunny, 72F')"}),
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
predict_spans = [s for s in exported_spans if s.span_type == SpanType.PREDICT]
|
|
1432
|
+
assert len(predict_spans) == 2
|
|
1433
|
+
turn0, turn1 = predict_spans[0], predict_spans[1]
|
|
1434
|
+
# Turn 0 has no predecessor → no prev_output.
|
|
1435
|
+
assert "rlm.prev_output" not in turn0.attributes
|
|
1436
|
+
# Turn 1 carries turn 0's code output.
|
|
1437
|
+
assert turn1.attributes.get("rlm.prev_output") == "Sunny, 72F"
|
|
1438
|
+
|
|
1375
1439
|
def test_repl_history_empty_for_non_rlm_modules(self):
|
|
1376
1440
|
"""Non-RLM modules should not have repl_history populated."""
|
|
1377
1441
|
callback = self._make_callback()
|
|
@@ -363,6 +363,16 @@ class TestTraceBuilder:
|
|
|
363
363
|
assert "total_tokens" not in d
|
|
364
364
|
assert "status" not in d
|
|
365
365
|
|
|
366
|
+
def test_to_start_dict_carries_root_span_type(self):
|
|
367
|
+
"""The root module's span type rides the start payload so serve can
|
|
368
|
+
classify a streaming trace at start, before any span lands (CMP-537)."""
|
|
369
|
+
trace = TraceBuilder(trace_id=uuid4())
|
|
370
|
+
trace.start_time = datetime(2026, 7, 22, tzinfo=timezone.utc)
|
|
371
|
+
trace.root_span_type = "rlm"
|
|
372
|
+
assert trace.to_start_dict()["root_span_type"] == "rlm"
|
|
373
|
+
# Unset (an older/non-root builder) sends null, not a crash.
|
|
374
|
+
assert TraceBuilder(trace_id=uuid4()).to_start_dict()["root_span_type"] is None
|
|
375
|
+
|
|
366
376
|
def test_to_finalize_dict(self):
|
|
367
377
|
"""to_finalize_dict includes timing, metrics, and previews only."""
|
|
368
378
|
trace = TraceBuilder(trace_id=uuid4())
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Tests for RLM tool-span instrumentation (_rlm_patch, CMP-187).
|
|
2
|
+
|
|
3
|
+
dspy.RLM hands its code interpreter the bare tool.func, bypassing
|
|
4
|
+
dspy.Tool.__call__'s @with_callbacks, so no tool span is created. The patch
|
|
5
|
+
wraps RLM._prepare_execution_tools so each user tool emits a TOOL span via the
|
|
6
|
+
active cmpnd callback while preserving the func's signature (the interpreter
|
|
7
|
+
builds its sandbox stub from inspect.signature).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
import dspy
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
import cmpnd
|
|
17
|
+
from cmpnd import _rlm_patch, callback, configuration, context, models
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(autouse=True)
|
|
21
|
+
def _configured():
|
|
22
|
+
configuration.configure(api_key="test_key", endpoint="http://localhost:8000")
|
|
23
|
+
yield
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _make_rlm():
|
|
27
|
+
def add_pages(first: int, last: int) -> int:
|
|
28
|
+
"""Sum a page range."""
|
|
29
|
+
return sum(range(first, last + 1))
|
|
30
|
+
|
|
31
|
+
return dspy.RLM("context, query -> answer", tools=[add_pages]), add_pages
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_patch_wraps_prepare_execution_tools_and_is_idempotent():
|
|
35
|
+
orig = dspy.RLM._prepare_execution_tools
|
|
36
|
+
_rlm_patch._patched = False
|
|
37
|
+
_rlm_patch.patch_rlm_tools()
|
|
38
|
+
wrapped = dspy.RLM._prepare_execution_tools
|
|
39
|
+
assert wrapped is not orig
|
|
40
|
+
_rlm_patch.patch_rlm_tools()
|
|
41
|
+
assert dspy.RLM._prepare_execution_tools is wrapped
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_wrapped_tool_preserves_signature():
|
|
45
|
+
"""The interpreter reads inspect.signature(fn) to build its sandbox stub."""
|
|
46
|
+
_rlm_patch._patched = False
|
|
47
|
+
_rlm_patch.patch_rlm_tools()
|
|
48
|
+
|
|
49
|
+
rlm, add_pages = _make_rlm()
|
|
50
|
+
tools = rlm._prepare_execution_tools()
|
|
51
|
+
shim = tools["add_pages"]
|
|
52
|
+
assert inspect.signature(shim) == inspect.signature(add_pages)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_tool_span_created_and_nested_under_rlm(monkeypatch):
|
|
56
|
+
"""With a callback and an RLM.forward span on the stack, invoking a wrapped
|
|
57
|
+
tool creates a TOOL span parented to the RLM span."""
|
|
58
|
+
_rlm_patch._patched = False
|
|
59
|
+
_rlm_patch.patch_rlm_tools()
|
|
60
|
+
|
|
61
|
+
tracked = []
|
|
62
|
+
cb = callback.CmpndCallback()
|
|
63
|
+
monkeypatch.setattr(cb, "_track_span", lambda span: tracked.append(span))
|
|
64
|
+
monkeypatch.setattr(dspy.settings, "callbacks", [cb], raising=False)
|
|
65
|
+
|
|
66
|
+
context.clear_context()
|
|
67
|
+
context.set_current_trace(models.TraceBuilder(trace_id=uuid.uuid4()))
|
|
68
|
+
rlm_span = context.create_child_span("RLM.forward", models.SpanType.MODULE)
|
|
69
|
+
context.push_span(rlm_span)
|
|
70
|
+
try:
|
|
71
|
+
rlm, _ = _make_rlm()
|
|
72
|
+
tools = rlm._prepare_execution_tools()
|
|
73
|
+
result = tools["add_pages"](first=1, last=3)
|
|
74
|
+
# the tool span is unwound after the tool returns, leaving the RLM span
|
|
75
|
+
span_after_tool = context.get_current_span()
|
|
76
|
+
finally:
|
|
77
|
+
context.pop_span()
|
|
78
|
+
|
|
79
|
+
assert result == 6
|
|
80
|
+
assert span_after_tool is rlm_span
|
|
81
|
+
tool_spans = [s for s in tracked if s.span_type == models.SpanType.TOOL]
|
|
82
|
+
assert len(tool_spans) == 1
|
|
83
|
+
span = tool_spans[0]
|
|
84
|
+
assert span.name == "Tool.add_pages"
|
|
85
|
+
assert span.parent_span_id == rlm_span.span_id
|
|
86
|
+
assert span.trace_id == rlm_span.trace_id
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_multiple_callbacks_emit_one_tool_span(monkeypatch):
|
|
90
|
+
"""Two registered CmpndCallbacks share one span-stack contextvar, so the
|
|
91
|
+
shim dispatches to only the first — one tool span, stack balanced, no
|
|
92
|
+
tool-span-nested-under-tool-span corruption."""
|
|
93
|
+
_rlm_patch._patched = False
|
|
94
|
+
_rlm_patch.patch_rlm_tools()
|
|
95
|
+
|
|
96
|
+
tracked_a, tracked_b = [], []
|
|
97
|
+
cb_a = callback.CmpndCallback()
|
|
98
|
+
cb_b = callback.CmpndCallback()
|
|
99
|
+
monkeypatch.setattr(cb_a, "_track_span", lambda span: tracked_a.append(span))
|
|
100
|
+
monkeypatch.setattr(cb_b, "_track_span", lambda span: tracked_b.append(span))
|
|
101
|
+
monkeypatch.setattr(dspy.settings, "callbacks", [cb_a, cb_b], raising=False)
|
|
102
|
+
|
|
103
|
+
context.clear_context()
|
|
104
|
+
context.set_current_trace(models.TraceBuilder(trace_id=uuid.uuid4()))
|
|
105
|
+
rlm_span = context.create_child_span("RLM.forward", models.SpanType.MODULE)
|
|
106
|
+
context.push_span(rlm_span)
|
|
107
|
+
try:
|
|
108
|
+
rlm, _ = _make_rlm()
|
|
109
|
+
tools = rlm._prepare_execution_tools()
|
|
110
|
+
tools["add_pages"](first=1, last=3)
|
|
111
|
+
span_after_tool = context.get_current_span()
|
|
112
|
+
finally:
|
|
113
|
+
context.pop_span()
|
|
114
|
+
|
|
115
|
+
assert span_after_tool is rlm_span # stack unwound to exactly the RLM span
|
|
116
|
+
tool_spans = [s for s in tracked_a + tracked_b if s.span_type == models.SpanType.TOOL]
|
|
117
|
+
assert len(tool_spans) == 1
|
|
118
|
+
assert tool_spans[0].parent_span_id == rlm_span.span_id
|
|
119
|
+
assert not tracked_b # only the first callback was dispatched to
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_tracing_off_calls_func_without_span():
|
|
123
|
+
"""No cmpnd callback registered -> func still runs, no span emitted."""
|
|
124
|
+
_rlm_patch._patched = False
|
|
125
|
+
_rlm_patch.patch_rlm_tools()
|
|
126
|
+
|
|
127
|
+
context.clear_context()
|
|
128
|
+
original = list(dspy.settings.get("callbacks", []))
|
|
129
|
+
dspy.settings.callbacks = []
|
|
130
|
+
try:
|
|
131
|
+
rlm, _ = _make_rlm()
|
|
132
|
+
tools = rlm._prepare_execution_tools()
|
|
133
|
+
assert tools["add_pages"](first=2, last=4) == 9
|
|
134
|
+
assert context.get_current_span() is None
|
|
135
|
+
finally:
|
|
136
|
+
dspy.settings.callbacks = original
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_raising_tool_closes_span_and_reraises(monkeypatch):
|
|
140
|
+
_rlm_patch._patched = False
|
|
141
|
+
_rlm_patch.patch_rlm_tools()
|
|
142
|
+
|
|
143
|
+
tracked = []
|
|
144
|
+
cb = callback.CmpndCallback()
|
|
145
|
+
monkeypatch.setattr(cb, "_track_span", lambda span: tracked.append(span))
|
|
146
|
+
monkeypatch.setattr(dspy.settings, "callbacks", [cb], raising=False)
|
|
147
|
+
|
|
148
|
+
def boom(x: int) -> int:
|
|
149
|
+
raise ValueError("nope")
|
|
150
|
+
|
|
151
|
+
rlm = dspy.RLM("context, query -> answer", tools=[boom])
|
|
152
|
+
|
|
153
|
+
context.clear_context()
|
|
154
|
+
context.set_current_trace(models.TraceBuilder(trace_id=uuid.uuid4()))
|
|
155
|
+
rlm_span = context.create_child_span("RLM.forward", models.SpanType.MODULE)
|
|
156
|
+
context.push_span(rlm_span)
|
|
157
|
+
try:
|
|
158
|
+
tools = rlm._prepare_execution_tools()
|
|
159
|
+
raised = False
|
|
160
|
+
try:
|
|
161
|
+
tools["boom"](x=1)
|
|
162
|
+
except ValueError:
|
|
163
|
+
raised = True
|
|
164
|
+
assert raised
|
|
165
|
+
# the tool span is closed even when the tool raises
|
|
166
|
+
span_after_tool = context.get_current_span()
|
|
167
|
+
finally:
|
|
168
|
+
context.pop_span()
|
|
169
|
+
|
|
170
|
+
assert span_after_tool is rlm_span
|
|
171
|
+
tool_spans = [s for s in tracked if s.span_type == models.SpanType.TOOL]
|
|
172
|
+
assert len(tool_spans) == 1
|
|
173
|
+
assert tool_spans[0].status == models.TraceStatus.ERROR
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_auto_instrument_installs_rlm_patch():
|
|
177
|
+
_rlm_patch._patched = False
|
|
178
|
+
orig = dspy.RLM._prepare_execution_tools
|
|
179
|
+
cmpnd.auto_instrument()
|
|
180
|
+
assert dspy.RLM._prepare_execution_tools is not orig
|