cmpnd 0.18.0__tar.gz → 0.18.2__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.
Files changed (294) hide show
  1. {cmpnd-0.18.0 → cmpnd-0.18.2}/PKG-INFO +1 -1
  2. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/_rlm_patch.py +12 -0
  3. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/callback.py +294 -1
  4. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/trace_render.py +1 -0
  5. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/models.py +8 -2
  6. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/optimization.py +18 -1
  7. {cmpnd-0.18.0 → cmpnd-0.18.2}/docs/sdk-instrumentation.md +17 -2
  8. {cmpnd-0.18.0 → cmpnd-0.18.2}/pyproject.toml +1 -1
  9. cmpnd-0.18.2/stubs/dspy/utils/callback.pyi +40 -0
  10. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/fake_lm.py +62 -0
  11. cmpnd-0.18.2/tests/e2e/test_interpreter_spans.py +112 -0
  12. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/integration/hosted_optimize_probe.py +114 -5
  13. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_callback.py +76 -0
  14. cmpnd-0.18.2/tests/test_interpreter_callback.py +350 -0
  15. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_mcp_tools.py +9 -3
  16. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_optimize_lms.py +27 -0
  17. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_trace_render.py +1 -0
  18. {cmpnd-0.18.0 → cmpnd-0.18.2}/uv.lock +52 -133
  19. cmpnd-0.18.0/stubs/dspy/utils/callback.pyi +0 -19
  20. {cmpnd-0.18.0 → cmpnd-0.18.2}/.gitignore +0 -0
  21. {cmpnd-0.18.0 → cmpnd-0.18.2}/CLAUDE.md +0 -0
  22. {cmpnd-0.18.0 → cmpnd-0.18.2}/README.md +0 -0
  23. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/__init__.py +0 -0
  24. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/_gepa_patch.py +0 -0
  25. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/_program_patch.py +0 -0
  26. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/__init__.py +0 -0
  27. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/api_client.py +0 -0
  28. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/__init__.py +0 -0
  29. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/admin.py +0 -0
  30. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/auth.py +0 -0
  31. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/datasets.py +0 -0
  32. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/deployments.py +0 -0
  33. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/evals.py +0 -0
  34. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/login.py +0 -0
  35. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/optimizations.py +0 -0
  36. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/org.py +0 -0
  37. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/programs.py +0 -0
  38. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/provider_credentials.py +0 -0
  39. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/signatures.py +0 -0
  40. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/tool_servers.py +0 -0
  41. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/top.py +0 -0
  42. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/commands/traces.py +0 -0
  43. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/credentials.py +0 -0
  44. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/deprecation.py +0 -0
  45. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/pagination.py +0 -0
  46. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/shell.py +0 -0
  47. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/cli/verbs.py +0 -0
  48. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/configuration.py +0 -0
  49. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/context.py +0 -0
  50. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/dataset_sync.py +0 -0
  51. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/datasets.py +0 -0
  52. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/decorators.py +0 -0
  53. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/deployment.py +0 -0
  54. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/eval_handler.py +0 -0
  55. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/execution.py +0 -0
  56. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/exporter.py +0 -0
  57. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/exporter_http.py +0 -0
  58. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/helpers.py +0 -0
  59. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/identity.py +0 -0
  60. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/imaging.py +0 -0
  61. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/ir/__init__.py +0 -0
  62. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/ir/decode.py +0 -0
  63. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/ir/encode.py +0 -0
  64. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/ir/hash.py +0 -0
  65. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/ir/program.py +0 -0
  66. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/ir/reflection.py +0 -0
  67. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/ir/types.py +0 -0
  68. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/ir/xxh64.py +0 -0
  69. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/mcp_servers.py +0 -0
  70. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/optimizers/__init__.py +0 -0
  71. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/optimizers/gepa.py +0 -0
  72. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/optimizers/gepa_callback.py +0 -0
  73. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/optimizers/resume.py +0 -0
  74. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/optimizers/tracker.py +0 -0
  75. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/packaging.py +0 -0
  76. {cmpnd-0.18.0 → cmpnd-0.18.2}/cmpnd/retry.py +0 -0
  77. {cmpnd-0.18.0 → cmpnd-0.18.2}/docs/2026-04-02-code-review.md +0 -0
  78. {cmpnd-0.18.0 → cmpnd-0.18.2}/docs/cli.md +0 -0
  79. {cmpnd-0.18.0 → cmpnd-0.18.2}/docs/custom-proposer.md +0 -0
  80. {cmpnd-0.18.0 → cmpnd-0.18.2}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
  81. {cmpnd-0.18.0 → cmpnd-0.18.2}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
  82. {cmpnd-0.18.0 → cmpnd-0.18.2}/examples/generalizing_proposer.py +0 -0
  83. {cmpnd-0.18.0 → cmpnd-0.18.2}/examples/local_ollama.py +0 -0
  84. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/LINEAGE.md +0 -0
  85. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/README.md +0 -0
  86. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/cmpnd_ingest/__init__.py +0 -0
  87. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/cmpnd_ingest/bodies.py +0 -0
  88. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/cmpnd_ingest/exporter_http.py +0 -0
  89. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/cmpnd_ingest/protocols.py +0 -0
  90. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/cmpnd_ingest/py.typed +0 -0
  91. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/cmpnd_ingest/request_log.py +0 -0
  92. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/cmpnd_ingest/retry.py +0 -0
  93. {cmpnd-0.18.0 → cmpnd-0.18.2}/ingest/pyproject.toml +0 -0
  94. {cmpnd-0.18.0 → cmpnd-0.18.2}/scripts/audit-sdist.sh +0 -0
  95. {cmpnd-0.18.0 → cmpnd-0.18.2}/scripts/capture_review_run.py +0 -0
  96. {cmpnd-0.18.0 → cmpnd-0.18.2}/scripts/check_release_artifacts.py +0 -0
  97. {cmpnd-0.18.0 → cmpnd-0.18.2}/scripts/fixtures/review_run.json.gz +0 -0
  98. {cmpnd-0.18.0 → cmpnd-0.18.2}/scripts/gepa_resume_bug.py +0 -0
  99. {cmpnd-0.18.0 → cmpnd-0.18.2}/scripts/require_version_bump.py +0 -0
  100. {cmpnd-0.18.0 → cmpnd-0.18.2}/scripts/seed_review_run.py +0 -0
  101. {cmpnd-0.18.0 → cmpnd-0.18.2}/scripts/test-in-linux.sh +0 -0
  102. {cmpnd-0.18.0 → cmpnd-0.18.2}/stubs/dspy/__init__.pyi +0 -0
  103. {cmpnd-0.18.0 → cmpnd-0.18.2}/stubs/dspy/primitives/__init__.pyi +0 -0
  104. {cmpnd-0.18.0 → cmpnd-0.18.2}/stubs/dspy/primitives/prediction.pyi +0 -0
  105. {cmpnd-0.18.0 → cmpnd-0.18.2}/stubs/dspy/utils/__init__.pyi +0 -0
  106. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/__init__.py +0 -0
  107. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/_exporter_helpers.py +0 -0
  108. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/_fake_clock.py +0 -0
  109. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/_identity_helpers.py +0 -0
  110. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/conftest.py +0 -0
  111. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/__init__.py +0 -0
  112. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/committee_task.py +0 -0
  113. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/conftest.py +0 -0
  114. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/data/committee_train.ndjson.gz +0 -0
  115. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/data/committee_val.ndjson.gz +0 -0
  116. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/helpers.py +0 -0
  117. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/target_capabilities.py +0 -0
  118. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_capture_prompts.py +0 -0
  119. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_composite_module.py +0 -0
  120. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_dataset_auto_creation.py +0 -0
  121. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_deploy_execute.py +0 -0
  122. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_deploy_programs.py +0 -0
  123. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_edge_cases.py +0 -0
  124. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_example_hash.py +0 -0
  125. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_gepa_optimization.py +0 -0
  126. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_lm_accuracy.py +0 -0
  127. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_module_tracing.py +0 -0
  128. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_optimization_lineage.py +0 -0
  129. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_optimization_metric_identity.py +0 -0
  130. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_optimize_failures.py +0 -0
  131. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_optimize_loop.py +0 -0
  132. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_optimize_review_committee.py +0 -0
  133. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
  134. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_parallel_export.py +0 -0
  135. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_project_attribution.py +0 -0
  136. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_react_tool_spans.py +0 -0
  137. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_save_load.py +0 -0
  138. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_trace_structure.py +0 -0
  139. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/e2e/test_user_attribution.py +0 -0
  140. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/01_predict_qa.json +0 -0
  141. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
  142. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
  143. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/04_predict_richer_types.json +0 -0
  144. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
  145. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/06_multichaincomparison.json +0 -0
  146. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/07_program_of_thought.json +0 -0
  147. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/08_opaque_module.json +0 -0
  148. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
  149. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/10_react.json +0 -0
  150. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/11_refine.json +0 -0
  151. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/12_retrieve.json +0 -0
  152. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/13_knn.json +0 -0
  153. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/14_parallel.json +0 -0
  154. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/identity_vectors/README.md +0 -0
  155. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/ingest_conformance.py +0 -0
  156. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/integration/__init__.py +0 -0
  157. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/integration/conftest.py +0 -0
  158. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/integration/optimization_run_probe.py +0 -0
  159. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/integration/test_cli_deploy_smoketest.py +0 -0
  160. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/integration/test_logs_renders_traces.py +0 -0
  161. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/integration/test_optimization_run.py +0 -0
  162. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/integration/test_optimizing_progress.py +0 -0
  163. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/integration/test_unhappy_path.py +0 -0
  164. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/load/__init__.py +0 -0
  165. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/load/__main__.py +0 -0
  166. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/load/drain.py +0 -0
  167. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/load/ingest.py +0 -0
  168. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/load/latency_transport.py +0 -0
  169. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/load/rlm_shape.py +0 -0
  170. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/load/sweep.py +0 -0
  171. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/load/test_rlm_shape.py +0 -0
  172. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/__init__.py +0 -0
  173. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/conftest.py +0 -0
  174. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/seed_parity.py +0 -0
  175. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_auth.py +0 -0
  176. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_datasets.py +0 -0
  177. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_deployments.py +0 -0
  178. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_evals.py +0 -0
  179. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_health.py +0 -0
  180. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_modules.py +0 -0
  181. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_optimizations.py +0 -0
  182. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_programs.py +0 -0
  183. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_signatures.py +0 -0
  184. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_trace_streaming.py +0 -0
  185. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/parity/test_traces.py +0 -0
  186. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/__init__.py +0 -0
  187. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/_provider.py +0 -0
  188. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/_reliability.py +0 -0
  189. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/_result.py +0 -0
  190. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/mint_bedrock_token.py +0 -0
  191. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_deploy_programs.py +0 -0
  192. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_multi_client_stress.py +0 -0
  193. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_optimize_client_committee.py +0 -0
  194. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_optimize_loop.py +0 -0
  195. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_optimize_loop_wasm.py +0 -0
  196. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_optimize_stress.py +0 -0
  197. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_provider_roles.py +0 -0
  198. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_real_provider_twins.py +0 -0
  199. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_reliability.py +0 -0
  200. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_reliability_classify.py +0 -0
  201. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_rlm_wasm.py +0 -0
  202. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/stress/test_trace_search.py +0 -0
  203. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_candidate_lineage.py +0 -0
  204. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/__init__.py +0 -0
  205. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/conftest.py +0 -0
  206. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_admin.py +0 -0
  207. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_api_client.py +0 -0
  208. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_auth.py +0 -0
  209. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_credentials.py +0 -0
  210. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_datasets.py +0 -0
  211. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_deployments.py +0 -0
  212. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_evals.py +0 -0
  213. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_login.py +0 -0
  214. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_main.py +0 -0
  215. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_optimizations.py +0 -0
  216. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_org.py +0 -0
  217. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_pagination.py +0 -0
  218. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_parity.py +0 -0
  219. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_programs.py +0 -0
  220. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_provider_credentials.py +0 -0
  221. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_schema_sample.py +0 -0
  222. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_shell.py +0 -0
  223. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_signatures.py +0 -0
  224. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_tool_servers.py +0 -0
  225. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_top.py +0 -0
  226. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_traces.py +0 -0
  227. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cli/test_traces_search_wire.py +0 -0
  228. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_clustering/__init__.py +0 -0
  229. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_clustering/conftest.py +0 -0
  230. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_clustering/test_edge_cases.py +0 -0
  231. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_clustering/test_hash_consistency.py +0 -0
  232. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_clustering/test_type_conversion.py +0 -0
  233. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_cmp551_grouping_identity.py +0 -0
  234. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_configuration.py +0 -0
  235. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_context.py +0 -0
  236. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_context_propagation.py +0 -0
  237. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_dataset_polling.py +0 -0
  238. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_dataset_sync.py +0 -0
  239. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_datasets.py +0 -0
  240. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_decorators.py +0 -0
  241. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_deploy.py +0 -0
  242. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_deploy_mcp_tool_references.py +0 -0
  243. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_eval.py +0 -0
  244. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_eval_start.py +0 -0
  245. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_eval_trace_grouping.py +0 -0
  246. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_exception_paths.py +0 -0
  247. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_execute.py +0 -0
  248. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_exporter.py +0 -0
  249. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_exporter_queue.py +0 -0
  250. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_exporter_real.py +0 -0
  251. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_fake_lm.py +0 -0
  252. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_gepa_callback.py +0 -0
  253. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_gepa_integration.py +0 -0
  254. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_gepa_resume.py +0 -0
  255. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_identity.py +0 -0
  256. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_imaging.py +0 -0
  257. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_import_weight.py +0 -0
  258. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_ingest_conformance.py +0 -0
  259. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_ingest_second_producer.py +0 -0
  260. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_integration.py +0 -0
  261. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_ir_encoder_properties.py +0 -0
  262. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_ir_golden_vectors.py +0 -0
  263. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_ir_roundtrip.py +0 -0
  264. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_models.py +0 -0
  265. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_optimization_progress.py +0 -0
  266. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_optimization_start.py +0 -0
  267. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_optimize.py +0 -0
  268. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_optimize_budget.py +0 -0
  269. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_optimize_lift.py +0 -0
  270. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_optimize_proposer.py +0 -0
  271. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_optimize_reflection_lm.py +0 -0
  272. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_optimize_terminal_failure.py +0 -0
  273. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_packaging.py +0 -0
  274. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_packaging_callable_source.py +0 -0
  275. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_packaging_mcp_tools.py +0 -0
  276. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_packaging_program_source.py +0 -0
  277. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_poll_until.py +0 -0
  278. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_program_lineage_persist.py +0 -0
  279. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_project_payload.py +0 -0
  280. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_reflector_properties.py +0 -0
  281. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_reflector_smoke.py +0 -0
  282. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_repro_reactv2_bugs.py +0 -0
  283. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_require_version_bump.py +0 -0
  284. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_retry.py +0 -0
  285. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_reversible_failures.py +0 -0
  286. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_rlm_patch.py +0 -0
  287. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_schemas.py +0 -0
  288. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_seed_review_run.py +0 -0
  289. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_stats_export.py +0 -0
  290. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_tracked_gepa.py +0 -0
  291. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_tracker_enrich.py +0 -0
  292. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_truncation.py +0 -0
  293. {cmpnd-0.18.0 → cmpnd-0.18.2}/tests/test_xxh64.py +0 -0
  294. {cmpnd-0.18.0 → cmpnd-0.18.2}/todo.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cmpnd
