codespy-ai 2.0.0__tar.gz → 2.0.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 (126) hide show
  1. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/PKG-INFO +1 -1
  2. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/pyproject.toml +1 -1
  3. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/__init__.py +1 -1
  4. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/context_safe.py +44 -1
  5. codespy_ai-2.0.2/src/codespy/agents/diagnostic_adapter.py +328 -0
  6. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/dspy_config.py +3 -3
  7. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/cerebral/cerebral.py +163 -13
  8. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/prefrontal/prefrontal.py +2 -1
  9. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/prefrontal/reach.py +46 -10
  10. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/code_review/agent.py +11 -19
  11. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/doc/agent.py +5 -5
  12. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/helpers.py +33 -0
  13. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/models.py +6 -1
  14. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/scope/agent.py +21 -39
  15. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/supply_chain/agent.py +13 -17
  16. codespy_ai-2.0.2/src/codespy/agents/rlm_tools.py +330 -0
  17. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/config.py +13 -0
  18. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/config_dspy.py +1 -1
  19. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/workflows/review/pipeline.py +11 -1
  20. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/LICENSE +0 -0
  21. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/README.md +0 -0
  22. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/__init__.py +0 -0
  23. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/cost_tracker.py +0 -0
  24. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/__init__.py +0 -0
  25. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/cerebral/__init__.py +0 -0
  26. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/cerebral/cost.py +0 -0
  27. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/cerebral/delta_ops_compat.py +0 -0
  28. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/cerebral/orphans.py +0 -0
  29. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/cerebral/routines.py +0 -0
  30. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/cerebral/structured_output.py +0 -0
  31. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/hippocampus/__init__.py +0 -0
  32. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/hippocampus/budget.py +0 -0
  33. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/hippocampus/cartographer.py +0 -0
  34. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/hippocampus/context_memory.py +0 -0
  35. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/hippocampus/distiller.py +0 -0
  36. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/hippocampus/episode.py +0 -0
  37. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/hippocampus/hippocampus.py +0 -0
  38. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/pg0_manager.py +0 -0
  39. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/postgres.py +0 -0
  40. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/prefrontal/__init__.py +0 -0
  41. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/prefrontal/facets.py +0 -0
  42. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/prefrontal/format.py +0 -0
  43. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/prefrontal/query.py +0 -0
  44. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/memory/recall.py +0 -0
  45. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/__init__.py +0 -0
  46. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/audit/__init__.py +0 -0
  47. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/audit/agent.py +0 -0
  48. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/code_review/__init__.py +0 -0
  49. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/doc/__init__.py +0 -0
  50. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/doc/doc_extractor.py +0 -0
  51. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/scope/__init__.py +0 -0
  52. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/scope/manifest_parser.py +0 -0
  53. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/scope/models.py +0 -0
  54. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/summary/__init__.py +0 -0
  55. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/summary/agent.py +0 -0
  56. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/agents/review/supply_chain/__init__.py +0 -0
  57. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/cli.py +0 -0
  58. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/cli_local.py +0 -0
  59. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/cli_mcp_server.py +0 -0
  60. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/cli_remote.py +0 -0
  61. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/config_git.py +0 -0
  62. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/config_io.py +0 -0
  63. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/config_llm.py +0 -0
  64. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/config_memory.py +0 -0
  65. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/config_utils.py +0 -0
  66. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/__init__.py +0 -0
  67. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/cyber/__init__.py +0 -0
  68. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/cyber/osv/__init__.py +0 -0
  69. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/cyber/osv/client.py +0 -0
  70. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/cyber/osv/models.py +0 -0
  71. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/cyber/osv/server.py +0 -0
  72. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/git/__init__.py +0 -0
  73. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/git/base.py +0 -0
  74. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/git/client.py +0 -0
  75. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/git/github_client.py +0 -0
  76. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/git/gitlab_client.py +0 -0
  77. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/git/local_diff.py +0 -0
  78. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/git/models.py +0 -0
  79. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/git/server.py +0 -0
  80. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/mcp_utils.py +0 -0
  81. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/__init__.py +0 -0
  82. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/ripgrep/__init__.py +0 -0
  83. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/ripgrep/client.py +0 -0
  84. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/ripgrep/server.py +0 -0
  85. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/__init__.py +0 -0
  86. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/base_extractor.py +0 -0
  87. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/__init__.py +0 -0
  88. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/bash.py +0 -0
  89. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/cpp.py +0 -0
  90. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/csharp.py +0 -0
  91. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/go.py +0 -0
  92. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/java.py +0 -0
  93. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/javascript.py +0 -0
  94. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/kotlin.py +0 -0
  95. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/objc.py +0 -0
  96. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/php.py +0 -0
  97. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/python.py +0 -0
  98. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/ripgrep_fallback.py +0 -0
  99. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/ruby.py +0 -0
  100. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/rust.py +0 -0
  101. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/swift.py +0 -0
  102. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/extractors/terraform.py +0 -0
  103. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/models.py +0 -0
  104. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/parser.py +0 -0
  105. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/parsers/treesitter/server.py +0 -0
  106. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/storage/__init__.py +0 -0
  107. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/storage/base.py +0 -0
  108. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/storage/filesystem/__init__.py +0 -0
  109. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/storage/filesystem/client.py +0 -0
  110. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/storage/filesystem/server.py +0 -0
  111. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/storage/models.py +0 -0
  112. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/storage/s3/__init__.py +0 -0
  113. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/storage/s3/client.py +0 -0
  114. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/storage/s3/server.py +0 -0
  115. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/web/__init__.py +0 -0
  116. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/web/client.py +0 -0
  117. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/web/models.py +0 -0
  118. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/tools/web/server.py +0 -0
  119. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/workflows/__init__.py +0 -0
  120. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/workflows/review/__init__.py +0 -0
  121. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/workflows/review/models.py +0 -0
  122. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/workflows/review/reporters/__init__.py +0 -0
  123. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/workflows/review/reporters/base.py +0 -0
  124. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/workflows/review/reporters/git.py +0 -0
  125. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/workflows/review/reporters/stdout.py +0 -0
  126. {codespy_ai-2.0.0 → codespy_ai-2.0.2}/src/codespy/workflows/review/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codespy-ai
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Code review agent powered by DSPy
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "codespy-ai"
3
- version = "2.0.0"
3
+ version = "2.0.2"
4
4
  description = "Code review agent powered by DSPy"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """codespy - Code review agent powered by DSPy."""
