forgexa-cli 1.7.6__tar.gz → 1.8.3__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.7.6 → forgexa_cli-1.8.3}/PKG-INFO +1 -1
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli/daemon.py +318 -44
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/pyproject.toml +1 -1
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/README.md +0 -0
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.7.6 → forgexa_cli-1.8.3}/setup.cfg +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.8.3"
|
|
@@ -332,7 +332,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
332
332
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
333
333
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
334
334
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
335
|
-
DAEMON_VERSION = "1.
|
|
335
|
+
DAEMON_VERSION = "1.8.3"
|
|
336
336
|
|
|
337
337
|
|
|
338
338
|
def _detect_client_type() -> str:
|
|
@@ -611,7 +611,7 @@ class AgentDiscovery:
|
|
|
611
611
|
"""Scans for locally installed Agent CLI tools."""
|
|
612
612
|
|
|
613
613
|
AGENT_REGISTRY = {
|
|
614
|
-
"claude
|
|
614
|
+
"claude": {
|
|
615
615
|
"commands": ["claude"],
|
|
616
616
|
"detect": "claude --version",
|
|
617
617
|
"invoke_modes": ["print", "app-server"],
|
|
@@ -642,19 +642,19 @@ class AgentDiscovery:
|
|
|
642
642
|
"env_path_override": "FACTORY_GEMINI_PATH",
|
|
643
643
|
"compatibility_level": "L1",
|
|
644
644
|
},
|
|
645
|
-
"kimi
|
|
645
|
+
"kimi": {
|
|
646
646
|
"commands": ["kimi"],
|
|
647
647
|
"detect": "kimi --version",
|
|
648
648
|
"invoke_modes": ["cli"],
|
|
649
649
|
"env_path_override": "FACTORY_KIMI_PATH",
|
|
650
650
|
"compatibility_level": "L2",
|
|
651
651
|
},
|
|
652
|
-
"
|
|
653
|
-
"commands": ["
|
|
654
|
-
"detect": "
|
|
652
|
+
"copilot": {
|
|
653
|
+
"commands": ["copilot"],
|
|
654
|
+
"detect": "copilot --version",
|
|
655
655
|
"invoke_modes": ["cli"],
|
|
656
|
-
"env_path_override": "
|
|
657
|
-
"compatibility_level": "
|
|
656
|
+
"env_path_override": "FACTORY_COPILOT_PATH",
|
|
657
|
+
"compatibility_level": "L3",
|
|
658
658
|
},
|
|
659
659
|
}
|
|
660
660
|
|
|
@@ -711,6 +711,24 @@ class AgentDiscovery:
|
|
|
711
711
|
Path("/usr/local/bin"),
|
|
712
712
|
Path("/opt/homebrew/bin"),
|
|
713
713
|
]
|
|
714
|
+
# GitHub Copilot CLI — installed via VS Code extension into globalStorage
|
|
715
|
+
vscode_copilot = (
|
|
716
|
+
home / "Library" / "Application Support" / "Code" / "User"
|
|
717
|
+
/ "globalStorage" / "github.copilot-chat" / "copilotCli"
|
|
718
|
+
)
|
|
719
|
+
extra_dirs.append(vscode_copilot)
|
|
720
|
+
for vs_variant in ("Code - Insiders", "VSCodium"):
|
|
721
|
+
extra_dirs.append(
|
|
722
|
+
home / "Library" / "Application Support" / vs_variant / "User"
|
|
723
|
+
/ "globalStorage" / "github.copilot-chat" / "copilotCli"
|
|
724
|
+
)
|
|
725
|
+
if sys.platform == "linux":
|
|
726
|
+
# GitHub Copilot CLI on Linux VSCode
|
|
727
|
+
for config_dir in (
|
|
728
|
+
home / ".config" / "Code" / "User" / "globalStorage" / "github.copilot-chat" / "copilotCli",
|
|
729
|
+
home / ".config" / "Code - Insiders" / "User" / "globalStorage" / "github.copilot-chat" / "copilotCli",
|
|
730
|
+
):
|
|
731
|
+
extra_dirs.append(config_dir)
|
|
714
732
|
# nvm (macOS + Linux)
|
|
715
733
|
nvm_dir = os.environ.get("NVM_DIR", str(home / ".nvm"))
|
|
716
734
|
nvm_path = Path(nvm_dir)
|
|
@@ -1732,24 +1750,49 @@ class ProcessManager:
|
|
|
1732
1750
|
error_messages.append(err.get("message", "turn failed"))
|
|
1733
1751
|
elif isinstance(err, str):
|
|
1734
1752
|
error_messages.append(err)
|
|
1753
|
+
# ── GitHub Copilot CLI event types ──────────────────────────────
|
|
1754
|
+
elif ev_type == "assistant.turn_end":
|
|
1755
|
+
has_turn_completed = True
|
|
1756
|
+
elif ev_type == "assistant.turn_start":
|
|
1757
|
+
has_assistant_events = True
|
|
1758
|
+
elif ev_type == "assistant.message":
|
|
1759
|
+
has_meaningful_content = True
|
|
1760
|
+
has_assistant_events = True
|
|
1761
|
+
msg_data = data.get("data") or {}
|
|
1762
|
+
if isinstance(msg_data.get("content"), str) and msg_data["content"].strip():
|
|
1763
|
+
has_result = True
|
|
1764
|
+
elif ev_type == "assistant.message_delta":
|
|
1765
|
+
has_meaningful_content = True
|
|
1766
|
+
# ── Generic / Claude "result" event ────────────────────────────
|
|
1735
1767
|
elif ev_type == "result":
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
# events appeared, the CLI never made an API call. The result
|
|
1743
|
-
# text is a CLI-level error (e.g. "API Error: Connection error.")
|
|
1744
|
-
# rather than the agent's actual work output.
|
|
1745
|
-
tok_in = int(data.get("total_input_tokens", 0) or 0)
|
|
1746
|
-
tok_out = int(data.get("total_output_tokens", 0) or 0)
|
|
1747
|
-
no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
|
|
1748
|
-
if no_api_call and result_text:
|
|
1749
|
-
error_messages.append(result_text)
|
|
1750
|
-
else:
|
|
1768
|
+
# Copilot result format: {"type":"result","exitCode":0,"usage":{...}}
|
|
1769
|
+
# Claude result format: {"type":"result","result":"...","is_error":false,...}
|
|
1770
|
+
if "exitCode" in data:
|
|
1771
|
+
# Copilot JSONL result
|
|
1772
|
+
exit_code = int(data.get("exitCode") or 0)
|
|
1773
|
+
if exit_code == 0:
|
|
1751
1774
|
has_result = True
|
|
1752
1775
|
has_meaningful_content = True
|
|
1776
|
+
else:
|
|
1777
|
+
error_messages.append(f"Copilot exited with code {exit_code}")
|
|
1778
|
+
else:
|
|
1779
|
+
result_text = str(data.get("result", "") or "")
|
|
1780
|
+
if data.get("is_error"):
|
|
1781
|
+
err_text = result_text or str(data.get("error", "") or "result marked as error")
|
|
1782
|
+
error_messages.append(err_text)
|
|
1783
|
+
else:
|
|
1784
|
+
# Structural check: if no tokens were consumed AND no assistant
|
|
1785
|
+
# events appeared, the CLI never made an API call. The result
|
|
1786
|
+
# text is a CLI-level error (e.g. "API Error: Connection error.")
|
|
1787
|
+
# rather than the agent's actual work output.
|
|
1788
|
+
tok_in = int(data.get("total_input_tokens", 0) or 0)
|
|
1789
|
+
tok_out = int(data.get("total_output_tokens", 0) or 0)
|
|
1790
|
+
no_api_call = (tok_in + tok_out == 0) and not has_assistant_events
|
|
1791
|
+
if no_api_call and result_text:
|
|
1792
|
+
error_messages.append(result_text)
|
|
1793
|
+
else:
|
|
1794
|
+
has_result = True
|
|
1795
|
+
has_meaningful_content = True
|
|
1753
1796
|
elif ev_type == "error":
|
|
1754
1797
|
msg = data.get("message", "")
|
|
1755
1798
|
if msg:
|
|
@@ -1895,10 +1938,15 @@ class ProcessManager:
|
|
|
1895
1938
|
return f"Agent encountered errors without producing output: {error_messages[0]}"
|
|
1896
1939
|
|
|
1897
1940
|
# ── Claude: JSON output mode but no result object and no content ──
|
|
1898
|
-
if agent_id == "claude
|
|
1941
|
+
if agent_id == "claude" and json_line_count > 0:
|
|
1899
1942
|
if not has_result and not has_meaningful_content:
|
|
1900
1943
|
return "Claude produced no result output"
|
|
1901
1944
|
|
|
1945
|
+
# ── Copilot: JSONL mode but no turn completion and no content ──
|
|
1946
|
+
if agent_id == "copilot" and json_line_count > 0:
|
|
1947
|
+
if not has_result and not has_meaningful_content:
|
|
1948
|
+
return "Copilot produced no result output (check GitHub authentication: run 'gh auth login')"
|
|
1949
|
+
|
|
1902
1950
|
return None
|
|
1903
1951
|
|
|
1904
1952
|
async def run_agent(
|
|
@@ -1915,7 +1963,7 @@ class ProcessManager:
|
|
|
1915
1963
|
|
|
1916
1964
|
start_time = time.monotonic()
|
|
1917
1965
|
|
|
1918
|
-
if agent.agent_id == "claude
|
|
1966
|
+
if agent.agent_id == "claude":
|
|
1919
1967
|
result = await self._run_claude(
|
|
1920
1968
|
agent, prompt, workspace_path, timeout, task.task_id, on_chunk,
|
|
1921
1969
|
node_type=task.node_type,
|
|
@@ -1926,8 +1974,10 @@ class ProcessManager:
|
|
|
1926
1974
|
result = await self._run_opencode(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1927
1975
|
elif agent.agent_id == "gemini":
|
|
1928
1976
|
result = await self._run_gemini(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1929
|
-
elif agent.agent_id == "kimi
|
|
1977
|
+
elif agent.agent_id == "kimi":
|
|
1930
1978
|
result = await self._run_kimi_code(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1979
|
+
elif agent.agent_id == "copilot":
|
|
1980
|
+
result = await self._run_copilot(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1931
1981
|
else:
|
|
1932
1982
|
result = await self._run_generic(agent, prompt, workspace_path, timeout, task.task_id, on_chunk)
|
|
1933
1983
|
|
|
@@ -2386,6 +2436,101 @@ class ProcessManager:
|
|
|
2386
2436
|
result.metrics.update(parsed_metrics)
|
|
2387
2437
|
return result
|
|
2388
2438
|
|
|
2439
|
+
async def _run_copilot(
|
|
2440
|
+
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
2441
|
+
on_chunk: Any = None,
|
|
2442
|
+
) -> TaskResult:
|
|
2443
|
+
"""Run GitHub Copilot CLI in non-interactive JSON-streaming mode.
|
|
2444
|
+
|
|
2445
|
+
Uses TERM=dumb to suppress TTY-detection (copilot suspends when it
|
|
2446
|
+
can't acquire a pseudo-terminal). Requires GitHub login:
|
|
2447
|
+
``gh auth login`` or GITHUB_TOKEN env var must be set.
|
|
2448
|
+
|
|
2449
|
+
Flags:
|
|
2450
|
+
-p / --prompt Non-interactive prompt (exits after completion).
|
|
2451
|
+
--output-format json JSONL stream of session events.
|
|
2452
|
+
--allow-all Grant all tool + path + URL permissions required
|
|
2453
|
+
for autonomous file-editing tasks.
|
|
2454
|
+
-C <dir> Change working directory before execution.
|
|
2455
|
+
"""
|
|
2456
|
+
env = os.environ.copy()
|
|
2457
|
+
env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
|
|
2458
|
+
|
|
2459
|
+
model_override = os.environ.get("FACTORY_COPILOT_MODEL")
|
|
2460
|
+
reasoning = os.environ.get("FACTORY_COPILOT_REASONING", "medium")
|
|
2461
|
+
|
|
2462
|
+
cmd = [
|
|
2463
|
+
agent.command,
|
|
2464
|
+
"--output-format", "json",
|
|
2465
|
+
"--allow-all",
|
|
2466
|
+
"--effort", reasoning,
|
|
2467
|
+
"-C", str(cwd),
|
|
2468
|
+
"-p", prompt,
|
|
2469
|
+
]
|
|
2470
|
+
if model_override:
|
|
2471
|
+
cmd = [agent.command, "--model", model_override] + cmd[1:]
|
|
2472
|
+
|
|
2473
|
+
try:
|
|
2474
|
+
proc = await asyncio.create_subprocess_exec(
|
|
2475
|
+
*cmd,
|
|
2476
|
+
stdout=asyncio.subprocess.PIPE,
|
|
2477
|
+
stderr=asyncio.subprocess.PIPE,
|
|
2478
|
+
cwd=str(cwd),
|
|
2479
|
+
env=env,
|
|
2480
|
+
limit=100 * 1024 * 1024,
|
|
2481
|
+
start_new_session=True,
|
|
2482
|
+
)
|
|
2483
|
+
self.active_processes[task_id] = proc
|
|
2484
|
+
stdout, stderr, returncode = await self._stream_process(
|
|
2485
|
+
proc, None, timeout, task_id, on_chunk
|
|
2486
|
+
)
|
|
2487
|
+
|
|
2488
|
+
# Parse copilot JSONL output for metrics
|
|
2489
|
+
metrics = self._parse_copilot_output(stdout)
|
|
2490
|
+
|
|
2491
|
+
# Copilot always exits 0 on normal completion; check result.exitCode
|
|
2492
|
+
# from the JSONL "result" event for a true success signal.
|
|
2493
|
+
copilot_exit = self._extract_copilot_exit_code(stdout)
|
|
2494
|
+
effective_rc = copilot_exit if copilot_exit is not None else returncode
|
|
2495
|
+
|
|
2496
|
+
if effective_rc == 0 and returncode == 0:
|
|
2497
|
+
return TaskResult(
|
|
2498
|
+
status="success",
|
|
2499
|
+
exit_code=0,
|
|
2500
|
+
stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
2501
|
+
stderr=stderr[-10000:],
|
|
2502
|
+
metrics=metrics,
|
|
2503
|
+
)
|
|
2504
|
+
else:
|
|
2505
|
+
return TaskResult(
|
|
2506
|
+
status="failed",
|
|
2507
|
+
exit_code=effective_rc,
|
|
2508
|
+
stdout=stdout[-settings.AGENT_MAX_OUTPUT_SIZE:],
|
|
2509
|
+
stderr=stderr[-10000:],
|
|
2510
|
+
error=f"Copilot exited with code {effective_rc}: {stderr[-500:]}",
|
|
2511
|
+
metrics=metrics,
|
|
2512
|
+
)
|
|
2513
|
+
except asyncio.TimeoutError:
|
|
2514
|
+
if task_id in self.active_processes:
|
|
2515
|
+
self.active_processes[task_id].kill()
|
|
2516
|
+
return TaskResult(
|
|
2517
|
+
status="failed", exit_code=-1, stdout="", stderr="",
|
|
2518
|
+
error=f"Timed out after {timeout}s",
|
|
2519
|
+
)
|
|
2520
|
+
except Exception as exc:
|
|
2521
|
+
logger.exception("Copilot stream error for task %s", task_id)
|
|
2522
|
+
if task_id in self.active_processes:
|
|
2523
|
+
try:
|
|
2524
|
+
self.active_processes[task_id].kill()
|
|
2525
|
+
except Exception:
|
|
2526
|
+
pass
|
|
2527
|
+
return TaskResult(
|
|
2528
|
+
status="failed", exit_code=-1, stdout="", stderr="",
|
|
2529
|
+
error=f"Stream processing error: {exc}",
|
|
2530
|
+
)
|
|
2531
|
+
finally:
|
|
2532
|
+
self.active_processes.pop(task_id, None)
|
|
2533
|
+
|
|
2389
2534
|
async def _run_generic(
|
|
2390
2535
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
2391
2536
|
on_chunk: Any = None,
|
|
@@ -2702,6 +2847,79 @@ class ProcessManager:
|
|
|
2702
2847
|
metrics["token_output"] = output
|
|
2703
2848
|
return metrics
|
|
2704
2849
|
|
|
2850
|
+
def _parse_copilot_output(self, stdout: str) -> dict:
|
|
2851
|
+
"""Extract metrics from GitHub Copilot CLI JSONL output.
|
|
2852
|
+
|
|
2853
|
+
Copilot CLI (--output-format json) emits one JSON object per line.
|
|
2854
|
+
The key event types:
|
|
2855
|
+
- ``assistant.message`` -> content + model + outputTokens per turn
|
|
2856
|
+
- ``result`` -> exitCode + usage (premiumRequests,
|
|
2857
|
+
totalApiDurationMs, codeChanges)
|
|
2858
|
+
|
|
2859
|
+
Copilot is subscription-based and does NOT report USD cost or input
|
|
2860
|
+
tokens, so those fields are intentionally omitted.
|
|
2861
|
+
"""
|
|
2862
|
+
metrics: dict[str, Any] = {}
|
|
2863
|
+
total_output_tokens = 0
|
|
2864
|
+
model_seen: str | None = None
|
|
2865
|
+
|
|
2866
|
+
for raw in stdout.strip().split("\n"):
|
|
2867
|
+
raw = raw.strip()
|
|
2868
|
+
if not raw:
|
|
2869
|
+
continue
|
|
2870
|
+
try:
|
|
2871
|
+
data = json.loads(raw)
|
|
2872
|
+
except json.JSONDecodeError:
|
|
2873
|
+
continue
|
|
2874
|
+
if not isinstance(data, dict):
|
|
2875
|
+
continue
|
|
2876
|
+
|
|
2877
|
+
ev_type = str(data.get("type", ""))
|
|
2878
|
+
|
|
2879
|
+
if ev_type == "assistant.message":
|
|
2880
|
+
msg_data = data.get("data") or {}
|
|
2881
|
+
out_tokens = msg_data.get("outputTokens")
|
|
2882
|
+
if out_tokens:
|
|
2883
|
+
total_output_tokens += int(out_tokens)
|
|
2884
|
+
if not model_seen and isinstance(msg_data.get("model"), str):
|
|
2885
|
+
model_seen = msg_data["model"]
|
|
2886
|
+
|
|
2887
|
+
elif ev_type == "result":
|
|
2888
|
+
usage = data.get("usage") or {}
|
|
2889
|
+
premium_reqs = usage.get("premiumRequests")
|
|
2890
|
+
if premium_reqs is not None:
|
|
2891
|
+
metrics["premium_requests"] = int(premium_reqs)
|
|
2892
|
+
api_ms = usage.get("totalApiDurationMs")
|
|
2893
|
+
if api_ms:
|
|
2894
|
+
metrics["api_duration_ms"] = int(api_ms)
|
|
2895
|
+
changes = usage.get("codeChanges") or {}
|
|
2896
|
+
if changes.get("linesAdded") or changes.get("linesRemoved"):
|
|
2897
|
+
metrics["lines_added"] = int(changes.get("linesAdded") or 0)
|
|
2898
|
+
metrics["lines_removed"] = int(changes.get("linesRemoved") or 0)
|
|
2899
|
+
|
|
2900
|
+
if total_output_tokens:
|
|
2901
|
+
metrics["token_output"] = total_output_tokens
|
|
2902
|
+
if model_seen:
|
|
2903
|
+
metrics["model"] = model_seen
|
|
2904
|
+
return metrics
|
|
2905
|
+
|
|
2906
|
+
@staticmethod
|
|
2907
|
+
def _extract_copilot_exit_code(stdout: str) -> int | None:
|
|
2908
|
+
"""Extract the exitCode from Copilot JSONL ``result`` event."""
|
|
2909
|
+
for raw in reversed(stdout.strip().split("\n")):
|
|
2910
|
+
raw = raw.strip()
|
|
2911
|
+
if not raw:
|
|
2912
|
+
continue
|
|
2913
|
+
try:
|
|
2914
|
+
data = json.loads(raw)
|
|
2915
|
+
except json.JSONDecodeError:
|
|
2916
|
+
continue
|
|
2917
|
+
if isinstance(data, dict) and data.get("type") == "result":
|
|
2918
|
+
ec = data.get("exitCode")
|
|
2919
|
+
if ec is not None:
|
|
2920
|
+
return int(ec)
|
|
2921
|
+
return None
|
|
2922
|
+
|
|
2705
2923
|
async def _collect_git_info(self, cwd: Path) -> dict:
|
|
2706
2924
|
"""Collect git diff stats from workspace."""
|
|
2707
2925
|
info: dict[str, Any] = {}
|
|
@@ -2779,7 +2997,7 @@ class ProcessManager:
|
|
|
2779
2997
|
"""Collect git diff stats comparing HEAD vs merge-base with default branch.
|
|
2780
2998
|
|
|
2781
2999
|
Uses merge-base to capture ALL changes on the feature branch, not just
|
|
2782
|
-
the last commit (agents like claude
|
|
3000
|
+
the last commit (agents like claude may create multiple commits).
|
|
2783
3001
|
Falls back to HEAD~1 if merge-base detection fails.
|
|
2784
3002
|
"""
|
|
2785
3003
|
info: dict[str, Any] = {}
|
|
@@ -3140,7 +3358,7 @@ class TaskPoller:
|
|
|
3140
3358
|
task_id=t["task_id"],
|
|
3141
3359
|
graph_id=t["graph_id"],
|
|
3142
3360
|
node_type=t["node_type"],
|
|
3143
|
-
agent_type=t.get("agent_type", "claude
|
|
3361
|
+
agent_type=t.get("agent_type", "claude"),
|
|
3144
3362
|
input_prompt=t.get("input_prompt", ""),
|
|
3145
3363
|
input_data=t.get("input_data", {}),
|
|
3146
3364
|
timeout_seconds=t.get("timeout_seconds", settings.AGENT_TIMEOUT),
|
|
@@ -3787,10 +4005,22 @@ class RuntimeDaemon:
|
|
|
3787
4005
|
# 1. Find the right agent
|
|
3788
4006
|
agent = self._select_agent(task.agent_type, task.fallback_chain)
|
|
3789
4007
|
if not agent:
|
|
3790
|
-
|
|
4008
|
+
_INSTALL_HINTS = {
|
|
4009
|
+
"claude": "npm install -g @anthropic-ai/claude-code",
|
|
4010
|
+
"codex": "npm install -g @openai/codex",
|
|
4011
|
+
"opencode": "curl -sSL https://opencode.ai/install | sh",
|
|
4012
|
+
"gemini": "npm install -g @google/gemini-cli",
|
|
4013
|
+
"kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
|
|
4014
|
+
"copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
|
|
4015
|
+
}
|
|
4016
|
+
hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
|
|
4017
|
+
logger.error("No agent found for type '%s' on this runtime", task.agent_type)
|
|
3791
4018
|
await reporter.report_complete(task.task_id, TaskResult(
|
|
3792
4019
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
3793
|
-
error=
|
|
4020
|
+
error=(
|
|
4021
|
+
f"Agent '{task.agent_type}' is not available on this runtime. "
|
|
4022
|
+
f"Install it: {hint}"
|
|
4023
|
+
),
|
|
3794
4024
|
))
|
|
3795
4025
|
return
|
|
3796
4026
|
|
|
@@ -3818,15 +4048,22 @@ class RuntimeDaemon:
|
|
|
3818
4048
|
_line_buffer.extend(lines)
|
|
3819
4049
|
|
|
3820
4050
|
async def _progress_ticker():
|
|
3821
|
-
"""Flush buffered output lines + update progress % every
|
|
4051
|
+
"""Flush buffered output lines + update progress % every 1 s.
|
|
4052
|
+
|
|
4053
|
+
Using 1-second ticks keeps the UI responsive without flooding
|
|
4054
|
+
the backend. Empty ticks are skipped to reduce HTTP traffic.
|
|
4055
|
+
"""
|
|
3822
4056
|
import math as _math
|
|
3823
4057
|
tick = 0
|
|
3824
4058
|
while not progress_stop.is_set():
|
|
3825
|
-
await asyncio.sleep(
|
|
4059
|
+
await asyncio.sleep(1)
|
|
3826
4060
|
if progress_stop.is_set():
|
|
3827
4061
|
break
|
|
4062
|
+
if not _line_buffer and tick < 3:
|
|
4063
|
+
tick += 1
|
|
4064
|
+
continue
|
|
3828
4065
|
tick += 1
|
|
3829
|
-
pct = min(int(10 + 80 * (1 - 1 / (1 + tick /
|
|
4066
|
+
pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 80))), 90)
|
|
3830
4067
|
pid = self.process_manager.active_processes.get(task.task_id)
|
|
3831
4068
|
step = "running_agent"
|
|
3832
4069
|
if pid:
|
|
@@ -3912,11 +4149,14 @@ class RuntimeDaemon:
|
|
|
3912
4149
|
async def _progress_ticker2():
|
|
3913
4150
|
tick = 0
|
|
3914
4151
|
while not progress_stop2.is_set():
|
|
3915
|
-
await asyncio.sleep(
|
|
4152
|
+
await asyncio.sleep(1)
|
|
3916
4153
|
if progress_stop2.is_set():
|
|
3917
4154
|
break
|
|
4155
|
+
if not _line_buffer and tick < 3:
|
|
4156
|
+
tick += 1
|
|
4157
|
+
continue
|
|
3918
4158
|
tick += 1
|
|
3919
|
-
pct = min(int(10 + 80 * (1 - 1 / (1 + tick /
|
|
4159
|
+
pct = min(int(10 + 80 * (1 - 1 / (1 + tick / 80))), 90)
|
|
3920
4160
|
pid = self.process_manager.active_processes.get(task.task_id)
|
|
3921
4161
|
step = f"running_agent:{agent.agent_id}"
|
|
3922
4162
|
if pid:
|
|
@@ -4136,8 +4376,8 @@ class RuntimeDaemon:
|
|
|
4136
4376
|
if agent.agent_id == fallback_id:
|
|
4137
4377
|
return agent
|
|
4138
4378
|
|
|
4139
|
-
# 2. Try any available agent not yet tried (prefer opencode > gemini > claude
|
|
4140
|
-
preferred_order = ["opencode", "gemini", "claude
|
|
4379
|
+
# 2. Try any available agent not yet tried (prefer opencode > copilot > gemini > claude)
|
|
4380
|
+
preferred_order = ["opencode", "copilot", "gemini", "claude"]
|
|
4141
4381
|
for preferred_id in preferred_order:
|
|
4142
4382
|
if preferred_id in tried:
|
|
4143
4383
|
continue
|
|
@@ -4152,7 +4392,17 @@ class RuntimeDaemon:
|
|
|
4152
4392
|
return None
|
|
4153
4393
|
|
|
4154
4394
|
def _select_agent(self, agent_type: str, fallback_chain: list[str] | None = None) -> DiscoveredAgent | None:
|
|
4155
|
-
"""Find best matching agent for the requested type with fallback chain support.
|
|
4395
|
+
"""Find best matching agent for the requested type with fallback chain support.
|
|
4396
|
+
|
|
4397
|
+
Returns ``None`` when the requested agent is a known canonical ID but is not
|
|
4398
|
+
currently installed/discovered on this machine — callers should report a clear
|
|
4399
|
+
error rather than silently running a different agent.
|
|
4400
|
+
|
|
4401
|
+
The "any available agent" fallbacks (steps 3–4) only apply when ``agent_type``
|
|
4402
|
+
is NOT a recognized canonical ID (e.g. empty string, "auto", or an unknown
|
|
4403
|
+
custom identifier). This prevents silent substitution when the user explicitly
|
|
4404
|
+
selected an agent that is just not installed locally.
|
|
4405
|
+
"""
|
|
4156
4406
|
# 1. Exact match
|
|
4157
4407
|
for agent in self.agents:
|
|
4158
4408
|
if agent.agent_id == agent_type:
|
|
@@ -4168,11 +4418,18 @@ class RuntimeDaemon:
|
|
|
4168
4418
|
logger.info("Using fallback agent '%s' (requested '%s')", fallback_id, agent_type)
|
|
4169
4419
|
return agent
|
|
4170
4420
|
|
|
4171
|
-
# 3.
|
|
4421
|
+
# 3. If the requested agent_type is a KNOWN canonical ID (registered in
|
|
4422
|
+
# AGENT_REGISTRY) but not discovered locally, return None so the caller
|
|
4423
|
+
# can surface a clear "agent not installed" error instead of silently
|
|
4424
|
+
# using whatever L3 agent happens to be available.
|
|
4425
|
+
if agent_type in AgentDiscovery.AGENT_REGISTRY:
|
|
4426
|
+
return None
|
|
4427
|
+
|
|
4428
|
+
# 4. Fallback: first available L3 agent (only for unrecognized/generic types)
|
|
4172
4429
|
for agent in self.agents:
|
|
4173
4430
|
if agent.compatibility_level == "L3":
|
|
4174
4431
|
return agent
|
|
4175
|
-
#
|
|
4432
|
+
# 5. Any agent (last resort for unrecognized types)
|
|
4176
4433
|
return self.agents[0] if self.agents else None
|
|
4177
4434
|
|
|
4178
4435
|
# ── Layer 2: Validation Gate ──
|
|
@@ -4390,13 +4647,30 @@ class RuntimeDaemon:
|
|
|
4390
4647
|
timeout=10,
|
|
4391
4648
|
)
|
|
4392
4649
|
|
|
4393
|
-
# 1. Select agent
|
|
4394
|
-
|
|
4650
|
+
# 1. Select agent — normalize legacy aliases to canonical IDs
|
|
4651
|
+
_AGENT_ALIASES = {"claude": "claude", "kimi": "kimi"}
|
|
4652
|
+
agent_type = _AGENT_ALIASES.get(agent_override or "", agent_override or "claude")
|
|
4395
4653
|
agent = self._select_agent(agent_type, [])
|
|
4396
4654
|
if not agent:
|
|
4655
|
+
_INSTALL_HINTS = {
|
|
4656
|
+
"claude": "npm install -g @anthropic-ai/claude-code",
|
|
4657
|
+
"codex": "npm install -g @openai/codex",
|
|
4658
|
+
"opencode": "curl -sSL https://opencode.ai/install | sh",
|
|
4659
|
+
"gemini": "npm install -g @google/gemini-cli",
|
|
4660
|
+
"kimi": "curl -LsSf https://code.kimi.com/install.sh | bash",
|
|
4661
|
+
"copilot": "Install VS Code GitHub Copilot Chat extension, or run: gh copilot. Then: gh auth login",
|
|
4662
|
+
}
|
|
4663
|
+
hint = _INSTALL_HINTS.get(agent_type, f"install the '{agent_type}' CLI tool")
|
|
4397
4664
|
await conn.client.post(
|
|
4398
4665
|
f"{reporter_url}/complete",
|
|
4399
|
-
json={
|
|
4666
|
+
json={
|
|
4667
|
+
"status": "failed",
|
|
4668
|
+
"error": (
|
|
4669
|
+
f"Agent '{agent_type}' is not available on this runtime. "
|
|
4670
|
+
f"Install it: {hint}"
|
|
4671
|
+
),
|
|
4672
|
+
"failure_code": "no_agent",
|
|
4673
|
+
},
|
|
4400
4674
|
timeout=10,
|
|
4401
4675
|
)
|
|
4402
4676
|
return
|
|
@@ -4683,7 +4957,7 @@ class RuntimeDaemon:
|
|
|
4683
4957
|
async def _auto_commit(self, workspace_path: Path, task: TaskInfo) -> dict:
|
|
4684
4958
|
"""Auto-commit and push agent changes.
|
|
4685
4959
|
|
|
4686
|
-
Some agents (e.g. claude
|
|
4960
|
+
Some agents (e.g. claude) commit changes internally, so we must
|
|
4687
4961
|
also push even when the working directory is clean.
|
|
4688
4962
|
|
|
4689
4963
|
Before pushing, we rebase onto the latest ``origin/{default_branch}``
|
|
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
|