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.
- ralph/__init__.py +18 -0
- ralph/__main__.py +6 -0
- ralph/agents/__init__.py +15 -0
- ralph/agents/activity.py +28 -0
- ralph/agents/availability.py +62 -0
- ralph/agents/chain.py +296 -0
- ralph/agents/completion_signals.py +135 -0
- ralph/agents/execution_state.py +509 -0
- ralph/agents/executor.py +53 -0
- ralph/agents/idle_watchdog.py +821 -0
- ralph/agents/invoke.py +1703 -0
- ralph/agents/parsers/__init__.py +69 -0
- ralph/agents/parsers/base.py +167 -0
- ralph/agents/parsers/claude.py +572 -0
- ralph/agents/parsers/codex.py +262 -0
- ralph/agents/parsers/gemini.py +281 -0
- ralph/agents/parsers/generic.py +286 -0
- ralph/agents/parsers/opencode.py +254 -0
- ralph/agents/post_exit_watchdog.py +208 -0
- ralph/agents/registry.py +245 -0
- ralph/agents/subprocess_executor.py +159 -0
- ralph/agents/timeout_clock.py +72 -0
- ralph/api/__init__.py +14 -0
- ralph/api/cloud.py +45 -0
- ralph/api/opencode.py +152 -0
- ralph/banner.py +112 -0
- ralph/checkpoint/__init__.py +40 -0
- ralph/checkpoint/builder.py +132 -0
- ralph/checkpoint/execution_history.py +188 -0
- ralph/checkpoint/run_context.py +74 -0
- ralph/checkpoint/size_monitor.py +75 -0
- ralph/cli/__init__.py +9 -0
- ralph/cli/commands/__init__.py +26 -0
- ralph/cli/commands/check_policy.py +90 -0
- ralph/cli/commands/cleanup.py +65 -0
- ralph/cli/commands/commit.py +930 -0
- ralph/cli/commands/diagnose.py +537 -0
- ralph/cli/commands/explain.py +87 -0
- ralph/cli/commands/init.py +201 -0
- ralph/cli/commands/run.py +454 -0
- ralph/cli/main.py +966 -0
- ralph/cli/options.py +81 -0
- ralph/cloud/__init__.py +28 -0
- ralph/cloud/client.py +221 -0
- ralph/config/__init__.py +52 -0
- ralph/config/bootstrap.py +261 -0
- ralph/config/enums.py +97 -0
- ralph/config/loader.py +251 -0
- ralph/config/mcp_loader.py +130 -0
- ralph/config/mcp_models.py +135 -0
- ralph/config/models.py +444 -0
- ralph/config/welcome.py +202 -0
- ralph/diagnostics/__init__.py +262 -0
- ralph/display/__init__.py +134 -0
- ralph/display/activity_model.py +149 -0
- ralph/display/activity_router.py +162 -0
- ralph/display/artifact_reader.py +164 -0
- ralph/display/artifact_renderer.py +374 -0
- ralph/display/completion_summary.py +715 -0
- ralph/display/content_condenser.py +160 -0
- ralph/display/context.py +538 -0
- ralph/display/lifecycle_filter.py +74 -0
- ralph/display/line_sanitizer.py +26 -0
- ralph/display/long_content_summary.py +147 -0
- ralph/display/mode.py +20 -0
- ralph/display/parallel_display.py +388 -0
- ralph/display/phase_banner.py +550 -0
- ralph/display/phase_lifecycle.py +290 -0
- ralph/display/phase_status.py +126 -0
- ralph/display/plain_renderer.py +1378 -0
- ralph/display/progress.py +428 -0
- ralph/display/prompt_reader.py +41 -0
- ralph/display/raw_overflow.py +63 -0
- ralph/display/ring_buffer.py +69 -0
- ralph/display/snapshot.py +305 -0
- ralph/display/subscriber.py +466 -0
- ralph/display/tables.py +231 -0
- ralph/display/theme.py +220 -0
- ralph/display/tool_args.py +50 -0
- ralph/executor/__init__.py +23 -0
- ralph/executor/process.py +217 -0
- ralph/exit_pause/__init__.py +172 -0
- ralph/files/__init__.py +29 -0
- ralph/files/operations.py +134 -0
- ralph/git/__init__.py +66 -0
- ralph/git/__init__.pyi +14 -0
- ralph/git/executor.py +62 -0
- ralph/git/hooks.py +249 -0
- ralph/git/operations.py +316 -0
- ralph/git/rebase/__init__.py +69 -0
- ralph/git/rebase/rebase.py +348 -0
- ralph/git/rebase/rebase_checkpoint.py +347 -0
- ralph/git/rebase/rebase_continuation.py +206 -0
- ralph/git/rebase/rebase_kinds.py +389 -0
- ralph/git/rebase/rebase_preconditions.py +265 -0
- ralph/git/rebase/rebase_state_machine.py +242 -0
- ralph/git/subprocess_runner.py +78 -0
- ralph/git/wrapper.py +180 -0
- ralph/git/wrapper.pyi +14 -0
- ralph/guidelines/__init__.py +24 -0
- ralph/guidelines/go.py +169 -0
- ralph/guidelines/java.py +157 -0
- ralph/guidelines/javascript.py +285 -0
- ralph/guidelines/php.py +178 -0
- ralph/guidelines/python.py +168 -0
- ralph/guidelines/ruby.py +176 -0
- ralph/guidelines/rust.py +158 -0
- ralph/guidelines/stack.py +403 -0
- ralph/install.py +69 -0
- ralph/interrupt/__init__.py +25 -0
- ralph/interrupt/asyncio_bridge.py +87 -0
- ralph/interrupt/controller.py +134 -0
- ralph/interrupt/state.py +20 -0
- ralph/language_detector/__init__.py +82 -0
- ralph/language_detector/extensions.py +52 -0
- ralph/language_detector/models.py +44 -0
- ralph/language_detector/scanner.py +205 -0
- ralph/language_detector/signatures.py +284 -0
- ralph/logging.py +356 -0
- ralph/main.py +6 -0
- ralph/mcp/ARCHITECTURE.md +370 -0
- ralph/mcp/__init__.py +65 -0
- ralph/mcp/artifacts/__init__.py +40 -0
- ralph/mcp/artifacts/audit_adapter.py +230 -0
- ralph/mcp/artifacts/bridge.py +354 -0
- ralph/mcp/artifacts/commit_message.py +320 -0
- ralph/mcp/artifacts/development_result.py +61 -0
- ralph/mcp/artifacts/file_backend.py +48 -0
- ralph/mcp/artifacts/format_docs/__init__.py +120 -0
- ralph/mcp/artifacts/format_docs/artifact_formats_index.md +88 -0
- ralph/mcp/artifacts/format_docs/commit_message.md +96 -0
- ralph/mcp/artifacts/format_docs/development_analysis_decision.md +55 -0
- ralph/mcp/artifacts/format_docs/development_result.md +53 -0
- ralph/mcp/artifacts/format_docs/fix_result.md +50 -0
- ralph/mcp/artifacts/format_docs/issues.md +74 -0
- ralph/mcp/artifacts/format_docs/planning_analysis_decision.md +55 -0
- ralph/mcp/artifacts/format_docs/review_analysis_decision.md +55 -0
- ralph/mcp/artifacts/handoffs.py +281 -0
- ralph/mcp/artifacts/history.py +257 -0
- ralph/mcp/artifacts/plan.py +626 -0
- ralph/mcp/artifacts/policy_outcomes.py +34 -0
- ralph/mcp/artifacts/store.py +268 -0
- ralph/mcp/artifacts/typed_artifacts.py +171 -0
- ralph/mcp/multimodal/__init__.py +66 -0
- ralph/mcp/multimodal/artifacts.py +117 -0
- ralph/mcp/multimodal/capabilities.py +215 -0
- ralph/mcp/multimodal/errors.py +52 -0
- ralph/mcp/multimodal/resources.py +106 -0
- ralph/mcp/protocol/__init__.py +9 -0
- ralph/mcp/protocol/capability_mapping.py +590 -0
- ralph/mcp/protocol/env.py +28 -0
- ralph/mcp/protocol/session.py +73 -0
- ralph/mcp/protocol/startup.py +816 -0
- ralph/mcp/protocol/transport.py +236 -0
- ralph/mcp/server/__init__.py +31 -0
- ralph/mcp/server/__main__.py +6 -0
- ralph/mcp/server/factory.py +29 -0
- ralph/mcp/server/factory_impl.py +103 -0
- ralph/mcp/server/lifecycle.py +442 -0
- ralph/mcp/server/runtime.py +1043 -0
- ralph/mcp/session_plan.py +212 -0
- ralph/mcp/tools/__init__.py +8 -0
- ralph/mcp/tools/artifact.py +955 -0
- ralph/mcp/tools/bridge.py +1635 -0
- ralph/mcp/tools/coordination.py +268 -0
- ralph/mcp/tools/exec.py +570 -0
- ralph/mcp/tools/git_read.py +236 -0
- ralph/mcp/tools/names.py +248 -0
- ralph/mcp/tools/websearch.py +125 -0
- ralph/mcp/tools/webvisit.py +113 -0
- ralph/mcp/tools/workspace.py +1363 -0
- ralph/mcp/transport/__init__.py +37 -0
- ralph/mcp/transport/claude.py +70 -0
- ralph/mcp/transport/codex.py +144 -0
- ralph/mcp/transport/common.py +67 -0
- ralph/mcp/transport/opencode.py +80 -0
- ralph/mcp/upstream/__init__.py +9 -0
- ralph/mcp/upstream/agent_probe.py +341 -0
- ralph/mcp/upstream/client.py +489 -0
- ralph/mcp/upstream/config.py +146 -0
- ralph/mcp/upstream/models.py +29 -0
- ralph/mcp/upstream/registry.py +133 -0
- ralph/mcp/upstream/validation.py +239 -0
- ralph/mcp/websearch/__init__.py +14 -0
- ralph/mcp/websearch/backends/__init__.py +21 -0
- ralph/mcp/websearch/backends/base.py +28 -0
- ralph/mcp/websearch/backends/brave.py +86 -0
- ralph/mcp/websearch/backends/ddgs.py +71 -0
- ralph/mcp/websearch/backends/exa.py +105 -0
- ralph/mcp/websearch/backends/searxng.py +87 -0
- ralph/mcp/websearch/backends/tavily.py +95 -0
- ralph/mcp/websearch/secrets.py +45 -0
- ralph/mcp/webvisit/__init__.py +17 -0
- ralph/mcp/webvisit/extractor.py +131 -0
- ralph/mcp/webvisit/fetcher.py +210 -0
- ralph/phases/__init__.py +221 -0
- ralph/phases/analysis.py +221 -0
- ralph/phases/artifacts.py +90 -0
- ralph/phases/commit.py +117 -0
- ralph/phases/commit_logging.py +329 -0
- ralph/phases/execution.py +312 -0
- ralph/phases/integrity.py +118 -0
- ralph/phases/required_artifacts.py +173 -0
- ralph/phases/review.py +170 -0
- ralph/phases/timing.py +79 -0
- ralph/phases/verification.py +168 -0
- ralph/pipeline/__init__.py +19 -0
- ralph/pipeline/checkpoint.py +185 -0
- ralph/pipeline/cycle_baseline.py +50 -0
- ralph/pipeline/effects.py +233 -0
- ralph/pipeline/events.py +126 -0
- ralph/pipeline/handoffs.py +134 -0
- ralph/pipeline/orchestrator.py +299 -0
- ralph/pipeline/parallel/__init__.py +29 -0
- ralph/pipeline/parallel/coordinator.py +469 -0
- ralph/pipeline/parallel/mode.py +51 -0
- ralph/pipeline/parallel/scheduler.py +31 -0
- ralph/pipeline/parallel/worker_session.py +60 -0
- ralph/pipeline/progress.py +284 -0
- ralph/pipeline/reducer.py +1020 -0
- ralph/pipeline/runner.py +3778 -0
- ralph/pipeline/state.py +505 -0
- ralph/pipeline/work_units.py +244 -0
- ralph/pipeline/worker_state.py +47 -0
- ralph/platform/__init__.py +45 -0
- ralph/platform/detection.py +166 -0
- ralph/platform/models.py +102 -0
- ralph/policy/__init__.py +71 -0
- ralph/policy/defaults/agents.toml +84 -0
- ralph/policy/defaults/artifacts.toml +78 -0
- ralph/policy/defaults/mcp.toml +122 -0
- ralph/policy/defaults/pipeline.toml +269 -0
- ralph/policy/defaults/ralph-workflow-local.toml +138 -0
- ralph/policy/defaults/ralph-workflow.toml +157 -0
- ralph/policy/explain.py +343 -0
- ralph/policy/loader.py +453 -0
- ralph/policy/models.py +1072 -0
- ralph/policy/render.py +679 -0
- ralph/policy/validation.py +1028 -0
- ralph/process/README.md +113 -0
- ralph/process/__init__.py +40 -0
- ralph/process/child_liveness.py +333 -0
- ralph/process/liveness.py +142 -0
- ralph/process/manager.py +898 -0
- ralph/process/mcp_supervisor.py +91 -0
- ralph/prompts/__init__.py +55 -0
- ralph/prompts/commit/__init__.py +144 -0
- ralph/prompts/debug_dump.py +38 -0
- ralph/prompts/developer/__init__.py +255 -0
- ralph/prompts/materialize.py +1025 -0
- ralph/prompts/payload_refs.py +61 -0
- ralph/prompts/reviewer/__init__.py +79 -0
- ralph/prompts/system_prompt.py +86 -0
- ralph/prompts/template_context.py +36 -0
- ralph/prompts/template_engine.py +123 -0
- ralph/prompts/template_parsing.py +312 -0
- ralph/prompts/template_registry.py +98 -0
- ralph/prompts/template_variables.py +699 -0
- ralph/prompts/templates/commit_message.jinja +102 -0
- ralph/prompts/templates/commit_simplified.jinja +7 -0
- ralph/prompts/templates/conflict_resolution.jinja +11 -0
- ralph/prompts/templates/conflict_resolution_fallback.jinja +5 -0
- ralph/prompts/templates/developer_iteration.jinja +37 -0
- ralph/prompts/templates/developer_iteration_continuation.jinja +25 -0
- ralph/prompts/templates/developer_iteration_fallback.jinja +31 -0
- ralph/prompts/templates/development_analysis.jinja +275 -0
- ralph/prompts/templates/fix_mode.jinja +21 -0
- ralph/prompts/templates/planning.jinja +184 -0
- ralph/prompts/templates/planning_analysis.jinja +272 -0
- ralph/prompts/templates/planning_edit.jinja +128 -0
- ralph/prompts/templates/planning_edit_fallback.jinja +49 -0
- ralph/prompts/templates/planning_fallback.jinja +43 -0
- ralph/prompts/templates/review.jinja +68 -0
- ralph/prompts/templates/review_analysis.jinja +301 -0
- ralph/prompts/templates/shared/_analysis_context.jinja +12 -0
- ralph/prompts/templates/shared/_context_section.jinja +5 -0
- ralph/prompts/templates/shared/_critical_header.jinja +1 -0
- ralph/prompts/templates/shared/_developer_iteration_guidance.jinja +11 -0
- ralph/prompts/templates/shared/_diff_section.jinja +3 -0
- ralph/prompts/templates/shared/_mcp_tools.jinja +34 -0
- ralph/prompts/templates/shared/_no_git_commit.jinja +1 -0
- ralph/prompts/templates/shared/_output_checklist.jinja +4 -0
- ralph/prompts/templates/shared/_payload_section.jinja +28 -0
- ralph/prompts/templates/shared/_safety_no_execute.jinja +4 -0
- ralph/prompts/templates/shared/_session_capabilities.jinja +8 -0
- ralph/prompts/templates/shared/_unattended_mode.jinja +12 -0
- ralph/prompts/templates/worker_developer.jinja +27 -0
- ralph/prompts/types.py +97 -0
- ralph/recovery/__init__.py +61 -0
- ralph/recovery/budget.py +149 -0
- ralph/recovery/classifier.py +279 -0
- ralph/recovery/connectivity.py +142 -0
- ralph/recovery/controller.py +399 -0
- ralph/recovery/cycle_cap.py +32 -0
- ralph/recovery/events.py +89 -0
- ralph/recovery/testing.py +69 -0
- ralph/runtime/__init__.py +53 -0
- ralph/runtime/environment.py +184 -0
- ralph/runtime/verify_timeout.py +30 -0
- ralph/testing/__init__.py +67 -0
- ralph/testing/fake_agent_executor.py +83 -0
- ralph/testing/fake_process.py +413 -0
- ralph/timeout_defaults.py +71 -0
- ralph/verify.py +98 -0
- ralph/verify_timeout.py +162 -0
- ralph/workspace/__init__.py +19 -0
- ralph/workspace/fs.py +391 -0
- ralph/workspace/memory.py +426 -0
- ralph/workspace/protocol.py +232 -0
- ralph/workspace/scope.py +228 -0
- ralph/workspace/skip.py +29 -0
- ralph_workflow-0.8.0.dist-info/METADATA +604 -0
- ralph_workflow-0.8.0.dist-info/RECORD +316 -0
- ralph_workflow-0.8.0.dist-info/WHEEL +4 -0
- ralph_workflow-0.8.0.dist-info/entry_points.txt +3 -0
- 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
ralph/agents/__init__.py
ADDED
|
@@ -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
|
+
]
|
ralph/agents/activity.py
ADDED
|
@@ -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
|
+
]
|