codespy-ai 2.0.0__tar.gz → 2.0.1__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.1}/PKG-INFO +1 -1
  2. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/pyproject.toml +1 -1
  3. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/__init__.py +1 -1
  4. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/context_safe.py +44 -1
  5. codespy_ai-2.0.1/src/codespy/agents/diagnostic_adapter.py +328 -0
  6. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/dspy_config.py +3 -3
  7. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/code_review/agent.py +11 -19
  8. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/doc/agent.py +5 -5
  9. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/helpers.py +33 -0
  10. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/models.py +6 -1
  11. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/scope/agent.py +6 -10
  12. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/supply_chain/agent.py +13 -17
  13. codespy_ai-2.0.1/src/codespy/agents/rlm_tools.py +330 -0
  14. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/config.py +13 -0
  15. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/config_dspy.py +1 -1
  16. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/workflows/review/pipeline.py +11 -1
  17. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/LICENSE +0 -0
  18. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/README.md +0 -0
  19. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/__init__.py +0 -0
  20. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/cost_tracker.py +0 -0
  21. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/__init__.py +0 -0
  22. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/cerebral/__init__.py +0 -0
  23. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/cerebral/cerebral.py +0 -0
  24. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/cerebral/cost.py +0 -0
  25. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/cerebral/delta_ops_compat.py +0 -0
  26. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/cerebral/orphans.py +0 -0
  27. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/cerebral/routines.py +0 -0
  28. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/cerebral/structured_output.py +0 -0
  29. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/hippocampus/__init__.py +0 -0
  30. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/hippocampus/budget.py +0 -0
  31. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/hippocampus/cartographer.py +0 -0
  32. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/hippocampus/context_memory.py +0 -0
  33. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/hippocampus/distiller.py +0 -0
  34. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/hippocampus/episode.py +0 -0
  35. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/hippocampus/hippocampus.py +0 -0
  36. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/pg0_manager.py +0 -0
  37. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/postgres.py +0 -0
  38. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/prefrontal/__init__.py +0 -0
  39. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/prefrontal/facets.py +0 -0
  40. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/prefrontal/format.py +0 -0
  41. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/prefrontal/prefrontal.py +0 -0
  42. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/prefrontal/query.py +0 -0
  43. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/prefrontal/reach.py +0 -0
  44. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/memory/recall.py +0 -0
  45. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/__init__.py +0 -0
  46. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/audit/__init__.py +0 -0
  47. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/audit/agent.py +0 -0
  48. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/code_review/__init__.py +0 -0
  49. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/doc/__init__.py +0 -0
  50. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/doc/doc_extractor.py +0 -0
  51. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/scope/__init__.py +0 -0
  52. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/scope/manifest_parser.py +0 -0
  53. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/scope/models.py +0 -0
  54. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/summary/__init__.py +0 -0
  55. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/summary/agent.py +0 -0
  56. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/agents/review/supply_chain/__init__.py +0 -0
  57. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/cli.py +0 -0
  58. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/cli_local.py +0 -0
  59. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/cli_mcp_server.py +0 -0
  60. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/cli_remote.py +0 -0
  61. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/config_git.py +0 -0
  62. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/config_io.py +0 -0
  63. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/config_llm.py +0 -0
  64. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/config_memory.py +0 -0
  65. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/config_utils.py +0 -0
  66. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/__init__.py +0 -0
  67. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/cyber/__init__.py +0 -0
  68. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/cyber/osv/__init__.py +0 -0
  69. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/cyber/osv/client.py +0 -0
  70. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/cyber/osv/models.py +0 -0
  71. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/cyber/osv/server.py +0 -0
  72. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/git/__init__.py +0 -0
  73. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/git/base.py +0 -0
  74. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/git/client.py +0 -0
  75. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/git/github_client.py +0 -0
  76. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/git/gitlab_client.py +0 -0
  77. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/git/local_diff.py +0 -0
  78. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/git/models.py +0 -0
  79. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/git/server.py +0 -0
  80. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/mcp_utils.py +0 -0
  81. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/__init__.py +0 -0
  82. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/ripgrep/__init__.py +0 -0
  83. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/ripgrep/client.py +0 -0
  84. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/ripgrep/server.py +0 -0
  85. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/__init__.py +0 -0
  86. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/base_extractor.py +0 -0
  87. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/__init__.py +0 -0
  88. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/bash.py +0 -0
  89. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/cpp.py +0 -0
  90. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/csharp.py +0 -0
  91. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/go.py +0 -0
  92. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/java.py +0 -0
  93. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/javascript.py +0 -0
  94. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/kotlin.py +0 -0
  95. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/objc.py +0 -0
  96. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/php.py +0 -0
  97. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/python.py +0 -0
  98. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/ripgrep_fallback.py +0 -0
  99. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/ruby.py +0 -0
  100. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/rust.py +0 -0
  101. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/swift.py +0 -0
  102. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/extractors/terraform.py +0 -0
  103. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/models.py +0 -0
  104. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/parser.py +0 -0
  105. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/parsers/treesitter/server.py +0 -0
  106. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/storage/__init__.py +0 -0
  107. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/storage/base.py +0 -0
  108. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/storage/filesystem/__init__.py +0 -0
  109. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/storage/filesystem/client.py +0 -0
  110. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/storage/filesystem/server.py +0 -0
  111. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/storage/models.py +0 -0
  112. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/storage/s3/__init__.py +0 -0
  113. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/storage/s3/client.py +0 -0
  114. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/storage/s3/server.py +0 -0
  115. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/web/__init__.py +0 -0
  116. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/web/client.py +0 -0
  117. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/web/models.py +0 -0
  118. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/tools/web/server.py +0 -0
  119. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/workflows/__init__.py +0 -0
  120. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/workflows/review/__init__.py +0 -0
  121. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/workflows/review/models.py +0 -0
  122. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/workflows/review/reporters/__init__.py +0 -0
  123. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/workflows/review/reporters/base.py +0 -0
  124. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/workflows/review/reporters/git.py +0 -0
  125. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/src/codespy/workflows/review/reporters/stdout.py +0 -0
  126. {codespy_ai-2.0.0 → codespy_ai-2.0.1}/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.1
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.1"
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.1"
@@ -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)
@@ -14,14 +14,16 @@ from codespy.agents.context_safe import ContextSafe
14
14
  from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus
