ralph-workflow 0.8.0__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 (316) hide show
  1. ralph/__init__.py +18 -0
  2. ralph/__main__.py +6 -0
  3. ralph/agents/__init__.py +15 -0
  4. ralph/agents/activity.py +28 -0
  5. ralph/agents/availability.py +62 -0
  6. ralph/agents/chain.py +296 -0
  7. ralph/agents/completion_signals.py +135 -0
  8. ralph/agents/execution_state.py +509 -0
  9. ralph/agents/executor.py +53 -0
  10. ralph/agents/idle_watchdog.py +821 -0
  11. ralph/agents/invoke.py +1703 -0
  12. ralph/agents/parsers/__init__.py +69 -0
  13. ralph/agents/parsers/base.py +167 -0
  14. ralph/agents/parsers/claude.py +572 -0
  15. ralph/agents/parsers/codex.py +262 -0
  16. ralph/agents/parsers/gemini.py +281 -0
  17. ralph/agents/parsers/generic.py +286 -0
  18. ralph/agents/parsers/opencode.py +254 -0
  19. ralph/agents/post_exit_watchdog.py +208 -0
  20. ralph/agents/registry.py +245 -0
  21. ralph/agents/subprocess_executor.py +159 -0
  22. ralph/agents/timeout_clock.py +72 -0
  23. ralph/api/__init__.py +14 -0
  24. ralph/api/cloud.py +45 -0
  25. ralph/api/opencode.py +152 -0
  26. ralph/banner.py +112 -0
  27. ralph/checkpoint/__init__.py +40 -0
  28. ralph/checkpoint/builder.py +132 -0
  29. ralph/checkpoint/execution_history.py +188 -0
  30. ralph/checkpoint/run_context.py +74 -0
  31. ralph/checkpoint/size_monitor.py +75 -0
  32. ralph/cli/__init__.py +9 -0
  33. ralph/cli/commands/__init__.py +26 -0
  34. ralph/cli/commands/check_policy.py +90 -0
  35. ralph/cli/commands/cleanup.py +65 -0
  36. ralph/cli/commands/commit.py +930 -0
  37. ralph/cli/commands/diagnose.py +537 -0
  38. ralph/cli/commands/explain.py +87 -0
  39. ralph/cli/commands/init.py +201 -0
  40. ralph/cli/commands/run.py +454 -0
  41. ralph/cli/main.py +966 -0
  42. ralph/cli/options.py +81 -0
  43. ralph/cloud/__init__.py +28 -0
  44. ralph/cloud/client.py +221 -0
  45. ralph/config/__init__.py +52 -0
  46. ralph/config/bootstrap.py +261 -0
  47. ralph/config/enums.py +97 -0
  48. ralph/config/loader.py +251 -0
  49. ralph/config/mcp_loader.py +130 -0
  50. ralph/config/mcp_models.py +135 -0
  51. ralph/config/models.py +444 -0
  52. ralph/config/welcome.py +202 -0
  53. ralph/diagnostics/__init__.py +262 -0
  54. ralph/display/__init__.py +134 -0
  55. ralph/display/activity_model.py +149 -0
  56. ralph/display/activity_router.py +162 -0
  57. ralph/display/artifact_reader.py +164 -0
  58. ralph/display/artifact_renderer.py +374 -0
  59. ralph/display/completion_summary.py +715 -0
  60. ralph/display/content_condenser.py +160 -0
  61. ralph/display/context.py +538 -0
  62. ralph/display/lifecycle_filter.py +74 -0
  63. ralph/display/line_sanitizer.py +26 -0
  64. ralph/display/long_content_summary.py +147 -0
  65. ralph/display/mode.py +20 -0
  66. ralph/display/parallel_display.py +388 -0
  67. ralph/display/phase_banner.py +550 -0
  68. ralph/display/phase_lifecycle.py +290 -0
  69. ralph/display/phase_status.py +126 -0
  70. ralph/display/plain_renderer.py +1378 -0
  71. ralph/display/progress.py +428 -0
  72. ralph/display/prompt_reader.py +41 -0
  73. ralph/display/raw_overflow.py +63 -0
  74. ralph/display/ring_buffer.py +69 -0
  75. ralph/display/snapshot.py +305 -0
  76. ralph/display/subscriber.py +466 -0
  77. ralph/display/tables.py +231 -0
  78. ralph/display/theme.py +220 -0
  79. ralph/display/tool_args.py +50 -0
  80. ralph/executor/__init__.py +23 -0
  81. ralph/executor/process.py +217 -0
  82. ralph/exit_pause/__init__.py +172 -0
  83. ralph/files/__init__.py +29 -0
  84. ralph/files/operations.py +134 -0
  85. ralph/git/__init__.py +66 -0
  86. ralph/git/__init__.pyi +14 -0
  87. ralph/git/executor.py +62 -0
  88. ralph/git/hooks.py +249 -0
  89. ralph/git/operations.py +316 -0
  90. ralph/git/rebase/__init__.py +69 -0
  91. ralph/git/rebase/rebase.py +348 -0
  92. ralph/git/rebase/rebase_checkpoint.py +347 -0
  93. ralph/git/rebase/rebase_continuation.py +206 -0
  94. ralph/git/rebase/rebase_kinds.py +389 -0
  95. ralph/git/rebase/rebase_preconditions.py +265 -0
  96. ralph/git/rebase/rebase_state_machine.py +242 -0
  97. ralph/git/subprocess_runner.py +78 -0
  98. ralph/git/wrapper.py +180 -0
  99. ralph/git/wrapper.pyi +14 -0
  100. ralph/guidelines/__init__.py +24 -0
  101. ralph/guidelines/go.py +169 -0
  102. ralph/guidelines/java.py +157 -0
  103. ralph/guidelines/javascript.py +285 -0
  104. ralph/guidelines/php.py +178 -0
  105. ralph/guidelines/python.py +168 -0
  106. ralph/guidelines/ruby.py +176 -0
  107. ralph/guidelines/rust.py +158 -0
  108. ralph/guidelines/stack.py +403 -0
  109. ralph/install.py +69 -0
  110. ralph/interrupt/__init__.py +25 -0
  111. ralph/interrupt/asyncio_bridge.py +87 -0
  112. ralph/interrupt/controller.py +134 -0
  113. ralph/interrupt/state.py +20 -0
  114. ralph/language_detector/__init__.py +82 -0
  115. ralph/language_detector/extensions.py +52 -0
  116. ralph/language_detector/models.py +44 -0
  117. ralph/language_detector/scanner.py +205 -0
  118. ralph/language_detector/signatures.py +284 -0
  119. ralph/logging.py +356 -0
  120. ralph/main.py +6 -0
  121. ralph/mcp/ARCHITECTURE.md +370 -0
  122. ralph/mcp/__init__.py +65 -0
  123. ralph/mcp/artifacts/__init__.py +40 -0
  124. ralph/mcp/artifacts/audit_adapter.py +230 -0
  125. ralph/mcp/artifacts/bridge.py +354 -0
  126. ralph/mcp/artifacts/commit_message.py +320 -0
  127. ralph/mcp/artifacts/development_result.py +61 -0
  128. ralph/mcp/artifacts/file_backend.py +48 -0
  129. ralph/mcp/artifacts/format_docs/__init__.py +120 -0
  130. ralph/mcp/artifacts/format_docs/artifact_formats_index.md +88 -0
  131. ralph/mcp/artifacts/format_docs/commit_message.md +96 -0
  132. ralph/mcp/artifacts/format_docs/development_analysis_decision.md +55 -0
  133. ralph/mcp/artifacts/format_docs/development_result.md +53 -0
  134. ralph/mcp/artifacts/format_docs/fix_result.md +50 -0
  135. ralph/mcp/artifacts/format_docs/issues.md +74 -0
  136. ralph/mcp/artifacts/format_docs/planning_analysis_decision.md +55 -0
  137. ralph/mcp/artifacts/format_docs/review_analysis_decision.md +55 -0
  138. ralph/mcp/artifacts/handoffs.py +281 -0
  139. ralph/mcp/artifacts/history.py +257 -0
  140. ralph/mcp/artifacts/plan.py +626 -0
  141. ralph/mcp/artifacts/policy_outcomes.py +34 -0
  142. ralph/mcp/artifacts/store.py +268 -0
  143. ralph/mcp/artifacts/typed_artifacts.py +171 -0
  144. ralph/mcp/multimodal/__init__.py +66 -0
  145. ralph/mcp/multimodal/artifacts.py +117 -0
  146. ralph/mcp/multimodal/capabilities.py +215 -0
  147. ralph/mcp/multimodal/errors.py +52 -0
  148. ralph/mcp/multimodal/resources.py +106 -0
  149. ralph/mcp/protocol/__init__.py +9 -0
  150. ralph/mcp/protocol/capability_mapping.py +590 -0
  151. ralph/mcp/protocol/env.py +28 -0
  152. ralph/mcp/protocol/session.py +73 -0
  153. ralph/mcp/protocol/startup.py +816 -0
  154. ralph/mcp/protocol/transport.py +236 -0
  155. ralph/mcp/server/__init__.py +31 -0
  156. ralph/mcp/server/__main__.py +6 -0
  157. ralph/mcp/server/factory.py +29 -0
  158. ralph/mcp/server/factory_impl.py +103 -0
  159. ralph/mcp/server/lifecycle.py +442 -0
  160. ralph/mcp/server/runtime.py +1043 -0
  161. ralph/mcp/session_plan.py +212 -0
  162. ralph/mcp/tools/__init__.py +8 -0
  163. ralph/mcp/tools/artifact.py +955 -0
  164. ralph/mcp/tools/bridge.py +1635 -0
  165. ralph/mcp/tools/coordination.py +268 -0
  166. ralph/mcp/tools/exec.py +570 -0
  167. ralph/mcp/tools/git_read.py +236 -0
  168. ralph/mcp/tools/names.py +248 -0
  169. ralph/mcp/tools/websearch.py +125 -0
  170. ralph/mcp/tools/webvisit.py +113 -0
  171. ralph/mcp/tools/workspace.py +1363 -0
  172. ralph/mcp/transport/__init__.py +37 -0
  173. ralph/mcp/transport/claude.py +70 -0
  174. ralph/mcp/transport/codex.py +144 -0
  175. ralph/mcp/transport/common.py +67 -0
  176. ralph/mcp/transport/opencode.py +80 -0
  177. ralph/mcp/upstream/__init__.py +9 -0
  178. ralph/mcp/upstream/agent_probe.py +341 -0
  179. ralph/mcp/upstream/client.py +489 -0
  180. ralph/mcp/upstream/config.py +146 -0
  181. ralph/mcp/upstream/models.py +29 -0
  182. ralph/mcp/upstream/registry.py +133 -0
  183. ralph/mcp/upstream/validation.py +239 -0
  184. ralph/mcp/websearch/__init__.py +14 -0
  185. ralph/mcp/websearch/backends/__init__.py +21 -0
  186. ralph/mcp/websearch/backends/base.py +28 -0
  187. ralph/mcp/websearch/backends/brave.py +86 -0
  188. ralph/mcp/websearch/backends/ddgs.py +71 -0
  189. ralph/mcp/websearch/backends/exa.py +105 -0
  190. ralph/mcp/websearch/backends/searxng.py +87 -0
  191. ralph/mcp/websearch/backends/tavily.py +95 -0
  192. ralph/mcp/websearch/secrets.py +45 -0
  193. ralph/mcp/webvisit/__init__.py +17 -0
  194. ralph/mcp/webvisit/extractor.py +131 -0
  195. ralph/mcp/webvisit/fetcher.py +210 -0
  196. ralph/phases/__init__.py +221 -0
  197. ralph/phases/analysis.py +221 -0
  198. ralph/phases/artifacts.py +90 -0
  199. ralph/phases/commit.py +117 -0
  200. ralph/phases/commit_logging.py +329 -0
  201. ralph/phases/execution.py +312 -0
  202. ralph/phases/integrity.py +118 -0
  203. ralph/phases/required_artifacts.py +173 -0
  204. ralph/phases/review.py +170 -0
  205. ralph/phases/timing.py +79 -0
  206. ralph/phases/verification.py +168 -0
  207. ralph/pipeline/__init__.py +19 -0
  208. ralph/pipeline/checkpoint.py +185 -0
  209. ralph/pipeline/cycle_baseline.py +50 -0
  210. ralph/pipeline/effects.py +233 -0
  211. ralph/pipeline/events.py +126 -0
  212. ralph/pipeline/handoffs.py +134 -0
  213. ralph/pipeline/orchestrator.py +299 -0
  214. ralph/pipeline/parallel/__init__.py +29 -0
  215. ralph/pipeline/parallel/coordinator.py +469 -0
  216. ralph/pipeline/parallel/mode.py +51 -0
  217. ralph/pipeline/parallel/scheduler.py +31 -0
  218. ralph/pipeline/parallel/worker_session.py +60 -0
  219. ralph/pipeline/progress.py +284 -0
  220. ralph/pipeline/reducer.py +1020 -0
  221. ralph/pipeline/runner.py +3778 -0
  222. ralph/pipeline/state.py +505 -0
  223. ralph/pipeline/work_units.py +244 -0
  224. ralph/pipeline/worker_state.py +47 -0
  225. ralph/platform/__init__.py +45 -0
  226. ralph/platform/detection.py +166 -0
  227. ralph/platform/models.py +102 -0
  228. ralph/policy/__init__.py +71 -0
  229. ralph/policy/defaults/agents.toml +84 -0
  230. ralph/policy/defaults/artifacts.toml +78 -0
  231. ralph/policy/defaults/mcp.toml +122 -0
  232. ralph/policy/defaults/pipeline.toml +269 -0
  233. ralph/policy/defaults/ralph-workflow-local.toml +138 -0
  234. ralph/policy/defaults/ralph-workflow.toml +157 -0
  235. ralph/policy/explain.py +343 -0
  236. ralph/policy/loader.py +453 -0
  237. ralph/policy/models.py +1072 -0
  238. ralph/policy/render.py +679 -0
  239. ralph/policy/validation.py +1028 -0
  240. ralph/process/README.md +113 -0
  241. ralph/process/__init__.py +40 -0
  242. ralph/process/child_liveness.py +333 -0
  243. ralph/process/liveness.py +142 -0
  244. ralph/process/manager.py +898 -0
  245. ralph/process/mcp_supervisor.py +91 -0
  246. ralph/prompts/__init__.py +55 -0
  247. ralph/prompts/commit/__init__.py +144 -0
  248. ralph/prompts/debug_dump.py +38 -0
  249. ralph/prompts/developer/__init__.py +255 -0
  250. ralph/prompts/materialize.py +1025 -0
  251. ralph/prompts/payload_refs.py +61 -0
  252. ralph/prompts/reviewer/__init__.py +79 -0
  253. ralph/prompts/system_prompt.py +86 -0
  254. ralph/prompts/template_context.py +36 -0
  255. ralph/prompts/template_engine.py +123 -0
  256. ralph/prompts/template_parsing.py +312 -0
  257. ralph/prompts/template_registry.py +98 -0
  258. ralph/prompts/template_variables.py +699 -0
  259. ralph/prompts/templates/commit_message.jinja +102 -0
  260. ralph/prompts/templates/commit_simplified.jinja +7 -0
  261. ralph/prompts/templates/conflict_resolution.jinja +11 -0
  262. ralph/prompts/templates/conflict_resolution_fallback.jinja +5 -0
  263. ralph/prompts/templates/developer_iteration.jinja +37 -0
  264. ralph/prompts/templates/developer_iteration_continuation.jinja +25 -0
  265. ralph/prompts/templates/developer_iteration_fallback.jinja +31 -0
  266. ralph/prompts/templates/development_analysis.jinja +275 -0
  267. ralph/prompts/templates/fix_mode.jinja +21 -0
  268. ralph/prompts/templates/planning.jinja +184 -0
  269. ralph/prompts/templates/planning_analysis.jinja +272 -0
  270. ralph/prompts/templates/planning_edit.jinja +128 -0
  271. ralph/prompts/templates/planning_edit_fallback.jinja +49 -0
  272. ralph/prompts/templates/planning_fallback.jinja +43 -0
  273. ralph/prompts/templates/review.jinja +68 -0
  274. ralph/prompts/templates/review_analysis.jinja +301 -0
  275. ralph/prompts/templates/shared/_analysis_context.jinja +12 -0
  276. ralph/prompts/templates/shared/_context_section.jinja +5 -0
  277. ralph/prompts/templates/shared/_critical_header.jinja +1 -0
  278. ralph/prompts/templates/shared/_developer_iteration_guidance.jinja +11 -0
  279. ralph/prompts/templates/shared/_diff_section.jinja +3 -0
  280. ralph/prompts/templates/shared/_mcp_tools.jinja +34 -0
  281. ralph/prompts/templates/shared/_no_git_commit.jinja +1 -0
  282. ralph/prompts/templates/shared/_output_checklist.jinja +4 -0
  283. ralph/prompts/templates/shared/_payload_section.jinja +28 -0
  284. ralph/prompts/templates/shared/_safety_no_execute.jinja +4 -0
  285. ralph/prompts/templates/shared/_session_capabilities.jinja +8 -0
  286. ralph/prompts/templates/shared/_unattended_mode.jinja +12 -0
  287. ralph/prompts/templates/worker_developer.jinja +27 -0
  288. ralph/prompts/types.py +97 -0
  289. ralph/recovery/__init__.py +61 -0
  290. ralph/recovery/budget.py +149 -0
  291. ralph/recovery/classifier.py +279 -0
  292. ralph/recovery/connectivity.py +142 -0
  293. ralph/recovery/controller.py +399 -0
  294. ralph/recovery/cycle_cap.py +32 -0
  295. ralph/recovery/events.py +89 -0
  296. ralph/recovery/testing.py +69 -0
  297. ralph/runtime/__init__.py +53 -0
  298. ralph/runtime/environment.py +184 -0
  299. ralph/runtime/verify_timeout.py +30 -0
  300. ralph/testing/__init__.py +67 -0
  301. ralph/testing/fake_agent_executor.py +83 -0
  302. ralph/testing/fake_process.py +413 -0
  303. ralph/timeout_defaults.py +71 -0
  304. ralph/verify.py +98 -0
  305. ralph/verify_timeout.py +162 -0
  306. ralph/workspace/__init__.py +19 -0
  307. ralph/workspace/fs.py +391 -0
  308. ralph/workspace/memory.py +426 -0
  309. ralph/workspace/protocol.py +232 -0
  310. ralph/workspace/scope.py +228 -0
  311. ralph/workspace/skip.py +29 -0
  312. ralph_workflow-0.8.0.dist-info/METADATA +604 -0
  313. ralph_workflow-0.8.0.dist-info/RECORD +316 -0
  314. ralph_workflow-0.8.0.dist-info/WHEEL +4 -0
  315. ralph_workflow-0.8.0.dist-info/entry_points.txt +3 -0
  316. ralph_workflow-0.8.0.dist-info/licenses/LICENSE +659 -0