3
- Version: 0.18.0
3
+ Version: 0.18.2
4
4
  Summary: DSPy observability and deployment SDK for cmpnd
5
5
  Project-URL: Homepage, https://cmpnd.ai
6
6
  Author-email: cmpnd <hello@cmpnd.ai>
@@ -1,5 +1,17 @@
1
1
  """Emit tool spans for ``dspy.RLM`` tool calls.
2
2
 
3
+ DEPRECATED: redundant on DSPy >= 3.3.0. DSPy 3.3.0 added
4
+ ``RLM._make_interpreter_tool``, which routes each user tool through
5
+ ``dspy.Tool.__call__`` (``@with_callbacks``), so DSPy's own path now fires the
6
+ tool hooks this shim was written to supply, and the interpreter-callback
7
+ instrumentation (``on_interpreter_tool_call_*``) synthesizes a span for the
8
+ ``llm_query`` builtins besides. On >= 3.3.0 this patch also *replaces* DSPy's
9
+ path, discarding the pydantic kwarg coercion and async ``tool.acall`` routing
10
+ DSPy intends. It stays active for backwards compatibility with older DSPy and
11
+ because retiring it is a separable behavior change (user-tool kwargs would go
12
+ back through DSPy's coercion); remove it once the supported DSPy floor is
13
+ >= 3.3.0.
14
+
3
15
  cmpnd creates a ``TOOL`` span only from DSPy's ``on_tool_start``/``on_tool_end``
4
16
  callbacks, and those fire only through ``dspy.Tool.__call__`` (decorated
5
17
  ``@with_callbacks``). ``dspy.RLM._prepare_execution_tools`` hands the code
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextvars
5
6
  import logging
6
7
  from datetime import datetime, timezone
7
8
  from typing import Any
@@ -11,6 +12,16 @@ from . import configuration, context, eval_handler, exporter, helpers, identity,
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
15
+ # The interpreter tool-call frame active in the current execution context. A real
16
+ # dspy.Tool fires on_tool_start synchronously inside the interpreter's invoke_tool,
17
+ # so it reads this to mark its enclosing frame — per-thread and per-async-task,
18
+ # unlike a callback-wide stack, so two RLM runs on different threads (e.g. under
19
+ # dspy.Evaluate) cannot mark each other's frame. Default None means no interpreter
20
+ # tool call is in scope, so an ordinary dspy.Tool (a ReAct action) marks nothing.
21
+ _current_interpreter_tool_frame: contextvars.ContextVar[dict[str, Any] | None] = contextvars.ContextVar(
22
+ "cmpnd_interpreter_tool_frame", default=None
23
+ )
24
+
14
25
 
15
26
  def trace_id(prediction: Any) -> str | None:
16
27
  """Return the cmpnd trace id for a prediction returned by an instrumented program.