15
15
  from codespy.agents.memory.hippocampus.episode import submit_episode_save
16
16
  from codespy.agents.memory.prefrontal import with_prefrontal_memory
17
- from codespy.agents.review.models import Issue, IssueCategory, ReviewContext
18
- from codespy.agents.review.scope.models import ScopeResult
19
17
  from codespy.agents.review.helpers import (
18
+ filter_by_confidence,
20
19
  issues_to_markdown,
21
20
  make_scope_relative,
22
21
  resolve_scope_root,
23
22
  restore_repo_paths,
24
23
  )
24
+ from codespy.agents.review.models import Issue, IssueCategory, ReviewContext
25
+ from codespy.agents.review.scope.models import ScopeResult
26
+ from codespy.agents.rlm_tools import build_rlm_agent, log_rlm_outcome, run_rlm
25
27
  from codespy.config import get_settings
26
28
  from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal
27
29
  from codespy.tools.mcp_utils import cleanup_mcp_contexts, connect_mcp_server
@@ -129,6 +131,7 @@ class CodeReviewSignature(dspy.Signature):
129
131
  - description: ≤25 words, imperative tone, no filler ("Fix X", "Rename Y to Z")
130
132
  - No polite or conversational language
131
133
  - Do not populate code_snippet — use line numbers instead
