forgexa-cli 1.47.4__tar.gz → 1.48.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/PKG-INFO +1 -1
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli/agent_core.py +62 -2
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli/daemon.py +329 -2
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/pyproject.toml +1 -1
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/README.md +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/setup.cfg +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.47.4 → forgexa_cli-1.48.0}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.48.0"
|
|
@@ -1616,14 +1616,74 @@ def build_opencode_command(
|
|
|
1616
1616
|
return cmd
|
|
1617
1617
|
|
|
1618
1618
|
|
|
1619
|
-
def opencode_base_env(
|
|
1620
|
-
|
|
1619
|
+
def opencode_base_env(
|
|
1620
|
+
env: dict[str, str], *, data_home: str | Path, require_windows_shell: bool = False,
|
|
1621
|
+
) -> dict[str, str]:
|
|
1622
|
+
"""Isolate each OpenCode run in its own XDG data directory.
|
|
1621
1623
|
|
|
1622
1624
|
Prevents concurrent opencode processes from racing on the shared SQLite
|
|
1623
1625
|
WAL file ('Failed to run the query PRAGMA wal_checkpoint(PASSIVE)').
|
|
1624
1626
|
"""
|
|
1625
1627
|
env = dict(env)
|
|
1626
1628
|
env["XDG_DATA_HOME"] = str(data_home)
|
|
1629
|
+
return configure_opencode_windows_shell(env, required=require_windows_shell)
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _find_opencode_windows_shell(env: dict[str, str]) -> str | None:
|
|
1633
|
+
configured = env.get("OPENCODE_GIT_BASH_PATH", "").strip()
|
|
1634
|
+
if configured and Path(configured).is_file():
|
|
1635
|
+
return configured
|
|
1636
|
+
|
|
1637
|
+
git = shutil.which("git", path=env.get("PATH"))
|
|
1638
|
+
if git:
|
|
1639
|
+
git_path = Path(git)
|
|
1640
|
+
for git_root in (git_path.parent.parent, git_path.parent.parent.parent):
|
|
1641
|
+
git_bash = git_root / "bin" / "bash.exe"
|
|
1642
|
+
if git_bash.is_file():
|
|
1643
|
+
return str(git_bash)
|
|
1644
|
+
|
|
1645
|
+
return shutil.which("pwsh", path=env.get("PATH"))
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
def configure_opencode_windows_shell(
|
|
1649
|
+
env: dict[str, str], *, required: bool = False,
|
|
1650
|
+
) -> dict[str, str]:
|
|
1651
|
+
"""Force OpenCode off classic Windows PowerShell for agent tool calls."""
|
|
1652
|
+
env = dict(env)
|
|
1653
|
+
if sys.platform != "win32":
|
|
1654
|
+
return env
|
|
1655
|
+
|
|
1656
|
+
shell = _find_opencode_windows_shell(env)
|
|
1657
|
+
if not shell:
|
|
1658
|
+
message = (
|
|
1659
|
+
"OpenCode on Windows requires Git Bash or PowerShell 7 (pwsh) so "
|
|
1660
|
+
"agent tool output is UTF-8. Install Git for Windows or PowerShell 7, "
|
|
1661
|
+
"then retry or select another agent."
|
|
1662
|
+
)
|
|
1663
|
+
if required:
|
|
1664
|
+
raise RuntimeError(message)
|
|
1665
|
+
logger.warning(message)
|
|
1666
|
+
return env
|
|
1667
|
+
|
|
1668
|
+
raw_config = env.get("OPENCODE_CONFIG_CONTENT", "").strip()
|
|
1669
|
+
config: dict[str, Any] = {}
|
|
1670
|
+
if raw_config:
|
|
1671
|
+
try:
|
|
1672
|
+
parsed = json.loads(raw_config)
|
|
1673
|
+
except json.JSONDecodeError as exc:
|
|
1674
|
+
raise RuntimeError(
|
|
1675
|
+
"OPENCODE_CONFIG_CONTENT must be a JSON object to configure "
|
|
1676
|
+
"OpenCode's Windows tool shell."
|
|
1677
|
+
) from exc
|
|
1678
|
+
if not isinstance(parsed, dict):
|
|
1679
|
+
raise RuntimeError(
|
|
1680
|
+
"OPENCODE_CONFIG_CONTENT must be a JSON object to configure "
|
|
1681
|
+
"OpenCode's Windows tool shell."
|
|
1682
|
+
)
|
|
1683
|
+
config = parsed
|
|
1684
|
+
|
|
1685
|
+
config["shell"] = shell
|
|
1686
|
+
env["OPENCODE_CONFIG_CONTENT"] = json.dumps(config, ensure_ascii=False)
|
|
1627
1687
|
return env
|
|
1628
1688
|
|
|
1629
1689
|
|
|
@@ -1120,7 +1120,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1120
1120
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1121
1121
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1122
1122
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1123
|
-
DAEMON_VERSION = "1.
|
|
1123
|
+
DAEMON_VERSION = "1.48.0"
|
|
1124
1124
|
|
|
1125
1125
|
|
|
1126
1126
|
def _detect_client_type() -> str:
|
|
@@ -1460,6 +1460,7 @@ class TaskInfo:
|
|
|
1460
1460
|
execution_attempt_id: str | None = None
|
|
1461
1461
|
runtime_evidence_plan: dict | None = None
|
|
1462
1462
|
local_evidence_upload: bool = False
|
|
1463
|
+
critic_review_required: bool = False
|
|
1463
1464
|
|
|
1464
1465
|
|
|
1465
1466
|
@dataclass
|
|
@@ -1490,6 +1491,105 @@ class TaskResult:
|
|
|
1490
1491
|
has_verified_completion: bool = False
|
|
1491
1492
|
execution_attempt_id: str | None = None
|
|
1492
1493
|
evidence_summary: dict | None = None
|
|
1494
|
+
critic_review: dict | None = None
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
_RUNTIME_CRITIC_TIMEOUT_SECONDS = 120
|
|
1498
|
+
_RUNTIME_CRITIC_DIFF_MAX_CHARS = 30_000
|
|
1499
|
+
_RUNTIME_CRITIC_OUTPUT_MAX_CHARS = 20_000
|
|
1500
|
+
_RUNTIME_CRITIC_SYSTEM_PROMPT = """You are the Critic Agent for a software delivery gate.
|
|
1501
|
+
Review only the supplied task context and code diff. You are running in an
|
|
1502
|
+
isolated review capsule, not the project workspace. Do not write or modify files,
|
|
1503
|
+
run mutating commands, create commits, or make network changes.
|
|
1504
|
+
|
|
1505
|
+
Return exactly one JSON object with this schema:
|
|
1506
|
+
{
|
|
1507
|
+
"verdict": "approve" | "request_changes" | "reject",
|
|
1508
|
+
"score": 0.0 to 1.0,
|
|
1509
|
+
"summary": "brief assessment",
|
|
1510
|
+
"dimension_scores": {
|
|
1511
|
+
"correctness": 0.0 to 1.0,
|
|
1512
|
+
"security": 0.0 to 1.0,
|
|
1513
|
+
"performance": 0.0 to 1.0,
|
|
1514
|
+
"style": 0.0 to 1.0,
|
|
1515
|
+
"testing": 0.0 to 1.0,
|
|
1516
|
+
"error_handling": 0.0 to 1.0
|
|
1517
|
+
},
|
|
1518
|
+
"issues": [{
|
|
1519
|
+
"severity": "critical" | "major" | "minor" | "info",
|
|
1520
|
+
"category": "security" | "performance" | "correctness" | "style" | "testing" | "error_handling",
|
|
1521
|
+
"title": "short title",
|
|
1522
|
+
"file": "repository-relative changed file path",
|
|
1523
|
+
"line": 1,
|
|
1524
|
+
"end_line": 1,
|
|
1525
|
+
"message": "actionable defect description",
|
|
1526
|
+
"evidence": "concrete basis from the supplied diff",
|
|
1527
|
+
"impact": "consequence if unresolved",
|
|
1528
|
+
"suggestion": "minimal fix direction",
|
|
1529
|
+
"verification": "focused verification step"
|
|
1530
|
+
}]
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
Only report findings grounded in the supplied diff. Return an empty issues array
|
|
1534
|
+
when no actionable defects are found."""
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def _runtime_critic_test_summary(observations: list[dict]) -> str:
|
|
1538
|
+
for observation in reversed(observations):
|
|
1539
|
+
if not isinstance(observation, dict):
|
|
1540
|
+
continue
|
|
1541
|
+
payload = observation.get("content")
|
|
1542
|
+
if not isinstance(payload, dict):
|
|
1543
|
+
payload = observation
|
|
1544
|
+
if payload.get("type") != "test_results":
|
|
1545
|
+
continue
|
|
1546
|
+
summary = payload.get("summary")
|
|
1547
|
+
if isinstance(summary, str) and summary.strip():
|
|
1548
|
+
return summary.strip()[:5000]
|
|
1549
|
+
return ""
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def _build_runtime_critic_prompt(
|
|
1553
|
+
task: TaskInfo,
|
|
1554
|
+
*,
|
|
1555
|
+
changed_files: list[str],
|
|
1556
|
+
diff: str,
|
|
1557
|
+
test_summary: str,
|
|
1558
|
+
) -> str:
|
|
1559
|
+
work_item = task.work_item or {}
|
|
1560
|
+
title = work_item.get("title") if isinstance(work_item, dict) else ""
|
|
1561
|
+
title = title if isinstance(title, str) and title.strip() else "Unnamed task"
|
|
1562
|
+
parts = [
|
|
1563
|
+
_RUNTIME_CRITIC_SYSTEM_PROMPT,
|
|
1564
|
+
"",
|
|
1565
|
+
f"Task: {title[:500]}",
|
|
1566
|
+
f"Task type: {task.node_type or 'coding'}",
|
|
1567
|
+
"",
|
|
1568
|
+
f"Changed files ({len(changed_files)}):",
|
|
1569
|
+
]
|
|
1570
|
+
parts.extend(f"- {path}" for path in changed_files[:50])
|
|
1571
|
+
parts.extend(("", "## Code Diff", "```diff", diff, "```"))
|
|
1572
|
+
if test_summary:
|
|
1573
|
+
parts.extend(("", "## Test Results", test_summary))
|
|
1574
|
+
parts.append("\nReturn only the JSON review object.")
|
|
1575
|
+
return "\n".join(parts)
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def _runtime_critic_attempt(agent_id: str, result: TaskResult) -> dict:
|
|
1579
|
+
if result.status == "success":
|
|
1580
|
+
outcome = "success"
|
|
1581
|
+
elif result.failure_code == "agent_idle_timeout":
|
|
1582
|
+
outcome = "timeout"
|
|
1583
|
+
elif result.exit_code not in (0, None):
|
|
1584
|
+
outcome = "exit_code"
|
|
1585
|
+
else:
|
|
1586
|
+
outcome = "agent_error"
|
|
1587
|
+
attempt = {"agent": agent_id, "outcome": outcome}
|
|
1588
|
+
duration_seconds = (result.metrics or {}).get("duration_seconds")
|
|
1589
|
+
if isinstance(duration_seconds, (int, float)) and not isinstance(duration_seconds, bool):
|
|
1590
|
+
if duration_seconds >= 0:
|
|
1591
|
+
attempt["duration_ms"] = int(duration_seconds * 1000)
|
|
1592
|
+
return attempt
|
|
1493
1593
|
|
|
1494
1594
|
|
|
1495
1595
|
_ACTIONABLE_REFLECTION_KINDS = frozenset({
|
|
@@ -1561,6 +1661,23 @@ def _filter_local_bind_task_result(result: "TaskResult") -> None:
|
|
|
1561
1661
|
result.stdout = ""
|
|
1562
1662
|
result.stderr = ""
|
|
1563
1663
|
result.observations = []
|
|
1664
|
+
critic_review = result.critic_review
|
|
1665
|
+
if isinstance(critic_review, dict) and isinstance(critic_review.get("result"), dict):
|
|
1666
|
+
result.critic_review = {
|
|
1667
|
+
"collection_status": "completed",
|
|
1668
|
+
"result": critic_review["result"],
|
|
1669
|
+
}
|
|
1670
|
+
if isinstance(critic_review.get("agent_used"), str):
|
|
1671
|
+
result.critic_review["agent_used"] = critic_review["agent_used"]
|
|
1672
|
+
if isinstance(critic_review.get("agent_attempts"), list):
|
|
1673
|
+
result.critic_review["agent_attempts"] = critic_review["agent_attempts"]
|
|
1674
|
+
elif isinstance(critic_review, dict):
|
|
1675
|
+
result.critic_review = {
|
|
1676
|
+
"collection_status": "unavailable",
|
|
1677
|
+
"failure_code": str(
|
|
1678
|
+
critic_review.get("failure_code") or "runtime_critic_local_bind_private"
|
|
1679
|
+
)[:64],
|
|
1680
|
+
}
|
|
1564
1681
|
if result.error:
|
|
1565
1682
|
result.error = (
|
|
1566
1683
|
"Local task failed. Review this task in Forgexa Desktop on the "
|
|
@@ -5291,6 +5408,8 @@ class ProcessManager:
|
|
|
5291
5408
|
"""Classify a fallback-eligible failure without conflating its cause."""
|
|
5292
5409
|
if result.failure_code in {"all_agents_rate_limited", "all_agents_unavailable"}:
|
|
5293
5410
|
return None
|
|
5411
|
+
if result.failure_code == "agent_platform_unsupported":
|
|
5412
|
+
return "agent_unavailable"
|
|
5294
5413
|
error_text = ProcessManager._failure_pattern_channels(result).lower()
|
|
5295
5414
|
if any(p in error_text for p in ProcessManager.RATE_LIMIT_PATTERNS):
|
|
5296
5415
|
return "rate_limit"
|
|
@@ -5938,6 +6057,7 @@ class ProcessManager:
|
|
|
5938
6057
|
"design": 50,
|
|
5939
6058
|
"testing": 60,
|
|
5940
6059
|
"review": 40,
|
|
6060
|
+
"critic_review": 1,
|
|
5941
6061
|
"fix": 60,
|
|
5942
6062
|
}
|
|
5943
6063
|
max_turns = int(os.environ.get(
|
|
@@ -6188,7 +6308,21 @@ class ProcessManager:
|
|
|
6188
6308
|
auth_src = Path.home() / ".local" / "share" / "opencode" / "auth.json"
|
|
6189
6309
|
if auth_src.exists():
|
|
6190
6310
|
shutil.copy2(auth_src, isolated_data_dir / "auth.json")
|
|
6191
|
-
|
|
6311
|
+
try:
|
|
6312
|
+
env = agent_core.opencode_base_env(
|
|
6313
|
+
os.environ.copy(),
|
|
6314
|
+
data_home=tmp_data_root,
|
|
6315
|
+
require_windows_shell=True,
|
|
6316
|
+
)
|
|
6317
|
+
except RuntimeError as exc:
|
|
6318
|
+
return TaskResult(
|
|
6319
|
+
status="failed",
|
|
6320
|
+
exit_code=-1,
|
|
6321
|
+
stdout="",
|
|
6322
|
+
stderr="",
|
|
6323
|
+
error=str(exc),
|
|
6324
|
+
failure_code="agent_platform_unsupported",
|
|
6325
|
+
)
|
|
6192
6326
|
result = await self._run_cli(cmd, cwd, timeout, task_id, on_chunk=on_chunk, env=env)
|
|
6193
6327
|
output = "\n".join(
|
|
6194
6328
|
part for part in (result.stdout, result.stderr, result.error) if part
|
|
@@ -7314,6 +7448,7 @@ class ProgressReporter:
|
|
|
7314
7448
|
"artifacts": result.artifacts,
|
|
7315
7449
|
"observations": result.observations,
|
|
7316
7450
|
"evidence_summary": result.evidence_summary,
|
|
7451
|
+
"critic_review": result.critic_review,
|
|
7317
7452
|
"metrics": result.metrics,
|
|
7318
7453
|
"git": result.git,
|
|
7319
7454
|
}
|
|
@@ -7924,6 +8059,7 @@ class TaskPoller:
|
|
|
7924
8059
|
if isinstance(t.get("runtime_evidence_plan"), dict) else None
|
|
7925
8060
|
),
|
|
7926
8061
|
local_evidence_upload=t.get("local_evidence_upload") is True,
|
|
8062
|
+
critic_review_required=t.get("critic_review_required") is True,
|
|
7927
8063
|
))
|
|
7928
8064
|
return tasks
|
|
7929
8065
|
except Exception as e:
|
|
@@ -10703,6 +10839,16 @@ class RuntimeDaemon:
|
|
|
10703
10839
|
before_sha=node_before_sha,
|
|
10704
10840
|
)
|
|
10705
10841
|
|
|
10842
|
+
if result.status == "success" and task.critic_review_required:
|
|
10843
|
+
await reporter.report_progress(task.task_id, 95, "running_critic_review")
|
|
10844
|
+
result.critic_review = await self._run_runtime_critic_review(
|
|
10845
|
+
task,
|
|
10846
|
+
agent,
|
|
10847
|
+
workspace_path,
|
|
10848
|
+
result,
|
|
10849
|
+
before_sha=node_before_sha,
|
|
10850
|
+
)
|
|
10851
|
+
|
|
10706
10852
|
# 6. Report completion (include actual agent used if different from requested)
|
|
10707
10853
|
result.metrics["actual_agent"] = agent.agent_id
|
|
10708
10854
|
if agent.agent_id != task.agent_type:
|
|
@@ -10731,6 +10877,187 @@ class RuntimeDaemon:
|
|
|
10731
10877
|
project_lock.release()
|
|
10732
10878
|
_release_local_workspace_file_lock(local_bind_file_lock)
|
|
10733
10879
|
|
|
10880
|
+
async def _collect_runtime_critic_diff(
|
|
10881
|
+
self,
|
|
10882
|
+
workspace_path: Path,
|
|
10883
|
+
*,
|
|
10884
|
+
before_sha: str,
|
|
10885
|
+
after_sha: str,
|
|
10886
|
+
changed_files: list[str],
|
|
10887
|
+
) -> str | None:
|
|
10888
|
+
if not before_sha or not after_sha or not changed_files:
|
|
10889
|
+
return None
|
|
10890
|
+
try:
|
|
10891
|
+
diff = await self.workspace_manager._git(
|
|
10892
|
+
"diff",
|
|
10893
|
+
"--no-ext-diff",
|
|
10894
|
+
"--unified=3",
|
|
10895
|
+
before_sha,
|
|
10896
|
+
after_sha,
|
|
10897
|
+
"--",
|
|
10898
|
+
*changed_files,
|
|
10899
|
+
cwd=workspace_path,
|
|
10900
|
+
timeout=60,
|
|
10901
|
+
)
|
|
10902
|
+
except Exception:
|
|
10903
|
+
logger.warning(
|
|
10904
|
+
"Could not collect frozen diff for Runtime Critic review of commit %s",
|
|
10905
|
+
after_sha[:12],
|
|
10906
|
+
exc_info=True,
|
|
10907
|
+
)
|
|
10908
|
+
return None
|
|
10909
|
+
return diff[:_RUNTIME_CRITIC_DIFF_MAX_CHARS]
|
|
10910
|
+
|
|
10911
|
+
async def _run_runtime_critic_review(
|
|
10912
|
+
self,
|
|
10913
|
+
task: TaskInfo,
|
|
10914
|
+
agent: DiscoveredAgent,
|
|
10915
|
+
workspace_path: Path,
|
|
10916
|
+
result: TaskResult,
|
|
10917
|
+
*,
|
|
10918
|
+
before_sha: str,
|
|
10919
|
+
) -> dict:
|
|
10920
|
+
"""Run a one-turn Critic on the selected Runtime Agent in a temp capsule.
|
|
10921
|
+
|
|
10922
|
+
The agent receives only the frozen diff and metadata in its prompt. Its
|
|
10923
|
+
cwd is an empty temporary directory, never the task workspace.
|
|
10924
|
+
"""
|
|
10925
|
+
agent_id = str(getattr(agent, "agent_id", "")).strip().lower()
|
|
10926
|
+
if not agent_id:
|
|
10927
|
+
return {
|
|
10928
|
+
"collection_status": "unavailable",
|
|
10929
|
+
"failure_code": "runtime_critic_agent_unknown",
|
|
10930
|
+
}
|
|
10931
|
+
metadata = {"agent_used": agent_id}
|
|
10932
|
+
is_local_bind = task.workspace_resolution == "local_bind"
|
|
10933
|
+
if is_local_bind and not task.local_evidence_upload:
|
|
10934
|
+
return {
|
|
10935
|
+
"collection_status": "unavailable",
|
|
10936
|
+
"failure_code": "runtime_critic_local_evidence_upload_disabled",
|
|
10937
|
+
**metadata,
|
|
10938
|
+
}
|
|
10939
|
+
|
|
10940
|
+
changed_files = [
|
|
10941
|
+
path.replace("\\", "/").lstrip("./")
|
|
10942
|
+
for path in result.files_changed
|
|
10943
|
+
if isinstance(path, str) and path.strip()
|
|
10944
|
+
]
|
|
10945
|
+
if not changed_files:
|
|
10946
|
+
return {"collection_status": "not_applicable"}
|
|
10947
|
+
|
|
10948
|
+
after_sha = str((result.git or {}).get("commit_sha") or "").strip()
|
|
10949
|
+
diff = await self._collect_runtime_critic_diff(
|
|
10950
|
+
workspace_path,
|
|
10951
|
+
before_sha=before_sha,
|
|
10952
|
+
after_sha=after_sha,
|
|
10953
|
+
changed_files=changed_files,
|
|
10954
|
+
)
|
|
10955
|
+
if not diff:
|
|
10956
|
+
return {
|
|
10957
|
+
"collection_status": "unavailable",
|
|
10958
|
+
"failure_code": "runtime_critic_diff_unavailable",
|
|
10959
|
+
**metadata,
|
|
10960
|
+
}
|
|
10961
|
+
|
|
10962
|
+
review_task = TaskInfo(
|
|
10963
|
+
task_id=f"{task.task_id}-critic",
|
|
10964
|
+
graph_id=task.graph_id,
|
|
10965
|
+
node_type="critic_review",
|
|
10966
|
+
agent_type=agent_id,
|
|
10967
|
+
input_prompt=_build_runtime_critic_prompt(
|
|
10968
|
+
task,
|
|
10969
|
+
changed_files=changed_files,
|
|
10970
|
+
diff=diff,
|
|
10971
|
+
test_summary=_runtime_critic_test_summary(result.observations),
|
|
10972
|
+
),
|
|
10973
|
+
input_data={"critic_review": True},
|
|
10974
|
+
timeout_seconds=_RUNTIME_CRITIC_TIMEOUT_SECONDS,
|
|
10975
|
+
max_retries=0,
|
|
10976
|
+
retry_count=0,
|
|
10977
|
+
project={},
|
|
10978
|
+
work_item={},
|
|
10979
|
+
)
|
|
10980
|
+
try:
|
|
10981
|
+
with tempfile.TemporaryDirectory(
|
|
10982
|
+
prefix=f"forgexa-critic-{task.task_id[:8]}-"
|
|
10983
|
+
) as review_dir:
|
|
10984
|
+
review_result = await self.process_manager.run_agent(
|
|
10985
|
+
agent,
|
|
10986
|
+
review_task,
|
|
10987
|
+
Path(review_dir),
|
|
10988
|
+
)
|
|
10989
|
+
except asyncio.CancelledError:
|
|
10990
|
+
raise
|
|
10991
|
+
except Exception:
|
|
10992
|
+
logger.warning(
|
|
10993
|
+
"Runtime Critic execution failed for task %s",
|
|
10994
|
+
task.task_id,
|
|
10995
|
+
exc_info=True,
|
|
10996
|
+
)
|
|
10997
|
+
return {
|
|
10998
|
+
"collection_status": "unavailable",
|
|
10999
|
+
"failure_code": "runtime_critic_exception",
|
|
11000
|
+
"agent_attempts": [{"agent": agent_id, "outcome": "agent_error"}],
|
|
11001
|
+
**metadata,
|
|
11002
|
+
}
|
|
11003
|
+
|
|
11004
|
+
attempt = _runtime_critic_attempt(agent_id, review_result)
|
|
11005
|
+
if review_result.status != "success":
|
|
11006
|
+
return {
|
|
11007
|
+
"collection_status": "unavailable",
|
|
11008
|
+
"failure_code": review_result.failure_code or "runtime_critic_agent_error",
|
|
11009
|
+
"agent_attempts": [attempt],
|
|
11010
|
+
**metadata,
|
|
11011
|
+
}
|
|
11012
|
+
|
|
11013
|
+
review_output = _daemon_extract_agent_output(
|
|
11014
|
+
agent_id,
|
|
11015
|
+
review_result.stdout or "",
|
|
11016
|
+
).strip()
|
|
11017
|
+
if not review_output:
|
|
11018
|
+
attempt["outcome"] = "empty_output"
|
|
11019
|
+
return {
|
|
11020
|
+
"collection_status": "unavailable",
|
|
11021
|
+
"failure_code": "runtime_critic_empty_output",
|
|
11022
|
+
"agent_attempts": [attempt],
|
|
11023
|
+
**metadata,
|
|
11024
|
+
}
|
|
11025
|
+
if is_local_bind:
|
|
11026
|
+
structured_result = _daemon_extract_json(review_output)
|
|
11027
|
+
if not isinstance(structured_result, dict):
|
|
11028
|
+
attempt["outcome"] = "invalid_output"
|
|
11029
|
+
return {
|
|
11030
|
+
"collection_status": "unavailable",
|
|
11031
|
+
"failure_code": "runtime_critic_invalid_output",
|
|
11032
|
+
"agent_attempts": [attempt],
|
|
11033
|
+
**metadata,
|
|
11034
|
+
}
|
|
11035
|
+
encoded_result = json.dumps(
|
|
11036
|
+
structured_result,
|
|
11037
|
+
ensure_ascii=True,
|
|
11038
|
+
separators=(",", ":"),
|
|
11039
|
+
)
|
|
11040
|
+
if len(encoded_result) > _RUNTIME_CRITIC_OUTPUT_MAX_CHARS:
|
|
11041
|
+
attempt["outcome"] = "output_too_large"
|
|
11042
|
+
return {
|
|
11043
|
+
"collection_status": "unavailable",
|
|
11044
|
+
"failure_code": "runtime_critic_output_too_large",
|
|
11045
|
+
"agent_attempts": [attempt],
|
|
11046
|
+
**metadata,
|
|
11047
|
+
}
|
|
11048
|
+
return {
|
|
11049
|
+
"collection_status": "completed",
|
|
11050
|
+
"result": structured_result,
|
|
11051
|
+
"agent_attempts": [attempt],
|
|
11052
|
+
**metadata,
|
|
11053
|
+
}
|
|
11054
|
+
return {
|
|
11055
|
+
"collection_status": "completed",
|
|
11056
|
+
"output": review_output[:_RUNTIME_CRITIC_OUTPUT_MAX_CHARS],
|
|
11057
|
+
"agent_attempts": [attempt],
|
|
11058
|
+
**metadata,
|
|
11059
|
+
}
|
|
11060
|
+
|
|
10734
11061
|
def _select_fallback_agent(
|
|
10735
11062
|
self, current_agent_id: str, fallback_chain: list[str] | None, tried: set[str]
|
|
10736
11063
|
) -> DiscoveredAgent | None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|