@@ -51,6 +62,35 @@ def _get_dspy():
51
62
  return _dspy, _BaseCallback
52
63
 
53
64
 
65
+ # Hook names already reported as unhandled, so the debug line below is one per
66
+ # hook rather than one per dispatch.
67
+ _reported_unhandled_hooks: set[str] = set()
68
+
69
+
70
+ def _ignore_hook(*_args: Any, **_kwargs: Any) -> None:
71
+ """Stand in for a DSPy callback hook cmpnd does not instrument.
72
+
73
+ DSPy invokes a start hook as ``(call_id=, instance=, inputs=)`` and an end
74
+ hook as ``(call_id=, outputs=, exception=)``; accepting anything keeps this
75
+ correct for whatever shape a later hook takes.
76
+ """
77
+
78
+
79
+ def _dspy_declares_hook(name: str) -> bool:
80
+ """Report whether the installed DSPy declares ``name`` on ``BaseCallback``.
81
+
82
+ Asking DSPy rather than matching a name pattern is what keeps the fallback
83
+ in :meth:`CmpndCallback.__getattr__` narrow: it absorbs the hooks DSPy
84
+ actually dispatches and nothing else. When dspy is absent the answer is no
85
+ — nothing is dispatching callbacks in that case.
86
+ """
87
+ try:
88
+ _, base = _get_dspy()
89
+ except ImportError:
90
+ return False
91
+ return callable(getattr(base, name, None))
92
+
93
+
54
94
  def _cached_prompt_tokens(usage: Any) -> int:
55
95
  """Read the provider-side prompt-cache read count off a LiteLLM usage dict.
56
96
 
@@ -161,6 +201,21 @@ class _CallbackBase:
161
201
  # Track which traces use streaming export (thread-safe lookup for child spans)
162
202
  self._streaming_traces: dict[str, bool] = {}
163
203
 
204
+ # In-flight interpreter tool-call frames, keyed by DSPy call_id (unique per
205
+ # call, so this map is thread-safe the way _call_id_to_span is). An
206
+ # interpreter tool call fires on_interpreter_tool_call_*, and a real
207
+ # dspy.Tool fires an on_tool_* pair nested inside it; the frame lets the
208
+ # tool_call end suppress its own synthesized span when a real tool span
209
+ # already covered the call. Which frame a nested on_tool_start marks is
210
+ # carried by the _current_interpreter_tool_frame contextvar, not by this
211
+ # map, so overlapping runs on different threads stay isolated. See
212
+ # _InterpreterMixin.
213
+ self._interpreter_tool_frames: dict[str, dict[str, Any]] = {}
214
+
215
+ # call_id -> start time for interpreter startup/shutdown, whose duration
216
+ # folds into an attribute on the enclosing span rather than its own span.
217
+ self._interpreter_lifecycle_starts: dict[str, datetime] = {}
218
+
164
219
  # Ensure exporter is running
165
220
  exporter.ensure_exporter_started()
166
221
 
@@ -1014,6 +1069,14 @@ class _ToolMixin(_CallbackBase):
1014
1069
  if tool_name == "finish":
1015
1070
  return
1016
1071
 
1072
+ # A real dspy.Tool span is firing beneath an interpreter tool call, so the
1073
+ # tool_call end must not synthesize its own row for the same call. Mark the
1074
+ # frame active in THIS execution context (the contextvar), not a global
1075
+ # stack top, so a concurrent run on another thread is unaffected.
1076
+ active_frame = _current_interpreter_tool_frame.get()
1077
+ if active_frame is not None:
1078
+ active_frame["had_real_tool"] = True
1079
+
1017
1080
  span = context.create_child_span(f"Tool.{tool_name}", models.SpanType.TOOL)
1018
1081
 
1019
1082
  # Tool-specific attributes
@@ -1071,7 +1134,214 @@ class _ToolMixin(_CallbackBase):
1071
1134
  exp.export_span(span)
1072
1135
 
1073
1136
 
1074
- class CmpndCallback(_ModuleMixin, _LMMixin, _AdapterMixin, _ToolMixin):
1137
+ class _InterpreterMixin(_CallbackBase):
1138
+ """DSPy CodeInterpreter callbacks — one span per REPL code execution.
1139
+
1140
+ DSPy >=3.3.1 decorates ``PythonInterpreter.execute`` with ``@with_callbacks``,
1141
+ so a module that runs code in the interpreter — ``RLM``, ``ProgramOfThought``,
1142
+ ``CodeAct``, ``Flex``, every module reaching it through ``_create_interpreter``
1143
+ — dispatches ``on_interpreter_execute_start`` / ``on_interpreter_execute_end``
1144
+ around each cell. One ``interpreter_execute`` span records the cell: the code
1145
+ in, its captured stdout out, wall time, and the error type when the cell
1146
+ raised. The ``SUBMIT()`` result comes back wrapped in ``FinalOutput``, recorded
1147
+ distinctly from stdout so a reader can tell the run's answer from a cell's
1148
+ print output.
1149
+ """
1150
+
1151
+ def on_interpreter_execute_start(
1152
+ self,
1153
+ call_id: str,
1154
+ instance: Any,
1155
+ inputs: dict[str, Any],
1156
+ ) -> None:
1157
+ """Handle a CodeInterpreter code execution start — one REPL cell."""
1158
+ # An interpreter event with no active trace and no active span — a pooled
1159
+ # pre-warm, or a caller-owned interpreter used outside a program call —
1160
+ # has no span to attach to and no trace to root, so drop it.
1161
+ if context.get_current_trace() is None and context.get_current_span() is None:
1162
+ return
1163
+
1164
+ span = context.create_child_span(f"{instance.__class__.__name__}.execute", models.SpanType.INTERPRETER_EXECUTE)
1165
+
1166
+ if self._capture_inputs:
1167
+ code = inputs.get("code")
1168
+ if code is not None:
1169
+ span.set_inputs({"code": helpers.truncate_field(code, self._max_input_field_size, depth=1)})
1170
+
1171
+ self._call_id_to_span[call_id] = span
1172
+ context.push_span(span)
1173
+
1174
+ def on_interpreter_execute_end(
1175
+ self,
1176
+ call_id: str,
1177
+ outputs: Any | None,
1178
+ exception: BaseException | None = None,
1179
+ ) -> None:
1180
+ """Handle a CodeInterpreter code execution end."""
1181
+ span = self._call_id_to_span.pop(call_id, None)
1182
+ if not span:
1183
+ return
1184
+
1185
+ span.end_time = datetime.now(timezone.utc)
1186
+
1187
+ if exception:
1188
+ span.set_error(exception)
1189
+ else:
1190
+ span.status = models.TraceStatus.OK
1191
+ if self._capture_outputs and outputs is not None:
1192
+ # execute() returns FinalOutput(...) for a SUBMIT() cell and the
1193
+ # captured stdout string for an ordinary cell. Record the two
1194
+ # distinctly: FinalOutput carries the run's answer, stdout the
1195
+ # cell's print output.
1196
+ if type(outputs).__name__ == "FinalOutput":
1197
+ final = getattr(outputs, "output", outputs)
1198
+ span.set_outputs({"final_output": helpers.truncate_outputs(final, self._max_output_field_size)})
1199
+ span.attributes["interpreter.submitted"] = "true"
1200
+ else:
1201
+ span.set_outputs({"stdout": helpers.truncate_outputs(outputs, self._max_output_field_size)})
1202
+
1203
+ context.pop_span()
1204
+ self._track_span(span)
1205
+
1206
+ if self._should_stream_span(span):
1207
+ exp = exporter.get_exporter()
1208
+ if exp:
1209
+ exp.export_span(span)
1210
+
1211
+ def on_interpreter_tool_call_start(
1212
+ self,
1213
+ call_id: str,
1214
+ instance: Any,
1215
+ inputs: dict[str, Any],
1216
+ ) -> None:
1217
+ """Handle a tool call made from interpreter code (invoke_tool).
1218
+
1219
+ A frame is opened but no span is created yet: a real dspy.Tool nested in
1220
+ this call emits its own TOOL span (nested under the enclosing execute
1221
+ span, the current top of the stack), and the end handler synthesizes a
1222
+ TOOL span only when no real tool span covered the call — the case of a
1223
+ builtin like llm_query, which fires no tool hook.
1224
+ """
1225
+ del instance # DSPy keyword-calls this hook; the interpreter instance is unused
1226
+ if context.get_current_trace() is None and context.get_current_span() is None:
1227
+ return
1228
+ frame: dict[str, Any] = {
1229
+ "tool_name": inputs.get("tool_name", "unknown"),
1230
+ "inputs": inputs.get("kwargs", {}),
1231
+ "had_real_tool": False,
1232
+ "parent_span": context.get_current_span(),
1233
+ "start_time": datetime.now(timezone.utc),
1234
+ }
1235
+ self._interpreter_tool_frames[call_id] = frame
1236
+ # Publish this frame as the one a nested on_tool_start marks, in this
1237
+ # context only. The token restores the prior frame (nested tool calls) on
1238
+ # end; DSPy runs start and end on the same thread/task, so reset is valid.
1239
+ frame["_cv_token"] = _current_interpreter_tool_frame.set(frame)
1240
+
1241
+ def on_interpreter_tool_call_end(
1242
+ self,
1243
+ call_id: str,
1244
+ outputs: Any | None,
1245
+ exception: BaseException | None = None,
1246
+ ) -> None:
1247
+ """Close an interpreter tool-call frame; synthesize a TOOL span if needed."""
1248
+ frame = self._interpreter_tool_frames.pop(call_id, None)
1249
+ if frame is None:
1250
+ return
1251
+ token = frame.pop("_cv_token", None)
1252
+ if token is not None:
1253
+ try:
1254
+ _current_interpreter_tool_frame.reset(token)
1255
+ except ValueError:
1256
+ # Reset from a different context than the set (should not happen —
1257
+ # DSPy pairs start/end on one thread); leave the contextvar as is.
1258
+ pass
1259
+
1260
+ # A real dspy.Tool already emitted its span for this call; one TOOL span
1261
+ # is enough, so suppress the synthesized row.
1262
+ if frame["had_real_tool"]:
1263
+ return
1264
+
1265
+ # A real dspy.Tool already emitted its span for this call; one TOOL span
1266
+ # is enough, so suppress the synthesized row.
1267
+ if frame["had_real_tool"]:
1268
+ return
1269
+
1270
+ # A builtin (llm_query / llm_query_batched) fired no tool hook, so give it
1271
+ # the visibility it would otherwise lack: a synthesized TOOL span.
1272
+ parent = frame["parent_span"]
1273
+ span = models.SpanBuilder(
1274
+ span_id=uuid4(),
1275
+ trace_id=parent.trace_id if parent else None,
1276
+ name=f"Tool.{frame['tool_name']}",
1277
+ span_type=models.SpanType.TOOL,
1278
+ parent_span_id=parent.span_id if parent else None,
1279
+ start_time=frame["start_time"],
1280
+ )
1281
+ span.attributes["tool.name"] = str(frame["tool_name"])
1282
+ span.attributes["tool.synthesized"] = "true"
1283
+
1284
+ if self._capture_inputs and frame["inputs"]:
1285
+ span.set_inputs(
1286
+ helpers.clean_inputs({"kwargs": frame["inputs"]}, _get_dspy()[0], self._max_input_field_size)
1287
+ )
1288
+
1289
+ span.end_time = datetime.now(timezone.utc)
1290
+ if exception:
1291
+ span.set_error(exception)
1292
+ else:
1293
+ span.status = models.TraceStatus.OK
1294
+ if self._capture_outputs and outputs is not None:
1295
+ span.set_outputs(helpers.truncate_outputs(outputs, self._max_output_field_size))
1296
+
1297
+ self._track_span(span)
1298
+ if self._should_stream_span(span):
1299
+ exp = exporter.get_exporter()
1300
+ if exp:
1301
+ exp.export_span(span)
1302
+
1303
+ def on_interpreter_startup_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None:
1304
+ """Mark interpreter startup (Deno spawn + Pyodide boot) start."""
1305
+ del instance, inputs # DSPy keyword-calls this hook; only call_id is needed
1306
+ self._interpreter_lifecycle_starts[call_id] = datetime.now(timezone.utc)
1307
+
1308
+ def on_interpreter_startup_end(
1309
+ self, call_id: str, outputs: Any | None, exception: BaseException | None = None
1310
+ ) -> None:
1311
+ """Fold interpreter cold-start duration into the enclosing span."""
1312
+ del outputs, exception # a lifecycle event carries no outputs to record
1313
+ self._fold_lifecycle_duration(call_id, "interpreter.startup_ms")
1314
+
1315
+ def on_interpreter_shutdown_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None:
1316
+ """Mark interpreter shutdown start."""
1317
+ del instance, inputs # DSPy keyword-calls this hook; only call_id is needed
1318
+ self._interpreter_lifecycle_starts[call_id] = datetime.now(timezone.utc)
1319
+
1320
+ def on_interpreter_shutdown_end(
1321
+ self, call_id: str, outputs: Any | None, exception: BaseException | None = None
1322
+ ) -> None:
1323
+ """Fold interpreter shutdown duration into the enclosing span."""
1324
+ del outputs, exception # a lifecycle event carries no outputs to record
1325
+ self._fold_lifecycle_duration(call_id, "interpreter.shutdown_ms")
1326
+
1327
+ def _fold_lifecycle_duration(self, call_id: str, attr: str) -> None:
1328
+ """Record a lifecycle event's duration as an attribute on the enclosing span.
1329
+
1330
+ No span of its own — two lifecycle rows per RLM turn would bury the tree.
1331
+ An event with no enclosing span (a pooled pre-warm, or a shutdown after the
1332
+ program call already ended) is dropped: it has nothing to attach to.
1333
+ """
1334
+ start = self._interpreter_lifecycle_starts.pop(call_id, None)
1335
+ if start is None:
1336
+ return
1337
+ span = context.get_current_span()
1338
+ if span is None:
1339
+ return
1340
+ ms = (datetime.now(timezone.utc) - start).total_seconds() * 1000
1341
+ span.attributes[attr] = f"{ms:.1f}"
1342
+
1343
+
1344
+ class CmpndCallback(_ModuleMixin, _LMMixin, _AdapterMixin, _ToolMixin, _InterpreterMixin):
1075
1345
  """