134
+ - Always set confidence: ≥0.9 verified with tools; 0.7–0.9 strong evidence; <0.7 weak
132
135
  """
133
136
 
134
137
  scope: ScopeResult = dspy.InputField(
@@ -282,15 +285,9 @@ class CodeReviewer(dspy.Module):
282
285
  sig = with_prefrontal_memory(CodeReviewSignature) if review_context.prefrontal_memory else CodeReviewSignature
283
286
  recall_tool = pf.recall_tool() if pf else None
284
287
  agent_tools = [*tools, recall_tool] if recall_tool else tools
285
- agent = ContextSafe(
286
- dspy.RLM(
287
- sig,
288
- tools=agent_tools,
289
- max_iters=max_iters,
290
- max_llm_calls=self._settings.get_max_llm_calls("code_review"),
291
- ),
288
+ agent = build_rlm_agent(
292
289
  sig,
293
- tools=agent_tools,
290
+ agent_tools,
294
291
  name="code_review",
295
292
  max_iters=max_iters,
296
293
  max_llm_calls=self._settings.get_max_llm_calls("code_review"),
@@ -336,16 +333,11 @@ class CodeReviewer(dspy.Module):
336
333
  initial_memory=scope_initial_memory,
337
334
  topics=topics,
338
335
  )
339
- result = await agent.acall(
340
- scope=scoped,
341
- categories=categories,
342
- **pf_kwargs,
336
+ result = await run_rlm(agent, scope=scoped, categories=categories, **pf_kwargs)
337
+ log_rlm_outcome(result, "code_review", scope.subroot)
338
+ issues = filter_by_confidence(
339
+ result.issues, self._settings.review.min_confidence, "code_review", scope.subroot
343
340
  )
344
- issues = [
345
- issue
346
- for issue in (result.issues or [])
347
- if issue.confidence >= self._settings.review.min_confidence
348
- ]
349
341
  if hippo is not None:
350
342
  await hippo.aobserve(result)
351
343
  # Fire-and-forget background episode save
@@ -19,6 +19,7 @@ from codespy.agents.review.scope.models import ScopeResult
19
19
  from codespy.agents.review.doc.doc_extractor import extract_documentation
20
20
  from codespy.agents.review.helpers import (
21
21
  build_patches,
22
+ filter_by_confidence,
22
23
  issues_to_markdown,
23
24
  make_scope_relative,
24
25
  resolve_scope_root,
@@ -78,6 +79,7 @@ class DocReviewSignature(dspy.Signature):
78
79
  - description: ≤25 words, imperative tone ("Update X section", "Add Y to README")
79
80
  - Empty list if documentation is up to date. No approval text ("LGTM", "looks good")
80
81
  - No polite or conversational language
82
+ - Always set confidence: ≥0.9 verified with tools; 0.7–0.9 strong evidence; <0.7 weak
81
83
  """
82
84
 
83
85
  patches: str = dspy.InputField(
@@ -261,11 +263,9 @@ class DocReviewer(dspy.Module):
261
263
  categories=[IssueCategory.DOCUMENTATION],
262
264
  **pf_kwargs,
263
265
  )
264
- issues = [
265
- issue
266
- for issue in (result.issues or [])
267
- if issue.confidence >= self._settings.review.min_confidence
268
- ]
266
+ issues = filter_by_confidence(
267
+ result.issues, self._settings.review.min_confidence, "doc", scope.subroot
268
+ )
269
269
  if hippo is not None:
270
270
  await hippo.aobserve(result)
271
271
  # Fire-and-forget background episode save
@@ -267,3 +267,36 @@ def scope_package_names(scope: ScopeResult | Any) -> list[str]:
267
267
  if manifest is not None and getattr(manifest, "package_name", None):
268
268
  return [manifest.package_name]
269
269
  return []
