symphony-code 0.0.1__py3-none-any.whl

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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,118 @@
1
+ """Render dropped turns for the model and build the compacted-context message."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import List, Sequence
7
+
8
+ from core_ai.content import text_from_content
9
+ from core_ai.types import Message
10
+ from core_harness.addons.compaction import (
11
+ COMPACTION_CONTINUATION,
12
+ compaction_header,
13
+ dropped_turn_facts,
14
+ )
15
+
16
+ DEFAULT_TRANSCRIPT_MAX_CHARS = 24_000
17
+ _MESSAGE_PREVIEW_CHARS = 700
18
+ _TOOL_RESULT_PREVIEW_CHARS = 400
19
+ _TOOL_ARGS_PREVIEW_CHARS = 160
20
+
21
+
22
+ def _clamp(text: str, limit: int) -> str:
23
+ text = text.strip()
24
+ if len(text) <= limit:
25
+ return text
26
+ return text[: limit - 1].rstrip() + "…"
27
+
28
+
29
+ def _tool_call_line(call: dict) -> str:
30
+ function = call.get("function") or {}
31
+ name = str(function.get("name") or "tool")
32
+ raw_arguments = function.get("arguments") or ""
33
+ if isinstance(raw_arguments, str):
34
+ try:
35
+ decoded = json.loads(raw_arguments) if raw_arguments else {}
36
+ except json.JSONDecodeError:
37
+ decoded = raw_arguments
38
+ else:
39
+ decoded = raw_arguments
40
+ arguments = decoded if isinstance(decoded, str) else json.dumps(decoded, sort_keys=True)
41
+ return f"assistant → {name}({_clamp(arguments, _TOOL_ARGS_PREVIEW_CHARS)})"
42
+
43
+
44
+ def render_dropped_turns(
45
+ turns: Sequence[Sequence[Message]],
46
+ *,
47
+ max_chars: int = DEFAULT_TRANSCRIPT_MAX_CHARS,
48
+ ) -> str:
49
+ """Plain-text transcript of the turns about to be dropped.
50
+
51
+ Each message is clamped individually; if the whole transcript is still too
52
+ long the middle is elided so both the earliest asks and the most recent
53
+ work reach the model.
54
+ """
55
+ names: dict[str, str] = {}
56
+ lines: List[str] = []
57
+ for turn in turns:
58
+ for message in turn:
59
+ if message.role == "assistant" and message.tool_calls:
60
+ for call in message.tool_calls:
61
+ function = call.get("function") or {}
62
+ names[str(call.get("id") or "")] = str(function.get("name") or "tool")
63
+ text = text_from_content(message.content).strip()
64
+ if text:
65
+ lines.append(f"assistant: {_clamp(text, _MESSAGE_PREVIEW_CHARS)}")
66
+ lines.extend(_tool_call_line(call) for call in message.tool_calls)
67
+ elif message.role == "tool":
68
+ name = names.get(str(message.tool_call_id or ""), "tool")
69
+ body = text_from_content(message.content)
70
+ lines.append(f"tool[{name}]: {_clamp(body, _TOOL_RESULT_PREVIEW_CHARS)}")
71
+ else:
72
+ text = text_from_content(message.content)
73
+ lines.append(f"{message.role}: {_clamp(text, _MESSAGE_PREVIEW_CHARS)}")
74
+
75
+ transcript = "\n".join(lines)
76
+ if len(transcript) <= max_chars:
77
+ return transcript
78
+ head_budget = max_chars // 2
79
+ tail_budget = max_chars - head_budget
80
+ head: List[str] = []
81
+ used = 0
82
+ for line in lines:
83
+ if used + len(line) + 1 > head_budget:
84
+ break
85
+ head.append(line)
86
+ used += len(line) + 1
87
+ tail: List[str] = []
88
+ used = 0
89
+ for line in reversed(lines[len(head):]):
90
+ if used + len(line) + 1 > tail_budget:
91
+ break
92
+ tail.insert(0, line)
93
+ used += len(line) + 1
94
+ omitted = len(lines) - len(head) - len(tail)
95
+ return "\n".join([*head, f"… ({omitted} lines omitted) …", *tail])
96
+
97
+
98
+ def build_compacted_message(
99
+ turns: Sequence[Sequence[Message]],
100
+ *,
101
+ narrative: str,
102
+ tokens: int,
103
+ ) -> Message:
104
+ """Compacted-context message: header, model narrative, deterministic facts."""
105
+ dropped_messages = sum(len(turn) for turn in turns)
106
+ lines = compaction_header(dropped_messages, tokens=tokens)
107
+ lines.append("Summary of the dropped work:")
108
+ lines.append(narrative.strip())
109
+ lines.extend(dropped_turn_facts(turns))
110
+ lines.append(COMPACTION_CONTINUATION)
111
+ return Message(role="user", content="\n".join(lines))
112
+
113
+
114
+ __all__ = [
115
+ "DEFAULT_TRANSCRIPT_MAX_CHARS",
116
+ "build_compacted_message",
117
+ "render_dropped_turns",
118
+ ]
@@ -0,0 +1,68 @@
1
+ """Optional Jev critic mode for coding-agent runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from coding_agent._lazy import resolve
8
+ from coding_agent.addons.evaluation.evaluators import (
9
+ LLMEvaluator,
10
+ MockEvaluator,
11
+ StubEvaluator,
12
+ VercelJevEvaluator,
13
+ )
14
+ from coding_agent.addons.evaluation.handlers import HandlerResult, dispatch
15
+ from coding_agent.addons.evaluation.policy import (
16
+ JEV_SYSTEM_SEGMENT,
17
+ NUDGE_MARKER,
18
+ PolicyDecision,
19
+ apply_findings_gate,
20
+ critic_message,
21
+ decide_next_step,
22
+ )
23
+ from coding_agent.addons.evaluation.protocol import (
24
+ CONTINUE_NORMALLY,
25
+ EvaluationDecision,
26
+ EvaluationFinding,
27
+ EvaluationResult,
28
+ Evaluator,
29
+ FINISH_QUESTIONS,
30
+ START_QUESTIONS,
31
+ )
32
+ from coding_agent.addons.evaluation.state import RunState, build_run_state, clip_text
33
+
34
+ _ADDON_EXPORTS = {
35
+ "JevAddon": ("coding_agent.addons.evaluation.addon", "JevAddon"),
36
+ "jev_from_config": ("coding_agent.addons.evaluation.addon", "jev_from_config"),
37
+ }
38
+
39
+ __all__ = [
40
+ "CONTINUE_NORMALLY",
41
+ "EvaluationDecision",
42
+ "EvaluationFinding",
43
+ "EvaluationResult",
44
+ "Evaluator",
45
+ "FINISH_QUESTIONS",
46
+ "HandlerResult",
47
+ "JEV_SYSTEM_SEGMENT",
48
+ "JevAddon",
49
+ "LLMEvaluator",
50
+ "NUDGE_MARKER",
51
+ "PolicyDecision",
52
+ "MockEvaluator",
53
+ "RunState",
54
+ "START_QUESTIONS",
55
+ "StubEvaluator",
56
+ "VercelJevEvaluator",
57
+ "apply_findings_gate",
58
+ "build_run_state",
59
+ "clip_text",
60
+ "critic_message",
61
+ "decide_next_step",
62
+ "dispatch",
63
+ "jev_from_config",
64
+ ]
65
+
66
+
67
+ def __getattr__(name: str) -> Any:
68
+ return resolve(globals(), name, _ADDON_EXPORTS)
@@ -0,0 +1,199 @@
1
+ """Jev critic add-on. Evaluates completed runs and suggests revisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, Optional
7
+
8
+ from core_ai.types import Message
9
+ from core_harness.addons.addon import Addon
10
+
11
+ from coding_agent.config import EvaluationConfig
12
+ from coding_agent.addons.evaluation.evaluators import VercelJevEvaluator
13
+ from coding_agent.addons.evaluation.handlers import dispatch
14
+ from coding_agent.addons.evaluation.policy import (
15
+ MAX_HONOURED_FOLLOW_UPS,
16
+ PolicyDecision,
17
+ critic_message,
18
+ decide_next_step,
19
+ is_jev_nudge,
20
+ )
21
+ from coding_agent.addons.evaluation.protocol import (
22
+ CONTINUE_NORMALLY,
23
+ EvaluationResult,
24
+ EvaluationPhase,
25
+ Evaluator,
26
+ error_result,
27
+ )
28
+ from coding_agent.addons.evaluation.state import bound_text, build_run_state
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class JevAddon(Addon):
34
+ """Evaluate completed runs against the user's rule and task.
35
+
36
+ Dispatches named handlers from ``last_decision.action``. Does not override
37
+ ``before_turn`` or ``on_tool`` and never gates tools. A violated user rule
38
+ can trigger one automatic follow-up. Evaluation uses only the conversation,
39
+ tool results, and final response; it does not read workspace files.
40
+ """
41
+
42
+ name = "jev"
43
+
44
+ def __init__(
45
+ self,
46
+ config: EvaluationConfig,
47
+ *,
48
+ evaluator: Optional[Evaluator] = None,
49
+ ) -> None:
50
+ self.config = config
51
+ self.evaluator = evaluator or VercelJevEvaluator(config)
52
+ self.last_start: Optional[EvaluationResult] = None
53
+ self.last_finish: Optional[EvaluationResult] = None
54
+ self.last_decision: Optional[PolicyDecision] = None
55
+ self.follow_ups = 0
56
+ self._harness: Any = None
57
+
58
+ def attach(self, harness: Any) -> None:
59
+ self._harness = harness
60
+
61
+ def fork_for_child(self, parent_harness: Any) -> None:
62
+ """Skip inherit: child runs are not evaluated by Jev."""
63
+ del parent_harness
64
+ return None
65
+
66
+ async def _call(
67
+ self, method_name: str, phase: EvaluationPhase, **state_kwargs: Any
68
+ ) -> EvaluationResult:
69
+ state = build_run_state(config=self.config, phase=phase, **state_kwargs)
70
+ method = getattr(self.evaluator, method_name)
71
+ try:
72
+ result = await method(state)
73
+ except Exception as exc:
74
+ logger.warning("Jev %s failed open: %s", method_name, exc)
75
+ result = error_result(phase, str(exc)[:280])
76
+ decision = decide_next_step(result)
77
+ if (
78
+ (self.config.honour_follow_up or self.config.rule.strip())
79
+ and decision.should_act
80
+ and self.follow_ups >= MAX_HONOURED_FOLLOW_UPS
81
+ and decision.action == "retry"
82
+ ):
83
+ decision = PolicyDecision(
84
+ action=CONTINUE_NORMALLY,
85
+ reason="follow-up budget exhausted",
86
+ finding=decision.finding,
87
+ )
88
+ self.last_decision = decision
89
+ logger.info(
90
+ "jev %s status=%s findings=%s action=%s reason=%s",
91
+ phase,
92
+ result.status,
93
+ [finding.question for finding in result.findings],
94
+ decision.action,
95
+ decision.reason,
96
+ )
97
+ if self._harness is not None:
98
+ for addon in self._harness.addons:
99
+ hook = getattr(addon, "on_evaluation", None)
100
+ if callable(hook):
101
+ await hook(phase=phase, result=result, evaluator="jev")
102
+ return result
103
+
104
+ async def before_run(self, **payload: Any) -> None:
105
+ self._clear_nudge(payload)
106
+
107
+ async def after_run(self, **payload: Any) -> None:
108
+ result = payload.get("result")
109
+ messages = payload.get("messages")
110
+ if not isinstance(messages, list):
111
+ messages = getattr(result, "messages", None) or []
112
+ tool_calls = getattr(result, "tool_calls", None) or ()
113
+ final = getattr(result, "output_text", None) or ""
114
+ self.last_finish = await self._call(
115
+ "on_finish",
116
+ "finish",
117
+ request=payload.get("task") or "",
118
+ messages=messages,
119
+ final=final,
120
+ tool_calls=tool_calls,
121
+ )
122
+ self._dispatch(payload, self.last_decision)
123
+
124
+ def consume_follow_up(self) -> Optional[str]:
125
+ """Return one revision prompt for a violated rule or opt-in retry."""
126
+ if not (self.config.honour_follow_up or self.config.rule.strip()):
127
+ return None
128
+ decision = self.last_decision
129
+ if decision is None or decision.action != "retry":
130
+ return None
131
+ if not self.config.honour_follow_up and (
132
+ decision.finding is None or decision.finding.question != "rule_satisfied"
133
+ ):
134
+ return None
135
+ if self.follow_ups >= MAX_HONOURED_FOLLOW_UPS:
136
+ return None
137
+ self.follow_ups += 1
138
+ if decision.finding is not None and decision.finding.question == "rule_satisfied":
139
+ return bound_text(
140
+ f"Revise the completed work to satisfy this user rule: {self.config.rule}. "
141
+ f"Jev found: {decision.reason}",
142
+ self.config.request_max_chars,
143
+ )
144
+ return critic_message(decision)
145
+
146
+ def _dispatch(
147
+ self,
148
+ payload: dict[str, Any],
149
+ decision: Optional[PolicyDecision],
150
+ *,
151
+ queued: Optional[str] = None,
152
+ ) -> Optional[str]:
153
+ effect = dispatch(decision or PolicyDecision())
154
+ if effect.note:
155
+ self._apply_nudge(payload, effect.note)
156
+ return effect.note
157
+ if queued:
158
+ self._apply_nudge(payload, queued)
159
+ return queued
160
+ self._clear_nudge(payload)
161
+ return None
162
+
163
+ def _message_lists(self, payload: dict[str, Any]) -> list[list[Any]]:
164
+ result = payload.get("result")
165
+ seen: set[int] = set()
166
+ lists: list[list[Any]] = []
167
+ for target in (payload.get("messages"), getattr(result, "messages", None)):
168
+ if not isinstance(target, list) or id(target) in seen:
169
+ continue
170
+ seen.add(id(target))
171
+ lists.append(target)
172
+ return lists
173
+
174
+ def _apply_nudge(self, payload: dict[str, Any], note: str) -> None:
175
+ """Write the note onto live messages and ``HarnessResult.messages``."""
176
+ for target in self._message_lists(payload):
177
+ target[:] = [item for item in target if not is_jev_nudge(item)]
178
+ target.append(Message(role="user", content=note))
179
+
180
+ def _clear_nudge(self, payload: dict[str, Any]) -> None:
181
+ for target in self._message_lists(payload):
182
+ target[:] = [item for item in target if not is_jev_nudge(item)]
183
+
184
+
185
+ def jev_from_config(
186
+ config: EvaluationConfig,
187
+ *,
188
+ evaluator: Optional[Evaluator] = None,
189
+ ) -> Optional[JevAddon]:
190
+ """Mount the add-on only when critic mode is enabled."""
191
+ if not config.enabled:
192
+ return None
193
+ return JevAddon(
194
+ config,
195
+ evaluator=evaluator,
196
+ )
197
+
198
+
199
+ __all__ = ["JevAddon", "jev_from_config"]
@@ -0,0 +1,245 @@
1
+ """Evaluator implementations. Vercel helpers are reused, not forked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from typing import Any, Optional
9
+
10
+ import httpx
11
+
12
+ from core_ai.providers.catalog import find_provider, provider_api_key
13
+ from core_ai.providers.vercel_evaluation import (
14
+ evaluation_model_url,
15
+ evaluation_request_body,
16
+ evaluation_request_headers,
17
+ is_evaluation_model,
18
+ )
19
+ from core_ai.types import Message
20
+
21
+ from coding_agent.config import EvaluationConfig
22
+ from coding_agent.addons.evaluation.protocol import (
23
+ EvaluationDecision,
24
+ EvaluationPhase,
25
+ EvaluationResult,
26
+ FINISH_QUESTIONS,
27
+ START_QUESTIONS,
28
+ error_result,
29
+ findings_from_decisions,
30
+ skipped_result,
31
+ )
32
+ from coding_agent.addons.evaluation.state import RunState, clip_text
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ RATIONALE_MAX_CHARS = 280
37
+
38
+
39
+ def vercel_credentials(config: EvaluationConfig) -> tuple[str, str]:
40
+ """Return ``(api_key, base_url)`` for the configured evaluation provider."""
41
+ spec = find_provider(config.provider)
42
+ api_key = provider_api_key(spec) if spec is not None else ""
43
+ env_base = (os.environ.get("AI_GATEWAY_BASE_URL") or "").strip()
44
+ default_base = (spec.default_base_url or "").strip() if spec is not None else ""
45
+ return (api_key or "", env_base or default_base)
46
+
47
+
48
+ def decision_from_answer(name: str, answer: object) -> EvaluationDecision:
49
+ if not isinstance(answer, dict):
50
+ return EvaluationDecision(
51
+ name=name,
52
+ kind="error",
53
+ label="invalid",
54
+ rationale="answer was not an object",
55
+ )
56
+ kind = str(answer.get("type") or "").strip() or "error"
57
+ rationale = clip_text(
58
+ str(answer.get("rationale") or answer.get("reason") or answer.get("explanation") or ""),
59
+ RATIONALE_MAX_CHARS,
60
+ )
61
+ if kind == "boolean":
62
+ probability = answer.get("probability")
63
+ probs: Optional[dict[str, float]] = None
64
+ if isinstance(probability, (int, float)):
65
+ true_p = float(probability)
66
+ probs = {"true": true_p, "false": float(1.0 - true_p)}
67
+ value = true_p >= 0.5
68
+ else:
69
+ value = bool(answer.get("value"))
70
+ return EvaluationDecision(
71
+ name=name,
72
+ kind="boolean",
73
+ value=value,
74
+ probs=probs,
75
+ label="true" if value else "false",
76
+ rationale=rationale,
77
+ )
78
+ if kind == "choice":
79
+ choice = str(answer.get("choice") or "").strip()
80
+ return EvaluationDecision(
81
+ name=name,
82
+ kind="choice",
83
+ value=choice,
84
+ label=choice,
85
+ rationale=rationale,
86
+ )
87
+ if kind == "score":
88
+ score = answer.get("score")
89
+ return EvaluationDecision(
90
+ name=name,
91
+ kind="score",
92
+ value=score,
93
+ label="" if score is None else str(score),
94
+ rationale=rationale,
95
+ )
96
+ return EvaluationDecision(
97
+ name=name,
98
+ kind="error",
99
+ value=answer,
100
+ label=kind or "invalid",
101
+ rationale=rationale or "unsupported answer type",
102
+ )
103
+
104
+
105
+ def result_from_answers(phase: EvaluationPhase, answers: object) -> EvaluationResult:
106
+ if not isinstance(answers, dict) or not answers:
107
+ return skipped_result(phase, "evaluation model returned no answers")
108
+ decisions = [decision_from_answer(str(name), answer) for name, answer in answers.items()]
109
+ return EvaluationResult(
110
+ phase=phase,
111
+ status="ok",
112
+ decisions=decisions,
113
+ findings=findings_from_decisions(decisions),
114
+ )
115
+
116
+
117
+ class StubEvaluator:
118
+ """Offline evaluator: records nothing and returns skipped."""
119
+
120
+ async def on_start(self, state: RunState) -> EvaluationResult:
121
+ del state
122
+ return skipped_result("start", "stub evaluator")
123
+
124
+ async def on_finish(self, state: RunState) -> EvaluationResult:
125
+ del state
126
+ return skipped_result("finish", "stub evaluator")
127
+
128
+
129
+ class MockEvaluator:
130
+ """Test double that records each start/finish call."""
131
+
132
+ def __init__(
133
+ self,
134
+ *,
135
+ start: Optional[EvaluationResult] = None,
136
+ finish: Optional[EvaluationResult] = None,
137
+ error: Optional[BaseException] = None,
138
+ ) -> None:
139
+ self.start_result = start
140
+ self.finish_result = finish
141
+ self.error = error
142
+ self.starts: list[RunState] = []
143
+ self.finishes: list[RunState] = []
144
+
145
+ async def on_start(self, state: RunState) -> EvaluationResult:
146
+ self.starts.append(state)
147
+ if self.error is not None:
148
+ raise self.error
149
+ return self.start_result or skipped_result("start", "mock")
150
+
151
+ async def on_finish(self, state: RunState) -> EvaluationResult:
152
+ self.finishes.append(state)
153
+ if self.error is not None:
154
+ raise self.error
155
+ return self.finish_result or skipped_result("finish", "mock")
156
+
157
+
158
+ class LLMEvaluator:
159
+ """Placeholder chat-model critic. Not wired; Jev never becomes the primary model."""
160
+
161
+ async def on_start(self, state: RunState) -> EvaluationResult:
162
+ del state
163
+ return skipped_result("start", "LLMEvaluator is a stub; use VercelJevEvaluator")
164
+
165
+ async def on_finish(self, state: RunState) -> EvaluationResult:
166
+ del state
167
+ return skipped_result("finish", "LLMEvaluator is a stub; use VercelJevEvaluator")
168
+
169
+
170
+ class VercelJevEvaluator:
171
+ """POST ``/v4/ai/evaluation-model`` using ``core_ai.providers.vercel_evaluation``."""
172
+
173
+ def __init__(
174
+ self,
175
+ config: EvaluationConfig,
176
+ *,
177
+ api_key: Optional[str] = None,
178
+ base_url: str = "",
179
+ transport: Optional[httpx.AsyncBaseTransport] = None,
180
+ ) -> None:
181
+ self.config = config
182
+ resolved_key, resolved_base = vercel_credentials(config)
183
+ self.api_key = resolved_key if api_key is None else api_key
184
+ self.base_url = base_url or resolved_base
185
+ self.transport = transport
186
+
187
+ async def on_start(self, state: RunState) -> EvaluationResult:
188
+ return await self._evaluate("start", state, START_QUESTIONS)
189
+
190
+ async def on_finish(self, state: RunState) -> EvaluationResult:
191
+ questions = dict(FINISH_QUESTIONS)
192
+ if state.rule:
193
+ questions["rule_satisfied"] = {
194
+ "type": "boolean",
195
+ "instructions": (
196
+ "Did the work across this run satisfy the user's rule in state.rule? "
197
+ "Judge the observed work and final result. Explain any concrete violation."
198
+ ),
199
+ }
200
+ return await self._evaluate("finish", state, questions)
201
+
202
+ async def _evaluate(
203
+ self,
204
+ phase: EvaluationPhase,
205
+ state: RunState,
206
+ questions: dict[str, Any],
207
+ ) -> EvaluationResult:
208
+ if self.config.on_error != "fail-open":
209
+ return skipped_result(phase, "unsupported on_error policy")
210
+ if not self.api_key:
211
+ return skipped_result(phase, "missing Vercel AI Gateway API key")
212
+ model = self.config.model
213
+ if not is_evaluation_model(model):
214
+ return error_result(phase, f"{model} is not an evaluation model")
215
+ payload = {"state": state.as_eval_state(), "questions": questions}
216
+ body = evaluation_request_body(
217
+ [Message(role="user", content=json.dumps(payload))]
218
+ )
219
+ try:
220
+ async with httpx.AsyncClient(transport=self.transport) as client:
221
+ response = await client.post(
222
+ evaluation_model_url(self.base_url),
223
+ json=body,
224
+ headers=evaluation_request_headers(model, api_key=self.api_key),
225
+ timeout=60.0,
226
+ )
227
+ response.raise_for_status()
228
+ data = response.json()
229
+ except Exception as exc:
230
+ logger.warning("Jev evaluator %s failed open: %s", phase, exc)
231
+ return error_result(phase, clip_text(str(exc), RATIONALE_MAX_CHARS))
232
+ if not isinstance(data, dict):
233
+ return error_result(phase, "evaluation model returned a non-object response")
234
+ return result_from_answers(phase, data.get("answers"))
235
+
236
+
237
+ __all__ = [
238
+ "LLMEvaluator",
239
+ "MockEvaluator",
240
+ "StubEvaluator",
241
+ "VercelJevEvaluator",
242
+ "decision_from_answer",
243
+ "result_from_answers",
244
+ "vercel_credentials",
245
+ ]
@@ -0,0 +1,66 @@
1
+ """Per-action Jev handlers. Policy chooses ``action``; this module actuates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, Optional
7
+
8
+ from coding_agent.addons.evaluation.policy import PolicyDecision, critic_message
9
+ from coding_agent.addons.evaluation.protocol import CONTINUE_NORMALLY
10
+
11
+ # Read-only allowlist: write/edit/bash (and other implement tools) stay gated.
12
+ READ_ONLY_TOOLS = frozenset({"read_file", "search", "ask_user", "memory"})
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class HandlerResult:
17
+ note: Optional[str] = None
18
+ allow: Optional[frozenset[str]] = None
19
+
20
+
21
+ def handle_replan(decision: PolicyDecision) -> HandlerResult:
22
+ return HandlerResult(note=critic_message(decision), allow=READ_ONLY_TOOLS)
23
+
24
+
25
+ def handle_retry(decision: PolicyDecision) -> HandlerResult:
26
+ return HandlerResult(note=critic_message(decision))
27
+
28
+
29
+ def handle_review(decision: PolicyDecision) -> HandlerResult:
30
+ return HandlerResult(note=critic_message(decision))
31
+
32
+
33
+ def handle_gather(decision: PolicyDecision) -> HandlerResult:
34
+ return HandlerResult(note=critic_message(decision), allow=READ_ONLY_TOOLS)
35
+
36
+
37
+ def handle_ask(decision: PolicyDecision) -> HandlerResult:
38
+ return HandlerResult(note=critic_message(decision), allow=READ_ONLY_TOOLS)
39
+
40
+
41
+ def handle_finish(decision: PolicyDecision) -> HandlerResult:
42
+ del decision
43
+ return HandlerResult()
44
+
45
+
46
+ def handle_unsupported_claims(decision: PolicyDecision) -> HandlerResult:
47
+ return handle_review(decision)
48
+
49
+
50
+ HANDLERS: dict[str, Callable[[PolicyDecision], HandlerResult]] = {
51
+ "replan": handle_replan,
52
+ "retry": handle_retry,
53
+ "review": handle_review,
54
+ "gather": handle_gather,
55
+ "ask": handle_ask,
56
+ CONTINUE_NORMALLY: handle_finish,
57
+ "unsupported_claims": handle_unsupported_claims,
58
+ }
59
+
60
+
61
+ def dispatch(decision: PolicyDecision) -> HandlerResult:
62
+ """Named handler for ``last_decision.action`` (plus unsupported_claims)."""
63
+ finding = decision.finding
64
+ if decision.action == "review" and finding is not None and finding.question == "unsupported_claims":
65
+ return handle_unsupported_claims(decision)
66
+ return HANDLERS.get(decision.action, handle_finish)(decision)