ralph/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Top-level package for Ralph Workflow.
2
+
3
+ The public Python package is intentionally small at the root: it exposes version
4
+ metadata and points users toward the major subpackages that make up the system.
5
+
6
+ Useful pydoc entry points:
7
+
8
+ - ``ralph.cli`` for the Typer CLI application
9
+ - ``ralph.config`` for configuration models and loading
10
+ - ``ralph.pipeline`` for orchestration state and reducer/orchestrator logic
11
+ - ``ralph.phases`` for phase dispatch
12
+ - ``ralph.mcp`` for the MCP bridge and standalone server helpers
13
+ - ``ralph.git`` for GitPython-backed repository operations
14
+ - ``ralph.workspace`` for filesystem abstractions used by production code and tests
15
+ """
16
+
17
+ __version__ = "0.8.0"
18
+ __all__ = ["__version__"]
ralph/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m ralph`."""
2
+
3
+ from ralph.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,15 @@
1
+ """Public agent-management exports.
2
+
3
+ This package exposes the small set of agent abstractions most callers need:
4
+ registry lookup, chain composition, and process invocation.
5
+ """
6
+
7
+ from ralph.agents.chain import AgentChain
8
+ from ralph.agents.invoke import invoke_agent
9
+ from ralph.agents.registry import AgentRegistry
10
+
11
+ __all__ = [
12
+ "AgentChain",
13
+ "AgentRegistry",
14
+ "invoke_agent",
15
+ ]
@@ -0,0 +1,28 @@
1
+ """Watchdog-relevant activity signals emitted by agent transports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+
9
+ class AgentActivityKind(StrEnum):
10
+ """Kinds of agent activity that can reset the idle watchdog."""
11
+
12
+ OUTPUT_LINE = "output_line"
13
+ STREAM_DELTA = "stream_delta"
14
+ TOOL_USE = "tool_use"
15
+ TOOL_RESULT = "tool_result"
16
+ LIFECYCLE = "lifecycle"
17
+ CHILD_PROCESS = "child_process"
18
+ CHILD_HEARTBEAT = "child_heartbeat"
19
+ CHILD_PROGRESS = "child_progress"
20
+ CHILD_TERMINAL_ACK = "child_terminal_ack"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class AgentActivitySignal:
25
+ """Small transport-neutral signal consumed by timeout control flow."""
26
+
27
+ kind: AgentActivityKind
28
+ raw: str = ""
@@ -0,0 +1,62 @@
1
+ """Agent PATH availability checks for Ralph Workflow.
2
+
3
+ Shared helper used by both the first-run welcome banner and the
4
+ `ralph --diagnose` command to determine whether configured agents
5
+ are reachable on the system PATH.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ from typing import Literal, Protocol, runtime_checkable
12
+
13
+ AgentStatus = Literal["available", "missing_on_path", "no_cmd"]
14
+
15
+
16
+ class _AgentEntry(Protocol):
17
+ """Minimal agent config interface for availability checks."""
18
+
19
+ cmd: str
20
+ display_name: str | None
21
+
22
+
23
+ @runtime_checkable
24
+ class HasListAgents(Protocol):
25
+ """Protocol for agent registries used in availability checks."""
26
+
27
+ def list_agents(self) -> list[str]:
28
+ ...
29
+
30
+ def get(self, name: str) -> _AgentEntry | None:
31
+ ...
32
+
33
+
34
+ def check_agent_availability(
35
+ registry: HasListAgents,
36
+ ) -> list[tuple[str, AgentStatus]]:
37
+ """Check which agents are available on PATH.
38
+
39
+ Args:
40
+ registry: Object implementing list_agents() and get(name) for agent resolution.
41
+
42
+ Returns:
43
+ List of (registry_name, status) tuples where status is one of
44
+ 'available', 'missing_on_path', or 'no_cmd'.
45
+ The key is always the configured registry name so callers can join
46
+ back to the registry without a secondary display-name lookup.
47
+ """
48
+ results: list[tuple[str, AgentStatus]] = []
49
+ for name in registry.list_agents():
50
+ agent = registry.get(name)
51
+ if agent is None:
52
+ continue
53
+ cmd = agent.cmd
54
+ if not cmd:
55
+ results.append((name, "no_cmd"))
56
+ continue
57
+ first_word = cmd.split(maxsplit=1)[0]
58
+ status: AgentStatus = (
59
+ "available" if shutil.which(first_word) is not None else "missing_on_path"
60
+ )
61
+ results.append((name, status))
62
+ return results
ralph/agents/chain.py ADDED
@@ -0,0 +1,296 @@
1
+ """Agent fallback chain management with strict drain-to-chain binding.
2
+
3
+ This module handles the agent fallback chain — the ordered list of agents
4
+ to try when an agent fails. It supports retry logic and exponential backoff.
5
+
6
+ IMPORTANT: This module implements STRICT drain-to-chain binding. Every drain
7
+ must have an explicit binding in AgentsPolicy or startup validation fails.
8
+ There is NO permissive fallback resolution — no sibling fallback, no inference,
9
+ no default chains. If a drain is not bound, DrainNotBoundError is raised.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from typing import TYPE_CHECKING
16
+
17
+ from loguru import logger
18
+
19
+ from ralph.policy.models import AgentChainConfig, AgentDrainConfig, AgentsPolicy, DrainName
20
+
21
+ if TYPE_CHECKING:
22
+ from ralph.config.models import UnifiedConfig
23
+
24
+
25
+ class DrainNotBoundError(Exception):
26
+ """Raised when a drain has no explicit chain binding.
27
+
28
+ Attributes:
29
+ drain: The unbound drain name.
30
+ available_drains: Names of all bound drains.
31
+ """
32
+
33
+ def __init__(self, drain: str, available_drains: set[str]) -> None:
34
+ self.drain = drain
35
+ self.available_drains = available_drains
36
+ available = sorted(available_drains)
37
+ msg = (
38
+ f"Drain '{drain}' is not bound to any agent chain in agents.toml. "
39
+ f"Available drains: {available}. "
40
+ f"Add a binding for '{drain}' in agent_drains or use a bound drain."
41
+ )
42
+ super().__init__(msg)
43
+
44
+
45
+ class UnknownAgentError(Exception):
46
+ """Raised when an agent name is not found in the registry.
47
+
48
+ Attributes:
49
+ agent_name: The unknown agent name.
50
+ """
51
+
52
+ def __init__(self, agent_name: str) -> None:
53
+ self.agent_name = agent_name
54
+ msg = f"Unknown agent: '{agent_name}'. Register the agent in the configuration."
55
+ super().__init__(msg)
56
+
57
+
58
+ class AgentChain:
59
+ """Manages agent fallback chain with retry logic.
60
+
61
+ The chain maintains an ordered list of agents and handles:
62
+ - Current agent selection
63
+ - Retry counting and limits
64
+ - Exponential backoff between retries
65
+ - Fallback to next agent on exhaustion
66
+
67
+ Attributes:
68
+ agents: List of agent names in the chain.
69
+ current_index: Index of the currently selected agent.
70
+ retries: Number of retries for current agent.
71
+ max_retries: Maximum retries before falling back.
72
+ retry_delay_ms: Base delay between retries in milliseconds.
73
+ backoff_multiplier: Multiplier for exponential backoff.
74
+ max_backoff_ms: Maximum backoff delay in milliseconds.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ agents: list[str],
80
+ max_retries: int = 3,
81
+ retry_delay_ms: int = 1000,
82
+ backoff_multiplier: float = 2.0,
83
+ max_backoff_ms: int = 60000,
84
+ ) -> None:
85
+ """Initialize agent chain.
86
+
87
+ Args:
88
+ agents: List of agent names in fallback order.
89
+ max_retries: Maximum retries per agent before fallback.
90
+ retry_delay_ms: Base delay between retries in milliseconds.
91
+ backoff_multiplier: Multiplier for exponential backoff.
92
+ max_backoff_ms: Maximum backoff delay in milliseconds.
93
+ """
94
+ self.agents = agents
95
+ self.current_index = 0
96
+ self.retries = 0
97
+ self.max_retries = max_retries
98
+ self.retry_delay_ms = retry_delay_ms
99
+ self.backoff_multiplier = backoff_multiplier
100
+ self.max_backoff_ms = max_backoff_ms
101
+
102
+ @property
103
+ def current_agent(self) -> str | None:
104
+ """Get the current agent name.
105
+
106
+ Returns:
107
+ Agent name or None if chain is exhausted.
108
+ """
109
+ if not self.agents or self.current_index >= len(self.agents):
110
+ return None
111
+ return self.agents[self.current_index]
112
+
113
+ @property
114
+ def is_exhausted(self) -> bool:
115
+ """Check if all agents in chain are exhausted.
116
+
117
+ Returns:
118
+ True if no agents remain.
119
+ """
120
+ return self.current_agent is None
121
+
122
+ def can_retry(self) -> bool:
123
+ """Check if current agent can be retried.
124
+
125
+ Returns:
126
+ True if retries remain for current agent.
127
+ """
128
+ return self.retries < self.max_retries
129
+
130
+ def advance(self) -> bool:
131
+ """Advance to the next agent in the chain.
132
+
133
+ Returns:
134
+ True if advanced successfully, False if chain exhausted.
135
+ """
136
+ if self.current_index + 1 < len(self.agents):
137
+ self.current_index += 1
138
+ self.retries = 0
139
+ logger.debug("Advanced to next agent: {}", self.current_agent)
140
+ return True
141
+ logger.debug("Agent chain exhausted")
142
+ return False
143
+
144
+ def record_retry(self) -> None:
145
+ """Record a retry attempt for current agent."""
146
+ self.retries += 1
147
+ logger.debug(
148
+ "Retry {} of {} for agent {}",
149
+ self.retries,
150
+ self.max_retries,
151
+ self.current_agent,
152
+ )
153
+
154
+ def calculate_backoff(self) -> float:
155
+ """Calculate backoff delay in seconds.
156
+
157
+ Returns:
158
+ Backoff delay in seconds.
159
+ """
160
+ delay = self.retry_delay_ms * (self.backoff_multiplier**self.retries)
161
+ return min(delay, self.max_backoff_ms) / 1000.0
162
+
163
+ def wait_backoff(self) -> None:
164
+ """Wait for the backoff period."""
165
+ backoff = self.calculate_backoff()
166
+ logger.debug("Backing off for {:.2f} seconds", backoff)
167
+ time.sleep(backoff)
168
+
169
+
170
+ class ChainManager:
171
+ """Manages agent chains with strict drain-to-chain binding.
172
+
173
+ ChainManager is constructed with an AgentsPolicy and provides lookup of
174
+ chains by drain name. Drain resolution is STRICT — there is no fallback
175
+ or inference. If a drain is not explicitly bound, DrainNotBoundError
176
+ is raised.
177
+
178
+ Attributes:
179
+ agents_policy: The agents policy containing chains and drain bindings.
180
+ """
181
+
182
+ def __init__(self, agents_policy: AgentsPolicy) -> None:
183
+ """Initialize ChainManager with an AgentsPolicy.
184
+
185
+ Args:
186
+ agents_policy: Validated agents policy with chain and drain definitions.
187
+ """
188
+ self._policy = agents_policy
189
+
190
+ @classmethod
191
+ def from_config(cls, config: UnifiedConfig) -> ChainManager:
192
+ """Create ChainManager from a legacy UnifiedConfig.
193
+
194
+ This is a compatibility shim that converts the old UnifiedConfig
195
+ format to the new AgentsPolicy format.
196
+
197
+ Args:
198
+ config: Legacy unified configuration.
199
+
200
+ Returns:
201
+ ChainManager instance.
202
+ """
203
+ agent_chains: dict[str, AgentChainConfig] = {}
204
+ for name, agents in config.agent_chains.items():
205
+ agent_chains[name] = AgentChainConfig(agents=agents)
206
+
207
+ agent_drains: dict[DrainName, AgentDrainConfig] = {}
208
+ for drain, chain in config.agent_drains.items():
209
+ agent_drains[drain] = AgentDrainConfig(chain=chain)
210
+
211
+ policy = AgentsPolicy(
212
+ agent_chains=agent_chains,
213
+ agent_drains=agent_drains,
214
+ )
215
+ return cls(policy)
216
+
217
+ def chain_for_drain(self, drain: DrainName) -> AgentChainConfig:
218
+ """Get the chain configuration for a drain.
219
+
220
+ This is the STRICT drain resolution — no fallback, no inference.
221
+ If the drain is not explicitly bound in agents.toml, DrainNotBoundError
222
+ is raised at startup before any agent is invoked.
223
+
224
+ Args:
225
+ drain: Drain name to look up.
226
+
227
+ Returns:
228
+ AgentChainConfig for the bound chain.
229
+
230
+ Raises:
231
+ DrainNotBoundError: If the drain is not explicitly bound.
232
+ """
233
+ binding = self._policy.agent_drains.get(drain)
234
+ if binding is None:
235
+ raise DrainNotBoundError(
236
+ drain=drain,
237
+ available_drains=set(self._policy.agent_drains.keys()),
238
+ )
239
+
240
+ chain = self._policy.agent_chains.get(binding.chain)
241
+ if chain is None:
242
+ msg = (
243
+ f"Drain '{drain}' references chain '{binding.chain}' "
244
+ f"which is not defined in agent_chains"
245
+ )
246
+ raise ValueError(msg)
247
+
248
+ return chain
249
+
250
+ def chain_config_for_drain(self, drain: DrainName) -> AgentChainConfig:
251
+ """Alias for chain_for_drain for clarity."""
252
+ return self.chain_for_drain(drain)
253
+
254
+ def validate(self) -> list[str]:
255
+ """Validate the policy for internal consistency.
256
+
257
+ Returns:
258
+ List of validation error messages (empty if valid).
259
+ """
260
+ errors: list[str] = []
261
+
262
+ for drain, binding in self._policy.agent_drains.items():
263
+ if binding.chain not in self._policy.agent_chains:
264
+ errors.append(f"Drain '{drain}' references unknown chain '{binding.chain}'")
265
+
266
+ for name, chain in self._policy.agent_chains.items():
267
+ if not chain.agents:
268
+ errors.append(f"Chain '{name}' has no agents")
269
+
270
+ return errors
271
+
272
+
273
+ def create_chain_from_config(
274
+ config: UnifiedConfig,
275
+ chain_name: str,
276
+ ) -> AgentChain | None:
277
+ """Create an AgentChain from UnifiedConfig.
278
+
279
+ Args:
280
+ config: Unified configuration.
281
+ chain_name: Name of the chain in agent_chains.
282
+
283
+ Returns:
284
+ AgentChain instance or None if chain not found.
285
+ """
286
+ agent_names = config.agent_chains.get(chain_name)
287
+ if not agent_names:
288
+ return None
289
+
290
+ return AgentChain(
291
+ agents=agent_names,
292
+ max_retries=config.general.max_retries,
293
+ retry_delay_ms=config.general.retry_delay_ms,
294
+ backoff_multiplier=config.general.backoff_multiplier,
295
+ max_backoff_ms=config.general.max_backoff_ms,
296
+ )
@@ -0,0 +1,135 @@
1
+ """Completion signal evaluation for OpenCode agent exits.
2
+
3
+ evaluate_completion() inspects the workspace artifacts directory and the raw
4
+ NDJSON output to determine whether an OpenCode agent run produced the required
5
+ phase artifact or explicitly declared completion via the declare_complete MCP
6
+ tool. Explicit completion and artifact presence are separate signals; the
7
+ explicit-complete flag is never auto-set just because a phase has no required
8
+ artifact entry.
9
+
10
+ Phases whose pipeline definition marks the output artifact optional
11
+ (`artifact_required=False`) are treated as terminal on a clean exit even when no
12
+ artifact is produced and no explicit declare_complete call is made. The artifact
13
+ provides context only; its absence does not gate phase success. A present optional
14
+ artifact is still fully validated.
15
+
16
+ Phases without any artifact contract return required_artifact_present=False.
17
+ OpenCode agents running such phases must still call declare_complete explicitly
18
+ rather than relying on implicit success.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import dataclass
25
+ from typing import TYPE_CHECKING, cast
26
+
27
+ if TYPE_CHECKING:
28
+ from pathlib import Path
29
+
30
+ from ralph.phases.required_artifacts import RequiredArtifact
31
+
32
+ _EXPLICIT_COMPLETION_MARKER = "Task declared complete:"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class CompletionSignals:
37
+ """Signals that indicate whether an agent run actually completed its work.
38
+
39
+ Attributes:
40
+ explicit_complete: True when the agent called the declare_complete MCP
41
+ tool successfully (independent of artifact presence).
42
+ required_artifact_present: True when the required phase artifact exists
43
+ on disk. False when the phase has no registered required artifact or
44
+ the artifact file does not yet exist.
45
+ artifact_types: Tuple of artifact type names found.
46
+ terminal_ack_seen: True when a child_terminal lifecycle ACK was received
47
+ from the OpenCode transport.
48
+ artifact_optional: True when the phase marks its output artifact optional
49
+ (artifact_required=False). A clean exit is terminal even without the
50
+ artifact or an explicit declare_complete call.
51
+ """
52
+
53
+ explicit_complete: bool
54
+ required_artifact_present: bool
55
+ artifact_types: tuple[str, ...]
56
+ terminal_ack_seen: bool = False
57
+ artifact_optional: bool = False
58
+
59
+
60
+ def extract_explicit_completion(raw_output: list[str]) -> bool:
61
+ """Return True if raw NDJSON output contains a successful declare_complete call.
62
+
63
+ Detects the unique marker produced by handle_declare_complete() in
64
+ ralph/mcp/tools/coordination.py. The marker string only appears in the
65
+ output when the agent successfully calls the declare_complete MCP tool.
66
+
67
+ Args:
68
+ raw_output: Raw NDJSON lines from the agent subprocess stdout.
69
+
70
+ Returns:
71
+ True if the declare_complete marker is found in any output line.
72
+ """
73
+ return any(_EXPLICIT_COMPLETION_MARKER in line for line in raw_output)
74
+
75
+
76
+ def _artifact_is_schema_valid(artifact_path: Path) -> bool:
77
+ """Return True when the artifact file exists, parses as JSON, and is a non-empty dict."""
78
+ if not artifact_path.exists():
79
+ return False
80
+ try:
81
+ content = artifact_path.read_text(encoding="utf-8")
82
+ parsed = cast("object", json.loads(content))
83
+ return isinstance(parsed, dict) and len(parsed) > 0
84
+ except (OSError, json.JSONDecodeError, ValueError):
85
+ return False
86
+
87
+
88
+ def evaluate_completion(
89
+ workspace: Path,
90
+ raw_output: list[str] | None = None,
91
+ *,
92
+ required_artifact: RequiredArtifact | None = None,
93
+ ) -> CompletionSignals:
94
+ """Check whether the agent run produced a required artifact or explicit completion.
95
+
96
+ explicit_complete is set from scanning raw_output for the declare_complete
97
+ MCP tool marker, independently of artifact presence. required_artifact_present
98
+ is True only when the artifact file exists on disk, parses as valid JSON,
99
+ and contains a non-empty dict for phases that have a registered required artifact.
100
+ Phases without a registered required artifact always return
101
+ required_artifact_present=False so OpenCode agents cannot implicitly succeed
102
+ — they must call declare_complete explicitly.
103
+
104
+ Args:
105
+ workspace: Workspace root path.
106
+ raw_output: Raw NDJSON lines from agent stdout for explicit-completion detection.
107
+ required_artifact: Policy-derived artifact metadata.
108
+
109
+ Returns:
110
+ CompletionSignals reflecting current artifact state and explicit completion.
111
+ """
112
+ explicit = extract_explicit_completion(raw_output or [])
113
+ ra = required_artifact
114
+ if ra is None:
115
+ return CompletionSignals(
116
+ explicit_complete=explicit,
117
+ required_artifact_present=False,
118
+ artifact_types=(),
119
+ )
120
+ artifact_path = workspace / ra.json_path
121
+ present = _artifact_is_schema_valid(artifact_path)
122
+ optional = not ra.artifact_required
123
+ return CompletionSignals(
124
+ explicit_complete=explicit,
125
+ required_artifact_present=present,
126
+ artifact_types=(ra.artifact_type,) if present else (),
127
+ artifact_optional=optional,
128
+ )
129
+
130
+
131
+ __all__ = [
132
+ "CompletionSignals",
133
+ "evaluate_completion",
134
+ "extract_explicit_completion",
135
+ ]