270
+
271
+
272
+ def filter_by_confidence(
273
+ issues: list[Issue] | None,
274
+ min_confidence: float,
275
+ module: str,
276
+ scope_subroot: str,
277
+ ) -> list[Issue]:
278
+ """Filter issues by confidence threshold with logging.
279
+
280
+ Args:
281
+ issues: Raw issues list (may be None)
282
+ min_confidence: Minimum confidence threshold (0.0-1.0)
283
+ module: Module name for logging (e.g., 'code_review')
284
+ scope_subroot: Scope identifier for logging
285
+
286
+ Returns:
287
+ Filtered issues with confidence >= min_confidence
288
+ """
289
+ if issues is None:
290
+ logger.info("%s scope %s: 0 raw issues, 0 kept (min_confidence=%.2f)",
291
+ module, scope_subroot, min_confidence)
292
+ return []
293
+
294
+ raw_count = len(issues)
295
+ kept = [issue for issue in issues if issue.confidence >= min_confidence]
296
+ kept_count = len(kept)
297
+
298
+ logger.info(
299
+ "%s scope %s: %d raw issues, %d kept (min_confidence=%.2f)",
300
+ module, scope_subroot, raw_count, kept_count, min_confidence
301
+ )
302
+ return kept
@@ -128,7 +128,12 @@ class Issue(BaseModel):
128
128
  cwe_id: str | None = Field(
129
129
  default=None, description="CWE ID for security issues (e.g., CWE-79)"
130
130
  )
131
- confidence: float = Field(default=0.8, ge=0.0, le=1.0, description="Confidence score (0-1)")
131
+ confidence: float = Field(
132
+ default=0.8,
133
+ ge=0.0,
134
+ le=1.0,
135
+ description="Confidence score (0-1). ≥0.9 verified with tools; 0.7–0.9 strong evidence; <0.7 weak"
136
+ )
132
137
 
133
138
  @property
134
139
  def location(self) -> str:
@@ -18,13 +18,13 @@ import dspy # type: ignore[import-untyped]
18
18
  from pydantic import BaseModel, Field
19
19
 
20
20
  from codespy.agents import SignatureContext, get_cost_tracker
21
- from codespy.agents.context_safe import ContextSafe
22
21
  from codespy.agents.memory.hippocampus import ContextMemory, Hippocampus
23
22
  from codespy.agents.memory.hippocampus.episode import submit_episode_save
24
23
  from codespy.agents.memory.prefrontal import build_facets, with_prefrontal_memory
25
24
  from codespy.agents.review.models import ReviewContext
26
25
  from codespy.agents.review.scope.manifest_parser import extract_package_name
27
26
  from codespy.agents.review.scope.models import PackageManifest, ScopeResult, ScopeType
27
+ from codespy.agents.rlm_tools import build_rlm_agent, log_rlm_outcome, run_rlm
28
28
  from codespy.config import get_settings
29
29
  from codespy.config_memory import get_cerebral, get_episode_store, get_prefrontal
30
30
  from codespy.tools.git.client import get_client
@@ -1060,15 +1060,9 @@ class ScopeResolver(dspy.Module):
1060
1060
  )
1061
1061
  recall_tool = pf.recall_tool() if pf else None
1062
1062
  agent_tools = [*tools, recall_tool] if recall_tool else tools
1063
- agent = ContextSafe(
1064
- dspy.RLM(
1065
- sig,
1066
- tools=agent_tools,
1067
- max_iters=max_iters,
1068
- max_llm_calls=self._settings.get_max_llm_calls("scope"),
1069
- ),
1063
+ agent = build_rlm_agent(
1070
1064
  sig,
1071
- tools=agent_tools,
1065
+ agent_tools,
1072
1066
  name="scope",
1073
1067
  max_iters=max_iters,
1074
1068
  max_llm_calls=self._settings.get_max_llm_calls("scope"),
@@ -1123,7 +1117,8 @@ class ScopeResolver(dspy.Module):
1123
1117
  run_id=run_id,
1124
1118
  initial_memory=scope_initial_memory,
1125
1119
  )
1126
- result = await agent.acall(
1120
+ result = await run_rlm(
1121
+ agent,
1127
1122
  candidates=candidates_str,
1128
1123
  orphan_files=[f.filename for f in orphans],
1129
1124
  pr_title=pr.title or "No title",
@@ -1131,6 +1126,7 @@ class ScopeResolver(dspy.Module):
1131
1126
  project_instructions=project_instructions,
1132
1127
  **pf_kwargs,
1133
1128
  )
1129
+ log_rlm_outcome(result, "scope", pr.repo_full_name)
1134
1130
  if hippo is not None:
1135
1131
  await hippo.aobserve(result)
1136
1132