2
2
 
3
- __version__ = "2.0.0"
3
+ __version__ = "2.0.2"
@@ -4,6 +4,7 @@ Provides transparent fallback to RLM (Recursive Language Model) when inputs
4
4
  exceed the model's context window. Used to wrap all DSPy signature modules.
5
5
  """
6
6
 
7
+ import enum
7
8
  import logging
8
9
  import re
9
10
 
@@ -19,6 +20,47 @@ _RE_CONTEXT_LENGTH = re.compile(r"maximum context length is \d+ tokens", re.IGNO
19
20
  # Below this, context rot is not a concern regardless of ratio.
20
21
  _MIN_RLM_THRESHOLD_TOKENS = 8192
21
22
 
23
+ # Replaces dspy.RLM's stock `reasoning` desc ("Think step-by-step: what do you
24
+ # know? What remains? Plan your next action."), which on its own triggers Bedrock
25
+ # content_filter on Claude Opus 5.5.
26
+ RLM_REASONING_DESC = "Short plan for the next step."
27
+
28
+
29
+ def _plain(value): # type: ignore[no-untyped-def]
30
+ """Replace Enum members with their values, recursively.
31
+
32
+ dspy's PythonInterpreter injects inputs as `name = repr(value)`; an Enum repr
33
+ (`<IssueCategory.BUG: 'bug'>`) is not valid Python, so every REPL execution
34
+ fails with an empty SyntaxError.
35
+ """
36
+ if isinstance(value, enum.Enum):
37
+ return value.value
38
+ if isinstance(value, (list, tuple, set)):
39
+ return [_plain(v) for v in value]
40
+ if isinstance(value, dict):
41
+ return {_plain(k): _plain(v) for k, v in value.items()}
42
+ return value
43
+
44
+
45
+ class CodespyRLM(dspy.RLM):
46
+ """dspy.RLM adjusted for codespy.
47
+
48
+ - Neutral action-signature `reasoning` description (Bedrock content_filter).
49
+ - Enum inputs passed as plain values (REPL variable injection).
50
+ """
51
+
52
+ def forward(self, **kwargs): # type: ignore[no-untyped-def]
53
+ return super().forward(**{k: _plain(v) for k, v in kwargs.items()})
54
+
55
+ async def aforward(self, **kwargs): # type: ignore[no-untyped-def]
56
+ return await super().aforward(**{k: _plain(v) for k, v in kwargs.items()})
57
+
58
+ def _build_signatures(self): # type: ignore[no-untyped-def]
59
+ action_sig, extract_sig = super()._build_signatures()
60
+ if "reasoning" in action_sig.output_fields:
61
+ action_sig = action_sig.with_updated_fields("reasoning", desc=RLM_REASONING_DESC)
62
+ return action_sig, extract_sig
63
+
22
64
 
23
65
  def estimate_context_overflow(model: str, max_tokens: int, input_text: str) -> bool:
24
66
  """Estimate whether input + max_tokens would exceed the model's context window.