1076
1346
  DSPy callback for cmpnd observability.
1077
1347
 
@@ -1086,6 +1356,29 @@ class CmpndCallback(_ModuleMixin, _LMMixin, _AdapterMixin, _ToolMixin):
1086
1356
  >>> dspy.configure(callbacks=[cmpnd.CmpndCallback()])
1087
1357
  """
1088
1358
 
1359
+ def __getattr__(self, name: str) -> Any:
1360
+ """Absorb the DSPy callback hooks this SDK does not instrument.
1361
+
1362
+ DSPy resolves a handler with a bare ``getattr(callback, "on_…")`` and
1363
+ has no guard for a callback that omits one, so every hook DSPy adds
1364
+ turns each dispatch into an ``AttributeError`` it catches and logs as a
1365
+ warning — one per interpreter call, one per ``compile()``. DSPy 3.3.1
1366
+ added ten that way (``on_interpreter_*`` for the code interpreter,
1367
+ ``on_compile_*`` for optimizers), and later releases will add more.
1368
+
1369
+ So answer any hook the installed DSPy declares with a no-op instead of
1370
+ chasing its releases. Names DSPy does not declare still raise, which
1371
+ keeps a misspelled handler here an error rather than a silent no-op.
1372
+ This sits on the leaf class rather than ``_CallbackBase`` so the mixins,
1373
+ where the instrumented handlers live, keep strict attribute checking.
1374
+ """
1375
+ if name.startswith("on_") and _dspy_declares_hook(name):
1376
+ if name not in _reported_unhandled_hooks:
1377
+ _reported_unhandled_hooks.add(name)
1378
+ logger.debug("DSPy callback hook %s is not instrumented by cmpnd; ignoring it.", name)
1379
+ return _ignore_hook
1380
+ raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
1381
+
1089
1382
  def on_evaluate_start(
1090
1383
  self,
1091
1384
  call_id: str,
@@ -76,6 +76,7 @@ _SPAN_LABELS = {
76
76
  "module": "Module",
77
77
  "retrieve": "Retrieve",
78
78
  "tool": "Tool",
79
+ "interpreter_execute": "Interpreter",
79
80
  "chain_of_thought": "Chain of Thought",
80
81
  "react": "ReAct",
81
82
  "program_of_thought": "Program of Thought",
@@ -397,6 +397,7 @@ class SpanType(str, Enum):
397
397
  ADAPTER_FORMAT = "adapter_format"
398
398
  ADAPTER_PARSE = "adapter_parse"
399
399
  TOOL = "tool"
400
+ INTERPRETER_EXECUTE = "interpreter_execute"
400
401
  SIGNATURE_VALIDATE = "signature_validate"
401
402
  DEMO_SELECT = "demo_select"
402
403
  OPTIMIZATION = "optimization"
@@ -501,8 +502,13 @@ class SpanBuilder:
501
502
  """Set a span attribute."""
502
503
  self.attributes[key] = str(value)
503
504
 
504
- def set_error(self, exception: Exception) -> None:
505
- """Mark span as errored."""
505
+ def set_error(self, exception: BaseException) -> None:
506
+ """Mark span as errored.
507
+
508
+ Accepts any ``BaseException``: DSPy's ``@with_callbacks`` catches
509
+ ``BaseException`` and hands it to an end hook, so an interpreter cell that
510
+ raised a non-``Exception`` still errors its span rather than being lost.
511
+ """
506
512
  self.status = TraceStatus.ERROR
507
513
  self.error_message = str(exception)
508
514
  self.error_type = exception.__class__.__name__
@@ -412,6 +412,15 @@ def _lms_config(config: dict[str, Any], lms: Any) -> None:
412
412
  ``lms`` is untyped here where :func:`optimize` annotates it, because this is the layer
413
413
  that answers what a caller actually passed: an SDK user runs no typechecker of ours,
414
414
  so the shape checks below are the ones that fire.
415
+
416
+ **A name is declared exactly as it is written, and a padded one is refused rather than
417
+ corrected.** Every layer below this one trims — serve's `lmBindingSet`, the optimizer's,
418
+ and both workers' — so a binding declared as ``" critic"`` would be resolved, granted,
419
+ minted for and served consistently under ``"critic"``, and the only thing left wrong
420
+ would be the author's own ``lms[" critic"]`` in the metric, raising mid-run against a
421
+ set whose entries look identical to what they typed. Trimming it here would agree with
422
+ the wire and keep that mismatch; naming it here is the corrected line before the bundle
423
+ is built, which is the whole cost the binding design exists to avoid.
415
424
  """