@@ -197,9 +239,10 @@ class ContextSafe(dspy.Module):
197
239
 
198
240
  def _create_rlm_fallback(self) -> dspy.RLM:
199
241
  """Create RLM with current signature and tools."""
200
- return dspy.RLM(
242
+ return CodespyRLM(
201
243
  self._get_current_signature(),
202
244
  tools=self._tools,
203
245
  max_iters=self._max_iters,
204
246
  max_llm_calls=self._max_llm_calls,
247
+ verbose=logger.isEnabledFor(logging.DEBUG),
205
248
  )
@@ -0,0 +1,328 @@
1
+ """Diagnostic adapter for logging empty LM response details.
2
+
3
+ Wraps TwoStepAdapter to capture diagnostics when the main LM returns an empty
4
+ or null response, which helps diagnose why Claude occasionally returns empty
5
+ text with hidden thinking content.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import dspy # type: ignore[import-untyped]
15
+ from dspy.adapters.two_step_adapter import TwoStepAdapter # type: ignore[import-untyped]
16
+ from dspy.adapters.base import AdapterParseError # type: ignore[import-untyped]
17
+
18
+ from codespy.config import get_settings
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _getattr_or_key(obj: Any, name: str, default: Any = None) -> Any:
24
+ """Get attribute or dict key, tolerating both access patterns."""
25
+ if hasattr(obj, name):
26
+ return getattr(obj, name, default)
27
+ if isinstance(obj, dict):
28
+ return obj.get(name, default)
29
+ return default
30
+
31
+
32
+ def _safe_repr(value: Any, max_len: int = 500) -> str:
33
+ """Safe repr with length limit."""
34
+ try:
35
+ s = repr(value)
36
+ if len(s) > max_len:
37
+ return s[:max_len] + f"... ({len(s)} chars total)"
38
+ return s
39
+ except Exception:
40
+ return "<repr failed>"
41
+
42
+
43
+ def _get_finish_reason(entry: dict) -> str:
44
+ """Extract finish_reason from history entry."""
45
+ resp = entry.get("response")
46
+ if resp is None:
47
+ return "unknown"
48
+
49
+ resp_class_name = type(resp).__name__
50
+ if resp_class_name == "Response":
51
+ # lm15 style Response object
52
+ return _getattr_or_key(resp, "finish_reason", "unknown")
53
+ else:
54
+ # Standard dict-style response
55
+ choices = _getattr_or_key(resp, "choices", [])
56
+ if choices and len(choices) > 0:
57
+ choice = choices[0]
58
+ return _getattr_or_key(choice, "finish_reason", "unknown")
59
+ return "unknown"
60
+
61
+
62
+ def _write_refusal_dump(entry: dict, model: str, lm_kwargs: dict, signature_name: str) -> Path | None:
63
+ """Write a refusal dump to disk for later analysis.
64
+
65
+ Args:
66
+ entry: The history entry containing messages and response.
67
+ model: The model name.
68
+ lm_kwargs: The LM kwargs (will have API keys stripped).
69
+ signature_name: The signature name for the dump filename.
70
+
71
+ Returns:
72
+ Path to the written dump file, or None if writing failed.
73
+ """
74
+ try:
75
+ settings = get_settings()
76
+ cache_dir = Path(settings.review.cache_dir).expanduser().resolve()
77
+ refusals_dir = cache_dir / "refusals"
78
+ refusals_dir.mkdir(parents=True, exist_ok=True)
79
+
80
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
81
+ safe_sig = signature_name.replace("/", "_").replace("\\", "_")[:50]
82
+ dump_path = refusals_dir / f"{timestamp}-{safe_sig}.json"
83
+
84
+ # Strip API keys from lm_kwargs
85
+ safe_kwargs = {}
86
+ for key, value in lm_kwargs.items():
87
+ lower_key = key.lower()
88
+ # Redact actual secret keys, but not legitimate settings like max_tokens
89
+ if lower_key.endswith("_key") or lower_key.endswith("_secret") or lower_key.endswith("_token") or lower_key == "api_key":
90
+ safe_kwargs[key] = "<redacted>"
91
+ else:
92
+ safe_kwargs[key] = value
93
+
94
+ dump = {
95
+ "model": model,
96
+ "lm_kwargs": safe_kwargs,
97
+ "messages": entry.get("messages", []),
98
+ "response": entry.get("response"),
99
+ }
100
+
101
+ with open(dump_path, "w", encoding="utf-8") as f:
102
+ json.dump(dump, f, indent=2, default=str)
103
+
104
+ logger.warning("Empty LM response: refusal dump written to %s", dump_path)
105
+ return dump_path
106
+ except Exception as e:
107
+ logger.debug("Empty LM response: failed to write refusal dump: %s", e)
108
+ return None
109
+
110
+
111
+ def _log_empty_response(lm: Any, signature: Any) -> None:
112
+ """Log detailed diagnostics for an empty LM response.
113
+
114
+ Wraps everything in try/except so diagnostics can never mask the original error.
115
+ """
116
+ try:
117
+ model = getattr(lm, "model", "unknown")
118
+ max_tokens = getattr(lm, "kwargs", {}).get("max_tokens") if hasattr(lm, "kwargs") else None
119
+ reasoning_effort = getattr(lm, "kwargs", {}).get("reasoning_effort") if hasattr(lm, "kwargs") else None
120
+
121
+ # Get output field names from signature
122
+ output_fields: list[str] = []
123
+ signature_name = "unknown"
124
+ if signature is not None:
125
+ try:
126
+ signature_name = getattr(signature, "__name__", type(signature).__name__)
127
+ if hasattr(signature, "output_fields"):
128
+ output_fields = list(getattr(signature, "output_fields", []))
129
+ elif hasattr(signature, "fields"):
130
+ # Try to identify output fields from signature fields
131
+ fields = getattr(signature, "fields", {})
132
+ if isinstance(fields, dict):
133
+ output_fields = [k for k, v in fields.items() if getattr(v, "is_input", False) is False]
134
+ except Exception:
135
+ pass
136
+
137
+ logger.warning(
138
+ "Empty LM response: model=%s max_tokens=%s reasoning_effort=%s output_fields=%s",
139
+ model, max_tokens, reasoning_effort, output_fields
140
+ )
141
+
142
+ # Read from LM history
143
+ history = getattr(lm, "history", None)
144
+ if not history:
145
+ logger.warning("Empty LM response: no LM history available")
146
+ return
147
+
148
+ try:
149
+ entry = history[-1]
150
+ except IndexError:
151
+ logger.warning("Empty LM response: LM history is empty")
152
+ return
153
+
154
+ if not isinstance(entry, dict):
155
+ logger.warning("Empty LM response: unexpected history entry type %s", type(entry).__name__)
156
+ return
157
+
158
+ # Log usage info
159
+ usage = entry.get("usage")
160
+ if usage:
161
+ prompt_tokens = _getattr_or_key(usage, "prompt_tokens") or _getattr_or_key(usage, "input_tokens", 0)
162
+ completion_tokens = _getattr_or_key(usage, "completion_tokens") or _getattr_or_key(usage, "output_tokens", 0)
163
+ reasoning_tokens = _getattr_or_key(usage, "reasoning_tokens", 0)
164
+ logger.warning(
165
+ "Empty LM response: usage prompt_tokens=%s completion_tokens=%s reasoning_tokens=%s",
166
+ prompt_tokens, completion_tokens, reasoning_tokens
167
+ )
168
+ else:
169
+ logger.warning("Empty LM response: no usage info in history entry")
170
+
171
+ # Log response details
172
+ resp = entry.get("response")
173
+ if resp is None:
174
+ logger.warning("Empty LM response: no response object in history entry")
175
+ return
176
+
177
+ # Handle lm15 Response object vs dict
178
+ resp_class_name = type(resp).__name__
179
+ if resp_class_name == "Response":
180
+ # lm15 style Response object
181
+ finish_reason = _getattr_or_key(resp, "finish_reason", "unknown")
182
+ message = _getattr_or_key(resp, "message", None)
183
+ parts_info: list[str] = []
184
+ if message and hasattr(message, "parts"):
185
+ parts = getattr(message, "parts", [])
186
+ parts_info = [type(p).__name__ for p in parts]
187
+ usage_obj = _getattr_or_key(resp, "usage", None)
188
+ logger.warning(
189
+ "Empty LM response: lm15 Response finish_reason=%s message_parts=%s usage=%s",
190
+ finish_reason, parts_info, usage_obj
191
+ )
192
+ else:
193
+ # Standard dict-style response
194
+ choices = _getattr_or_key(resp, "choices", [])
195
+ if choices and len(choices) > 0:
196
+ choice = choices[0]
197
+ finish_reason = _getattr_or_key(choice, "finish_reason", "unknown")
198
+ message = _getattr_or_key(choice, "message", {})
199
+ content = _getattr_or_key(message, "content", "")
200
+
201
+ logger.warning(
202
+ "Empty LM response: finish_reason=%s content=%s",
203
+ finish_reason, _safe_repr(content, 500)
204
+ )
205
+
206
+ # Check for reasoning_content (Anthropic)
207
+ reasoning_content = _getattr_or_key(message, "reasoning_content", None)
208
+ if reasoning_content:
209
+ rc_len = len(str(reasoning_content))
210
+ logger.warning(
211
+ "Empty LM response: reasoning_content len=%s first_300=%s",
212
+ rc_len, _safe_repr(str(reasoning_content)[:300], 300)
213
+ )
214
+ else:
215
+ # Check for thinking_blocks
216
+ thinking_blocks = _getattr_or_key(message, "thinking_blocks", None)
217
+ if thinking_blocks:
218
+ block_types = [type(b).__name__ for b in thinking_blocks]
219
+ logger.warning(
220
+ "Empty LM response: thinking_blocks count=%s types=%s",
221
+ len(thinking_blocks), block_types
222
+ )
223
+ else:
224
+ logger.warning("Empty LM response: no reasoning_content or thinking_blocks")
225
+
226
+ # Check for tool_calls
227
+ tool_calls = _getattr_or_key(message, "tool_calls", None)
228
+ if tool_calls:
229
+ logger.warning("Empty LM response: tool_calls count=%s", len(tool_calls))
230
+ else:
231
+ logger.warning("Empty LM response: no tool_calls")
232
+
233
+ # Log provider_specific_fields if present
234
+ provider_fields = _getattr_or_key(message, "provider_specific_fields", None)
235
+ if provider_fields:
236
+ logger.warning("Empty LM response: provider_specific_fields keys=%s", list(provider_fields.keys()) if isinstance(provider_fields, dict) else type(provider_fields).__name__)
237
+ else:
238
+ logger.warning("Empty LM response: no choices in response")
239
+
240
+ # Log request size
241
+ messages = entry.get("messages", [])
242
+ request_size = len(str(messages))
243
+ logger.warning("Empty LM response: request_size_chars=%s", request_size)
244
+
245
+ # If finish_reason is content_filter, write a refusal dump
246
+ finish_reason = _get_finish_reason(entry)
247
+ if finish_reason == "content_filter":
248
+ lm_kwargs = getattr(lm, "kwargs", {}) if hasattr(lm, "kwargs") else {}
249
+ _write_refusal_dump(entry, model, lm_kwargs, signature_name)
250
+
251
+ except Exception as diag_exc:
252
+ # Never let diagnostics mask the original error
253
+ logger.debug("Empty LM response: diagnostic logging failed: %s", diag_exc, exc_info=True)
254
+
255
+
256
+ def _get_finish_reason_from_lm(lm: Any) -> str | None:
257
+ """Extract finish_reason from LM history entry."""
258
+ history = getattr(lm, "history", None)
259
+ if not history:
260
+ return None
261
+ try:
262
+ entry = history[-1]
263
+ if not isinstance(entry, dict):
264
+ return None
265
+ return _get_finish_reason(entry)
266
+ except Exception:
267
+ return None
268
+
269
+
270
+ class DiagnosticTwoStepAdapter(TwoStepAdapter):
271
+ """TwoStepAdapter that logs diagnostics on empty LM responses.
272
+
273
+ Wraps the parent's __call__ and __acall__ to catch AdapterParseError for empty/null
274
+ responses and log detailed diagnostics before re-raising the original
275
+ exception unchanged.
276
+ """
277
+
278
+ def _check_and_log_empty_response(
279
+ self, exc: AdapterParseError, lm: dspy.LM, signature: Any
280
+ ) -> None:
281
+ """Check if exception is for empty response and log diagnostics.
282
+
283
+ If finish_reason is content_filter, raises a more descriptive error.
284
+ """
285
+ # Only log for empty/null response errors
286
+ if "empty or null response" in str(exc).lower():
287
+ _log_empty_response(lm, signature)
288
+ # If finish_reason is content_filter, raise a more descriptive error
289
+ finish_reason = _get_finish_reason_from_lm(lm)
290
+ if finish_reason == "content_filter":
291
+ raise AdapterParseError(
292
+ adapter_name=getattr(self, "__class__.__name__", "DiagnosticTwoStepAdapter"),
293
+ signature=signature,
294
+ lm_response="",
295
+ message="model refused (content_filter)",
296
+ ) from exc
297
+
298
+ def __call__(
299
+ self,
300
+ lm: dspy.LM,
301
+ lm_kwargs: dict[str, Any],
302
+ signature: Any,
303
+ demos: list[dict[str, Any]],
304
+ inputs: dict[str, Any],
305
+ ) -> dict[str, Any]:
306
+ """Call parent adapter, logging diagnostics on empty response."""
307
+ try:
308
+ return super().__call__(lm, lm_kwargs, signature, demos, inputs)
309
+ except AdapterParseError as exc:
310
+ self._check_and_log_empty_response(exc, lm, signature)
311
+ # Always re-raise the original exception unchanged
312
+ raise
313
+
314
+ async def acall(
315
+ self,
316
+ lm: dspy.LM,
317
+ lm_kwargs: dict[str, Any],
318
+ signature: Any,
319
+ demos: list[dict[str, Any]],
320
+ inputs: dict[str, Any],
321
+ ) -> dict[str, Any]:
322
+ """Async call parent adapter, logging diagnostics on empty response."""
323
+ try:
324
+ return await super().acall(lm, lm_kwargs, signature, demos, inputs)
325
+ except AdapterParseError as exc:
326
+ self._check_and_log_empty_response(exc, lm, signature)
327
+ # Always re-raise the original exception unchanged
328
+ raise
@@ -7,8 +7,8 @@ from typing import Any
7
7
 
8
8
  import dspy # type: ignore[import-untyped]
9
9
  import litellm # type: ignore[import-untyped]
10
- from dspy.adapters.two_step_adapter import TwoStepAdapter # type: ignore[import-untyped]
11
10
 
11
+ from codespy.agents.diagnostic_adapter import DiagnosticTwoStepAdapter
12
12
  from codespy.config import Settings, get_settings
13
13
  from codespy.config_memory import MEMORY_CONSOLIDATION, MEMORY_MENTAL_MODELS, MEMORY_RECALL, MEMORY_RETAIN, REFLECTION_MODULES, LLMSettings
14
14
  from codespy.config_utils import secret_value
@@ -216,7 +216,7 @@ def lm_context(name: str) -> LMScope:
216
216
  llm_config = settings.get_llm_config(name)
217
217
  lm = new_lm(settings, llm_config)
218
218
  extraction_lm = new_extraction_lm(settings)
219
- ctx = dspy.context(lm=lm, adapter=TwoStepAdapter(extraction_lm))
219
+ ctx = dspy.context(lm=lm, adapter=DiagnosticTwoStepAdapter(extraction_lm))
220
220
  return LMScope(ctx=ctx, lm=lm, extraction_lm=extraction_lm)
221
221
 
222
222
 
@@ -270,7 +270,7 @@ def configure_dspy(settings: Settings) -> None:
270
270
 
271
271
  dspy.settings.configure(
272
272
  lm=lm,
273
- adapter=TwoStepAdapter(extraction_lm), # TwoStepAdapter solves ChatAdapter parsing failures
273
+ adapter=DiagnosticTwoStepAdapter(extraction_lm), # TwoStepAdapter solves ChatAdapter parsing failures
274
274
  )
275
275
 
276
276
  # Enable memory-only caching for LLM calls (no disk caching)