416
425
  if lms is None:
417
426
  return
@@ -430,8 +439,16 @@ def _lms_config(config: dict[str, Any], lms: Any) -> None:
430
439
 
431
440
  declared: dict[str, Any] = {}
432
441
  for name, lm in lms.items():
433
- if not isinstance(name, str) or not name:
442
+ if not isinstance(name, str) or not name.strip():
434
443
  raise ValueError(f"an LM binding's name must be a non-empty string (got {name!r})")
444
+ if name != name.strip():
445
+ raise ValueError(
446
+ f"the LM binding name {name!r} is padded with whitespace, and every layer "
447
+ f"below this one trims it — so the run would deploy, dispatch and roll out "
448
+ f"under {name.strip()!r}, and a metric written to match, asking for "
449
+ f"lms[{name!r}], would raise mid-run against a set whose entries look "
450
+ f"identical to what you typed. Declare it as {name.strip()!r}"
451
+ )
435
452
  if name == DEFAULT_LM_BINDING:
436
453
  raise ValueError(
437
454
  f"{DEFAULT_LM_BINDING!r} is the name the platform reserves for the program's own "
@@ -30,7 +30,7 @@ If you want to instrument manually (for example, to wire the callback into a non
30
30
 
31
31
  ## The callback
32
32
 
33
- `python-sdk/cmpnd/callback.py` implements `CmpndCallback`, a subclass of `dspy.utils.callback.BaseCallback`. DSPy calls its hook methods synchronously on whatever thread is executing the module. The callback owns no long-lived state beyond a per-call span builder map; everything trace/span related flows through context vars (see next section).
33
+ `python-sdk/cmpnd/callback.py` implements `CmpndCallback`, which satisfies `dspy.utils.callback.BaseCallback` without inheriting it — `callback.py` must import on the standard library alone (`tests/test_import_weight.py`), so it cannot import dspy to get a base class. DSPy calls its hook methods synchronously on whatever thread is executing the module. The callback owns no long-lived state beyond a per-call span builder map; everything trace/span related flows through context vars (see next section).
34
34
 
35
35
  The hook methods, in the order you'd expect them to fire for a typical `dspy.ChainOfThought` call:
36
36
 
@@ -38,8 +38,22 @@ The hook methods, in the order you'd expect them to fire for a typical `dspy.Cha
38
38
  - **LM calls** — `on_lm_start` (`callback.py:448`) / `on_lm_end` (`callback.py:498`) wrap the actual provider call. These are where token counts, cost, model name, and the raw prompt get attached. `capture_prompts` (`config.py:29`) controls whether the prompt payload is recorded.
39
39
  - **Adapter format/parse** — `on_adapter_format_start` / `_end` and `on_adapter_parse_start` / `_end` (`callback.py:604`–`631`). Both route through shared helpers `_start_adapter_span` (`callback.py:640`) and `_end_adapter_span` (`callback.py:664`). Adapter spans have their raw inputs/outputs stripped in `models.py` — see `_ADAPTER_SPAN_TYPES` handling in `python-sdk/cmpnd/models.py:177` — because the same content already appears on the enclosing LM span.
40
40
  - **Tools** — `on_tool_start` (`callback.py:700`) / `on_tool_end` (`callback.py:733`). One span per `dspy.Tool` invocation, with the tool's args as inputs.
41
+ - **Interpreter (code execution)** — `_InterpreterMixin` (`callback.py:1119`), for the DSPy `CodeInterpreter` hooks DSPy >= 3.3.1 fires around a program running code in the interpreter (`RLM`, `ProgramOfThought`, `CodeAct`, `Flex` — every module reaching it through `_create_interpreter`; `ReAct` / `ReActV2` use no interpreter and fire none). `on_interpreter_execute_start` / `_end` emit one `INTERPRETER_EXECUTE` span per REPL cell — the code in, its captured stdout out, and the `SUBMIT()` result (a DSPy `FinalOutput`) recorded distinctly from stdout. `on_interpreter_tool_call_start` / `_end` synthesize a `TOOL` span for a tool called from interpreter code **only when no real `dspy.Tool` span covered the call** (a user tool routes through `Tool.__call__` and fires `on_tool_*`, so its row is suppressed to avoid a duplicate; the `llm_query` builtins fire no tool hook, so they get the synthesized span). `on_interpreter_startup_*` / `on_interpreter_shutdown_*` emit no span — their duration folds into an `interpreter.startup_ms` / `interpreter.shutdown_ms` attribute on the enclosing span, and an event with no enclosing span (a pooled pre-warm) is dropped.
41
42
  - **Evaluate** — `on_evaluate_start` (`callback.py:773`) / `on_evaluate_end` (`callback.py:782`). These are thin forwarders that delegate to `EvalHandler` (see "Eval handling" below).
42
43
 
44
+ Not inheriting the base class costs the free no-op every hook would otherwise
45
+ have. DSPy resolves a handler with a bare `getattr(callback, "on_…")` and does
46
+ not guard for a callback that omits one, so a DSPy release that adds hooks turns
47
+ each dispatch into an `AttributeError` DSPy catches and logs as a warning — DSPy
48
+ 3.3.1 added ten (the eight `on_interpreter_*` for the code interpreter, now
49
+ instrumented by `_InterpreterMixin`, and `on_compile_*` for optimizers, which the
50
+ SDK still does not instrument). `CmpndCallback.__getattr__` closes that: any hook
51
+ the installed DSPy declares on `BaseCallback` and this SDK does not instrument
52
+ resolves to a no-op,
53
+ and every other name still raises. `TestDspyHookCoverage` in
54
+ `tests/test_callback.py` asserts the coverage against whatever DSPy is installed,
55
+ so the next release that adds a hook is caught by CI rather than by a user's logs.
56
+
43
57
  ### Signature extraction and signature_hash
44
58
 
45
59
  This doc does not re-derive hash semantics — see [`./clustering.md`](../../docs/CLUSTERING.md) for why `signature_hash` exists and how xxhash maps to a signed Int64. Here's only _where_ it gets attached:
@@ -60,7 +74,8 @@ Both code paths import from `python-sdk/cmpnd/identity.py`, which is the authori
60
74
  | Module-ish | `MODULE`, `PREDICT`, `CHAIN_OF_THOUGHT`, `REACT`, `RLM`, `RETRIEVE` | `on_module_start` fires. The concrete type is chosen by `_get_span_type_for_module` at `callback.py:793`. |
61
75
  | LM | `LM_CALL` | `on_lm_start` fires — one per provider round-trip. |
62
76
  | Adapter | `ADAPTER_FORMAT`, `ADAPTER_PARSE` | DSPy's adapter serializes the prompt or parses the response. |
63
- | Tool | `TOOL` | `dspy.Tool` is invoked (typically inside a ReAct loop). |
77
+ | Tool | `TOOL` | `dspy.Tool` is invoked (inside a ReAct loop), or synthesized for a builtin called from interpreter code. |
78
+ | Interpreter | `INTERPRETER_EXECUTE` | `on_interpreter_execute_start` fires — one per REPL code cell a code-executing module runs (`_InterpreterMixin`). |
64
79
  | Eval/optimize | `EVALUATION`, `OPTIMIZATION` | Created by `EvalHandler` and the optimization tracker, not by the normal module hooks. |
65
80
  | Other | `SIGNATURE_VALIDATE`, `DEMO_SELECT`, `UNKNOWN` | Reserved for edge cases and custom spans that don't fit another bucket. |
66
81
 
@@ -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.18.0"
8
+ version = "0.18.2"
9
9
  description = "DSPy observability and deployment SDK for cmpnd"
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -0,0 +1,40 @@
1
+ """Stubs for dspy.utils.callback.BaseCallback — the callback protocol
2
+ cmpnd.callback.CmpndCallback implements. It does not inherit this class:
3
+ callback.py must import without dspy, so it satisfies the protocol
4
+ structurally and absorbs hooks it does not instrument via __getattr__.
5
+ Declared here are the hooks cmpnd implements; DSPy's full set is larger.
6
+
7
+ End hooks take ``exception: BaseException | None`` because DSPy's
8
+ ``@with_callbacks`` catches ``BaseException`` and hands it to the end hook — a
9
+ cell that raised a non-``Exception`` (KeyboardInterrupt, SystemExit) still
10
+ reaches the handler, so the stub matches the dispatch rather than a narrower
11
+ ``Exception``.
12
+ """
13
+
14
+ from typing import Any
15
+
16
+ class BaseCallback:
17
+ def on_module_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
18
+ def on_module_end(self, call_id: str, outputs: Any, exception: BaseException | None = ...) -> None: ...
19
+ def on_lm_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
20
+ def on_lm_end(self, call_id: str, outputs: Any, exception: BaseException | None = ...) -> None: ...
21
+ def on_adapter_format_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
22
+ def on_adapter_format_end(self, call_id: str, outputs: Any, exception: BaseException | None = ...) -> None: ...
23
+ def on_adapter_parse_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
24
+ def on_adapter_parse_end(self, call_id: str, outputs: Any, exception: BaseException | None = ...) -> None: ...
25
+ def on_tool_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
26
+ def on_tool_end(self, call_id: str, outputs: Any, exception: BaseException | None = ...) -> None: ...
27
+ def on_evaluate_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
28
+ def on_evaluate_end(self, call_id: str, outputs: Any, exception: BaseException | None = ...) -> None: ...
29
+ def on_interpreter_execute_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
30
+ def on_interpreter_execute_end(self, call_id: str, outputs: Any, exception: BaseException | None = ...) -> None: ...
31
+ def on_interpreter_tool_call_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
32
+ def on_interpreter_tool_call_end(
33
+ self, call_id: str, outputs: Any, exception: BaseException | None = ...
34
+ ) -> None: ...
35
+ def on_interpreter_startup_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
36
+ def on_interpreter_startup_end(self, call_id: str, outputs: Any, exception: BaseException | None = ...) -> None: ...
37
+ def on_interpreter_shutdown_start(self, call_id: str, instance: Any, inputs: dict[str, Any]) -> None: ...
38
+ def on_interpreter_shutdown_end(
39
+ self, call_id: str, outputs: Any, exception: BaseException | None = ...
40
+ ) -> None: ...
@@ -96,6 +96,68 @@ def react_responder(tool_name: str, tool_args: dict[str, Any], answer: str = "fa
96
96
  return respond
97
97
 
98
98
 
99
+ def rlm_responder(cells: Sequence[str], answer: str) -> Responder:
100
+ """A stateful responder that scripts a ``dspy.RLM`` code trajectory.
101
+
102
+ RLM's per-turn signature declares ``reasoning``/``code``; the model returns a
103
+ ```python block that RLM's real interpreter executes, and the run ends when
104
+ the code calls ``SUBMIT(...)``. This scripts that loop deterministically —
105
+ no real model deciding what code to write — so a real interpreter run
106
+ (``execute`` / ``invoke_tool`` callbacks, `interpreter_execute` spans) can be
107
+ exercised with the fake LM instead of a paid one.
108
+
109
+ ``cells`` is the code for iterations 1..N, run in order; after the last cell
110
+ the responder emits ``SUBMIT(answer=...)`` so the run terminates rather than
111
+ burning ``max_iters``. Each code string is wrapped in the ```python fence RLM
112
+ asks for, so pass bare Python. The current iteration is read from the prompt's
113
+ ``[[ ## iteration ## ]]`` block, so the trajectory advances on its own.
114
+
115
+ The forced-extract turn RLM runs if it never SUBMITs declares the program's
116
+ own output fields instead of ``code``; that turn gets ``answer``.
117
+
118
+ Construct a fresh responder per test (the iteration is read from the prompt,
119
+ but keep one responder to one scripted program for clarity).
120
+ """
121
+ iteration_re = re.compile(r"\[\[ ## iteration ## \]\]\n(\d+)")
122
+
123
+ def respond(messages: list[dict[str, Any]], fields: list[str]) -> dict[str, str]:
124
+ if "code" not in fields: # the forced-extract turn asks for the real outputs
125
+ return {field: answer for field in fields}
126
+ last_user = next((m.get("content", "") for m in reversed(messages) if m.get("role") == "user"), "")
127
+ match = iteration_re.search(last_user)
128
+ iteration = int(match.group(1)) if match else 1
129
+ code = cells[iteration - 1] if iteration <= len(cells) else f"SUBMIT(answer={answer!r})"
130
+ return {"reasoning": f"step {iteration}", "code": f"```python\n{code}\n```"}
131
+
132
+ return respond
133
+
134
+
135
+ def pot_responder(code: str, answer: str) -> Responder:
136
+ """A responder that scripts a ``dspy.ProgramOfThought`` run.
137
+
138
+ PoT drives three predictors: ``code_generate`` (outputs ``reasoning`` /
139
+ ``generated_code`` — the code its real interpreter runs), ``code_regenerate``
140
+ (same fields, on an execution error), and ``generate_output`` (outputs
141
+ ``reasoning`` / ``answer`` from the code's captured output). This scripts them
142
+ deterministically so a non-RLM code-executing module exercises the same real
143
+ interpreter path (``execute`` callbacks, ``interpreter_execute`` spans) as
144
+ ``rlm_responder`` does for RLM — the end-to-end proof that the interpreter
145
+ instrumentation is module-independent.
146
+
147
+ ``code`` is wrapped in the ```python fence PoT parses, so pass bare Python
148
+ that prints its result; ``answer`` is returned once the code has run.
149
+ """
150
+
151
+ def respond(messages: list[dict[str, Any]], fields: list[str]) -> dict[str, str]:
152
+ if "generated_code" in fields:
153
+ return {"reasoning": "compute the result", "generated_code": f"```python\n{code}\n```"}
154
+ if "answer" in fields:
155
+ return {"reasoning": "the code produced the result", "answer": answer}
156
+ return {field: answer for field in fields}
157
+
158
+ return respond
159
+
160
+
99
161
  def lift_responder(
100
162
  golds: dict[str, str],
101
163
  trainset_inputs: set[str],