forgexa-cli 1.13.3__tar.gz → 1.13.5__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.13.3 → forgexa_cli-1.13.5}/PKG-INFO +1 -1
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli/daemon.py +328 -18
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli/main.py +70 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/pyproject.toml +1 -1
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/README.md +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/setup.cfg +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.5}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.13.
|
|
2
|
+
__version__ = "1.13.5"
|
|
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
523
523
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
524
524
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
525
525
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
526
|
-
DAEMON_VERSION = "1.13.
|
|
526
|
+
DAEMON_VERSION = "1.13.5"
|
|
527
527
|
|
|
528
528
|
|
|
529
529
|
def _detect_client_type() -> str:
|
|
@@ -794,28 +794,231 @@ def _prepare_ai_job_output(
|
|
|
794
794
|
return output_content, scenario_items
|
|
795
795
|
|
|
796
796
|
|
|
797
|
+
# ── Self-contained agent-output extraction ────────────────────────────────────
|
|
798
|
+
# These functions are deliberately inlined here (duplicated from
|
|
799
|
+
# app.services.ai_execution_strategy / qa_ai_assistant) so that the daemon
|
|
800
|
+
# works correctly when running as the CLI or desktop standalone daemon where
|
|
801
|
+
# the `app` package is NOT on sys.path. Any time the originals change,
|
|
802
|
+
# keep these in sync.
|
|
803
|
+
|
|
804
|
+
def _daemon_extract_copilot_output(raw: str) -> str:
|
|
805
|
+
"""Extract assistant text from Copilot CLI JSONL output."""
|
|
806
|
+
final_answer_ids: set[str] = set()
|
|
807
|
+
final_message_parts: list[str] = []
|
|
808
|
+
final_delta_parts: list[str] = []
|
|
809
|
+
fallback_message_parts: list[str] = []
|
|
810
|
+
fallback_delta_parts: list[str] = []
|
|
811
|
+
|
|
812
|
+
for line in raw.split("\n"):
|
|
813
|
+
line = line.strip()
|
|
814
|
+
if not line:
|
|
815
|
+
continue
|
|
816
|
+
try:
|
|
817
|
+
data = json.loads(line)
|
|
818
|
+
except json.JSONDecodeError:
|
|
819
|
+
continue
|
|
820
|
+
if not isinstance(data, dict):
|
|
821
|
+
continue
|
|
822
|
+
|
|
823
|
+
event_type = str(data.get("type", ""))
|
|
824
|
+
msg_data = data.get("data") or data
|
|
825
|
+
|
|
826
|
+
if event_type == "assistant.message_start":
|
|
827
|
+
if msg_data.get("phase") == "final_answer":
|
|
828
|
+
message_id = msg_data.get("messageId")
|
|
829
|
+
if isinstance(message_id, str) and message_id:
|
|
830
|
+
final_answer_ids.add(message_id)
|
|
831
|
+
continue
|
|
832
|
+
|
|
833
|
+
if event_type == "assistant.message":
|
|
834
|
+
content = msg_data.get("content", "")
|
|
835
|
+
if not isinstance(content, str) or not content.strip():
|
|
836
|
+
continue
|
|
837
|
+
message_id = msg_data.get("messageId")
|
|
838
|
+
if message_id in final_answer_ids or msg_data.get("phase") == "final_answer":
|
|
839
|
+
final_message_parts.append(content.strip())
|
|
840
|
+
else:
|
|
841
|
+
fallback_message_parts.append(content.strip())
|
|
842
|
+
continue
|
|
843
|
+
|
|
844
|
+
if event_type == "assistant.message_delta":
|
|
845
|
+
delta = msg_data.get("deltaContent", "")
|
|
846
|
+
if not isinstance(delta, str) or not delta:
|
|
847
|
+
continue
|
|
848
|
+
message_id = msg_data.get("messageId")
|
|
849
|
+
if message_id in final_answer_ids:
|
|
850
|
+
final_delta_parts.append(delta)
|
|
851
|
+
else:
|
|
852
|
+
fallback_delta_parts.append(delta)
|
|
853
|
+
|
|
854
|
+
if final_delta_parts:
|
|
855
|
+
return "".join(final_delta_parts).strip()
|
|
856
|
+
if final_message_parts:
|
|
857
|
+
return "\n\n".join(final_message_parts).strip()
|
|
858
|
+
if fallback_message_parts:
|
|
859
|
+
return "\n\n".join(fallback_message_parts).strip()
|
|
860
|
+
if fallback_delta_parts:
|
|
861
|
+
return "".join(fallback_delta_parts).strip()
|
|
862
|
+
return raw
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _daemon_extract_claude_output(raw: str) -> str:
|
|
866
|
+
"""Extract result text from Claude CLI JSON/stream-json output."""
|
|
867
|
+
raw = raw.strip()
|
|
868
|
+
try:
|
|
869
|
+
data = json.loads(raw)
|
|
870
|
+
if isinstance(data, dict):
|
|
871
|
+
if "result" in data:
|
|
872
|
+
return data["result"]
|
|
873
|
+
if "content" in data:
|
|
874
|
+
blocks = data["content"]
|
|
875
|
+
if isinstance(blocks, list):
|
|
876
|
+
texts = [b.get("text", "") for b in blocks if b.get("type") == "text"]
|
|
877
|
+
return "\n".join(texts)
|
|
878
|
+
elif isinstance(data, list):
|
|
879
|
+
texts = [item.get("text", "") for item in data if isinstance(item, dict) and item.get("type") == "text"]
|
|
880
|
+
if texts:
|
|
881
|
+
return "\n".join(texts)
|
|
882
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
883
|
+
pass
|
|
884
|
+
|
|
885
|
+
lines = raw.split("\n")
|
|
886
|
+
for line in reversed(lines):
|
|
887
|
+
line = line.strip()
|
|
888
|
+
if not line.startswith("{"):
|
|
889
|
+
continue
|
|
890
|
+
try:
|
|
891
|
+
data = json.loads(line)
|
|
892
|
+
if isinstance(data, dict) and "result" in data:
|
|
893
|
+
result_val = data["result"]
|
|
894
|
+
if isinstance(result_val, str) and result_val.strip():
|
|
895
|
+
return result_val
|
|
896
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
897
|
+
continue
|
|
898
|
+
|
|
899
|
+
parts: list[str] = []
|
|
900
|
+
for line in lines:
|
|
901
|
+
line = line.strip()
|
|
902
|
+
if not line.startswith("{"):
|
|
903
|
+
continue
|
|
904
|
+
try:
|
|
905
|
+
data = json.loads(line)
|
|
906
|
+
if not isinstance(data, dict):
|
|
907
|
+
continue
|
|
908
|
+
ev_type = data.get("type", "")
|
|
909
|
+
if ev_type == "assistant":
|
|
910
|
+
msg = data.get("message") or {}
|
|
911
|
+
for block in msg.get("content", []):
|
|
912
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
913
|
+
text = block.get("text", "")
|
|
914
|
+
if isinstance(text, str) and text.strip():
|
|
915
|
+
parts.append(text)
|
|
916
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
917
|
+
continue
|
|
918
|
+
if parts:
|
|
919
|
+
return "\n\n".join(parts)
|
|
920
|
+
|
|
921
|
+
brace_start = raw.rfind('{"type":"result"')
|
|
922
|
+
if brace_start == -1:
|
|
923
|
+
brace_start = raw.rfind('{"type": "result"')
|
|
924
|
+
if brace_start >= 0:
|
|
925
|
+
depth = 0
|
|
926
|
+
for i in range(brace_start, len(raw)):
|
|
927
|
+
if raw[i] == "{":
|
|
928
|
+
depth += 1
|
|
929
|
+
elif raw[i] == "}":
|
|
930
|
+
depth -= 1
|
|
931
|
+
if depth == 0:
|
|
932
|
+
try:
|
|
933
|
+
data = json.loads(raw[brace_start : i + 1])
|
|
934
|
+
if isinstance(data, dict) and "result" in data:
|
|
935
|
+
return data["result"]
|
|
936
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
937
|
+
pass
|
|
938
|
+
break
|
|
939
|
+
return raw
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _daemon_extract_opencode_output(raw: str) -> str:
|
|
943
|
+
"""Extract text content from OpenCode JSONL output."""
|
|
944
|
+
parts: list[str] = []
|
|
945
|
+
for line in raw.split("\n"):
|
|
946
|
+
line = line.strip()
|
|
947
|
+
if not line:
|
|
948
|
+
continue
|
|
949
|
+
try:
|
|
950
|
+
data = json.loads(line)
|
|
951
|
+
except json.JSONDecodeError:
|
|
952
|
+
if line:
|
|
953
|
+
parts.append(line)
|
|
954
|
+
continue
|
|
955
|
+
if not isinstance(data, dict):
|
|
956
|
+
continue
|
|
957
|
+
ev_type = str(data.get("type", ""))
|
|
958
|
+
part = data.get("part") or {}
|
|
959
|
+
if ev_type == "text" and isinstance(part, dict):
|
|
960
|
+
text = part.get("text", "")
|
|
961
|
+
if isinstance(text, str) and text.strip():
|
|
962
|
+
parts.append(text)
|
|
963
|
+
return "\n".join(parts) if parts else raw
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _daemon_extract_agent_output(agent_id: str, raw: str) -> str:
|
|
967
|
+
"""Dispatch to per-agent extraction; falls back to raw for unknown agents."""
|
|
968
|
+
if agent_id == "claude":
|
|
969
|
+
return _daemon_extract_claude_output(raw)
|
|
970
|
+
if agent_id == "copilot":
|
|
971
|
+
return _daemon_extract_copilot_output(raw)
|
|
972
|
+
if agent_id == "opencode":
|
|
973
|
+
return _daemon_extract_opencode_output(raw)
|
|
974
|
+
return raw
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _daemon_extract_json(text: str):
|
|
978
|
+
"""Extract a JSON array or object from text (no external imports)."""
|
|
979
|
+
text = text.strip()
|
|
980
|
+
if text.startswith("```"):
|
|
981
|
+
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
|
982
|
+
try:
|
|
983
|
+
return json.loads(text)
|
|
984
|
+
except json.JSONDecodeError:
|
|
985
|
+
pass
|
|
986
|
+
for start_char, end_char in [("[", "]"), ("{", "}")]:
|
|
987
|
+
start = text.find(start_char)
|
|
988
|
+
end = text.rfind(end_char)
|
|
989
|
+
if start != -1 and end > start:
|
|
990
|
+
try:
|
|
991
|
+
return json.loads(text[start : end + 1])
|
|
992
|
+
except json.JSONDecodeError:
|
|
993
|
+
continue
|
|
994
|
+
return None
|
|
995
|
+
|
|
996
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
997
|
+
|
|
998
|
+
|
|
797
999
|
def _normalize_ai_job_output(
|
|
798
1000
|
*,
|
|
799
1001
|
task_type: str,
|
|
800
1002
|
agent_id: str,
|
|
801
1003
|
raw_output: str,
|
|
802
1004
|
) -> tuple[str, list[dict] | None]:
|
|
803
|
-
"""Extract agent-specific final text before truncating for transport.
|
|
1005
|
+
"""Extract agent-specific final text before truncating for transport.
|
|
1006
|
+
|
|
1007
|
+
Uses self-contained helpers (_daemon_extract_*) so this function works
|
|
1008
|
+
both inside the backend server (where ``app.*`` is importable) and in the
|
|
1009
|
+
standalone CLI / desktop daemon (where it is not).
|
|
1010
|
+
"""
|
|
804
1011
|
if not raw_output:
|
|
805
1012
|
return "", None
|
|
806
1013
|
|
|
807
1014
|
if task_type not in {"qa_scenario_generate_prompt", "test_script_generate"}:
|
|
808
1015
|
return raw_output, None
|
|
809
1016
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
extracted_output = AIExecutionStrategy._extract_agent_output(agent_id, raw_output)
|
|
1017
|
+
extracted_output = _daemon_extract_agent_output(agent_id, raw_output)
|
|
813
1018
|
if task_type == "test_script_generate":
|
|
814
1019
|
return extracted_output or raw_output, None
|
|
815
1020
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
parsed = QAAIAssistant._extract_json(extracted_output)
|
|
1021
|
+
parsed = _daemon_extract_json(extracted_output)
|
|
819
1022
|
if isinstance(parsed, list):
|
|
820
1023
|
return json.dumps(parsed), parsed
|
|
821
1024
|
if isinstance(parsed, dict):
|
|
@@ -2934,6 +3137,31 @@ class ProcessManager:
|
|
|
2934
3137
|
and not signals["error_messages"]
|
|
2935
3138
|
)
|
|
2936
3139
|
|
|
3140
|
+
@staticmethod
|
|
3141
|
+
def _copilot_called_any_tools(stdout: str) -> bool:
|
|
3142
|
+
"""Return True if the Copilot output contains at least one tool call.
|
|
3143
|
+
|
|
3144
|
+
Used to distinguish an early API-rejection failure (exitCode=1 with
|
|
3145
|
+
no work done) from a mid-run failure where the CLI did attempt tool
|
|
3146
|
+
calls before encountering an error.
|
|
3147
|
+
"""
|
|
3148
|
+
for raw in (stdout or "").split("\n"):
|
|
3149
|
+
raw = raw.strip()
|
|
3150
|
+
if not raw:
|
|
3151
|
+
continue
|
|
3152
|
+
try:
|
|
3153
|
+
data = json.loads(raw)
|
|
3154
|
+
except json.JSONDecodeError:
|
|
3155
|
+
continue
|
|
3156
|
+
if isinstance(data, dict):
|
|
3157
|
+
ev = data.get("type", "")
|
|
3158
|
+
# tool.execution_start / tool_use / assistant.tool_calls are
|
|
3159
|
+
# all signals that at least one tool was invoked.
|
|
3160
|
+
if ev in ("tool.execution_start", "tool_use", "tool_result",
|
|
3161
|
+
"tool.execution_complete", "assistant.tool_calls"):
|
|
3162
|
+
return True
|
|
3163
|
+
return False
|
|
3164
|
+
|
|
2937
3165
|
@staticmethod
|
|
2938
3166
|
def is_rate_limited(result: "TaskResult") -> bool:
|
|
2939
3167
|
"""Check if an agent failure warrants trying a different agent.
|
|
@@ -3894,6 +4122,8 @@ class ProcessManager:
|
|
|
3894
4122
|
async def _run_copilot(
|
|
3895
4123
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
3896
4124
|
on_chunk: Any = None,
|
|
4125
|
+
*,
|
|
4126
|
+
_retry_without_effort: bool = False,
|
|
3897
4127
|
) -> TaskResult:
|
|
3898
4128
|
"""Run GitHub Copilot CLI in non-interactive JSON-streaming mode.
|
|
3899
4129
|
|
|
@@ -3902,30 +4132,50 @@ class ProcessManager:
|
|
|
3902
4132
|
``gh auth login`` or GITHUB_TOKEN env var must be set.
|
|
3903
4133
|
|
|
3904
4134
|
Flags:
|
|
3905
|
-
-p / --prompt
|
|
4135
|
+
-p / --prompt Non-interactive prompt (exits after completion).
|
|
3906
4136
|
--output-format json JSONL stream of session events.
|
|
3907
|
-
--allow-all
|
|
3908
|
-
|
|
3909
|
-
-C <dir>
|
|
4137
|
+
--allow-all Grant all tool + path + URL permissions required
|
|
4138
|
+
for autonomous file-editing tasks.
|
|
4139
|
+
-C <dir> Change working directory before execution.
|
|
4140
|
+
|
|
4141
|
+
Compatibility note:
|
|
4142
|
+
``--effort`` is only included when ``FACTORY_COPILOT_REASONING`` is
|
|
4143
|
+
explicitly set to a non-empty value. Older Copilot CLI releases (and
|
|
4144
|
+
some VS Code–bundled variants) don't accept ``--effort``; passing it
|
|
4145
|
+
makes the CLI return exitCode=1 in the JSONL result event immediately
|
|
4146
|
+
without calling any tools. Making it opt-in avoids this incompatibility
|
|
4147
|
+
while still allowing operators to tune effort on supported versions.
|
|
3910
4148
|
"""
|
|
3911
4149
|
env = os.environ.copy()
|
|
3912
4150
|
env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
|
|
3913
4151
|
env["COPILOT_HOME"] = self._prepare_copilot_home()
|
|
3914
4152
|
|
|
3915
4153
|
model_override = os.environ.get("FACTORY_COPILOT_MODEL")
|
|
3916
|
-
|
|
4154
|
+
# FACTORY_COPILOT_REASONING is opt-in: only pass --effort when the env
|
|
4155
|
+
# var is explicitly set to a non-empty string. The default (unset / "")
|
|
4156
|
+
# intentionally omits --effort so that Copilot CLI versions that don't
|
|
4157
|
+
# support the flag don't immediately fail with exitCode=1.
|
|
4158
|
+
reasoning = os.environ.get("FACTORY_COPILOT_REASONING", "").strip()
|
|
3917
4159
|
|
|
3918
4160
|
cmd = [
|
|
3919
4161
|
agent.command,
|
|
3920
4162
|
"--output-format", "json",
|
|
3921
4163
|
"--allow-all",
|
|
3922
|
-
"--effort", reasoning,
|
|
3923
|
-
"-C", str(cwd),
|
|
3924
|
-
"-p", prompt,
|
|
3925
4164
|
]
|
|
4165
|
+
if reasoning and not _retry_without_effort:
|
|
4166
|
+
cmd += ["--effort", reasoning]
|
|
4167
|
+
cmd += ["-C", str(cwd), "-p", prompt]
|
|
3926
4168
|
if model_override:
|
|
3927
4169
|
cmd = [agent.command, "--model", model_override] + cmd[1:]
|
|
3928
4170
|
|
|
4171
|
+
# Log the exact invocation (redact prompt body) so operators can
|
|
4172
|
+
# reproduce failures manually and diagnose CLI version incompatibilities.
|
|
4173
|
+
_cmd_display = " ".join(
|
|
4174
|
+
f'"{a}"' if " " in a else a
|
|
4175
|
+
for a in cmd[:-1] # omit the -p value (potentially huge/sensitive)
|
|
4176
|
+
) + ' -p "<prompt>"'
|
|
4177
|
+
logger.debug("Copilot invocation for task %s: %s", task_id, _cmd_display)
|
|
4178
|
+
|
|
3929
4179
|
try:
|
|
3930
4180
|
proc = await asyncio.create_subprocess_exec(
|
|
3931
4181
|
*cmd,
|
|
@@ -3975,6 +4225,38 @@ class ProcessManager:
|
|
|
3975
4225
|
metrics=metrics,
|
|
3976
4226
|
)
|
|
3977
4227
|
else:
|
|
4228
|
+
# --- Immediate-failure retry ladder ---
|
|
4229
|
+
# If Copilot exited with code 1 and no tools were called, the CLI
|
|
4230
|
+
# rejected the initial API request before doing any work. This is a
|
|
4231
|
+
# flag-incompatibility or auth/quota symptom; retry with progressively
|
|
4232
|
+
# stripped flags to auto-heal across CLI versions.
|
|
4233
|
+
#
|
|
4234
|
+
# Retry 1: drop --effort (added in newer ghcs; unsupported in older
|
|
4235
|
+
# VS Code-bundled versions).
|
|
4236
|
+
# Retry 2: if still failing AND we haven't already tried both drops,
|
|
4237
|
+
# the error is likely auth/quota — log actionable hint.
|
|
4238
|
+
_no_tools = not self._copilot_called_any_tools(stdout)
|
|
4239
|
+
if effective_rc == 1 and _no_tools and reasoning and not _retry_without_effort:
|
|
4240
|
+
logger.warning(
|
|
4241
|
+
"Copilot exitCode=1 with no tool calls for task %s — "
|
|
4242
|
+
"retrying without --effort (flag may be unsupported by this CLI version). "
|
|
4243
|
+
"Invocation was: %s",
|
|
4244
|
+
task_id, _cmd_display,
|
|
4245
|
+
)
|
|
4246
|
+
return await self._run_copilot(
|
|
4247
|
+
agent, prompt, cwd, timeout, task_id, on_chunk,
|
|
4248
|
+
_retry_without_effort=True,
|
|
4249
|
+
)
|
|
4250
|
+
# Exhausted retries — emit an actionable diagnostic hint
|
|
4251
|
+
if effective_rc == 1 and _no_tools:
|
|
4252
|
+
logger.error(
|
|
4253
|
+
"Copilot exitCode=1 with no tool calls for task %s after retries. "
|
|
4254
|
+
"Likely causes: (1) GitHub auth expired — run `gh auth status` and "
|
|
4255
|
+
"`gh auth login --scopes copilot`; (2) Copilot subscription inactive; "
|
|
4256
|
+
"(3) --allow-all flag not supported by this CLI version (%s). "
|
|
4257
|
+
"To reproduce manually: %s",
|
|
4258
|
+
task_id, agent.version, _cmd_display,
|
|
4259
|
+
)
|
|
3978
4260
|
return TaskResult(
|
|
3979
4261
|
status="failed",
|
|
3980
4262
|
exit_code=effective_rc,
|
|
@@ -6465,8 +6747,36 @@ class RuntimeDaemon:
|
|
|
6465
6747
|
if not intents:
|
|
6466
6748
|
issues.append("test-intent.json contains no test intents")
|
|
6467
6749
|
for ti in intents[:20]:
|
|
6468
|
-
|
|
6469
|
-
|
|
6750
|
+
# Accept common alternative field names that agents generate:
|
|
6751
|
+
# id → also accept "test_id", "intent_id"
|
|
6752
|
+
# title → also accept "description", "summary", "name"
|
|
6753
|
+
_ti_id = (
|
|
6754
|
+
ti.get("id")
|
|
6755
|
+
or ti.get("test_id")
|
|
6756
|
+
or ti.get("intent_id")
|
|
6757
|
+
)
|
|
6758
|
+
_ti_label = (
|
|
6759
|
+
ti.get("title")
|
|
6760
|
+
or ti.get("description")
|
|
6761
|
+
or ti.get("summary")
|
|
6762
|
+
or ti.get("name")
|
|
6763
|
+
)
|
|
6764
|
+
if not _ti_id or not _ti_label:
|
|
6765
|
+
# Include actual keys present so the retry prompt gives
|
|
6766
|
+
# the agent precise feedback on what to fix.
|
|
6767
|
+
_present_keys = list(ti.keys())[:8]
|
|
6768
|
+
if not _ti_id:
|
|
6769
|
+
issues.append(
|
|
6770
|
+
f"Test intent missing required 'id' field "
|
|
6771
|
+
f"(found keys: {_present_keys}). "
|
|
6772
|
+
f"Each intent MUST have an 'id' field (e.g. \"id\": \"TI-001\")."
|
|
6773
|
+
)
|
|
6774
|
+
else:
|
|
6775
|
+
issues.append(
|
|
6776
|
+
f"Test intent '{_ti_id}' missing required 'title' or 'description' field "
|
|
6777
|
+
f"(found keys: {_present_keys}). "
|
|
6778
|
+
f"Each intent MUST have a 'title' field (e.g. \"title\": \"...\")."
|
|
6779
|
+
)
|
|
6470
6780
|
break
|
|
6471
6781
|
except _json.JSONDecodeError as e:
|
|
6472
6782
|
issues.append(f"test-intent.json is not valid JSON: {e}")
|
|
@@ -27,6 +27,16 @@ Usage:
|
|
|
27
27
|
forgexa gates pending
|
|
28
28
|
forgexa config show
|
|
29
29
|
forgexa --help
|
|
30
|
+
|
|
31
|
+
Daemon shortcut commands (aliases — no need to type `daemon`):
|
|
32
|
+
forgexa start -d
|
|
33
|
+
forgexa stop
|
|
34
|
+
forgexa restart
|
|
35
|
+
forgexa status
|
|
36
|
+
forgexa status --verbose
|
|
37
|
+
forgexa logs -f
|
|
38
|
+
forgexa logs -n 100 -f
|
|
39
|
+
forgexa agents
|
|
30
40
|
"""
|
|
31
41
|
from __future__ import annotations
|
|
32
42
|
|
|
@@ -1387,6 +1397,59 @@ def main() -> None:
|
|
|
1387
1397
|
)
|
|
1388
1398
|
daemon_agents_p.add_argument("--all", action="store_true", help="Include all users' runtimes (admin only)")
|
|
1389
1399
|
|
|
1400
|
+
# ── Daemon shortcut commands ───────────────────────────────────────────────
|
|
1401
|
+
# Top-level aliases so users can type `forgexa start` instead of
|
|
1402
|
+
# `forgexa daemon start`. Both forms are fully equivalent.
|
|
1403
|
+
_start_sc = sub.add_parser(
|
|
1404
|
+
"start",
|
|
1405
|
+
help="Start local daemon (alias: forgexa daemon start)",
|
|
1406
|
+
)
|
|
1407
|
+
_start_sc.add_argument(
|
|
1408
|
+
"-d", "--detach",
|
|
1409
|
+
action="store_true",
|
|
1410
|
+
help="Run in background (recommended for production use)",
|
|
1411
|
+
)
|
|
1412
|
+
_start_sc.add_argument("--server-url", default=None, help="Server URL to connect to")
|
|
1413
|
+
|
|
1414
|
+
sub.add_parser(
|
|
1415
|
+
"stop",
|
|
1416
|
+
help="Stop local daemon (alias: forgexa daemon stop)",
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
_restart_sc = sub.add_parser(
|
|
1420
|
+
"restart",
|
|
1421
|
+
help="Restart local daemon (alias: forgexa daemon restart)",
|
|
1422
|
+
)
|
|
1423
|
+
_restart_sc.add_argument("--server-url", default=None, help="Server URL to connect to")
|
|
1424
|
+
|
|
1425
|
+
_status_sc = sub.add_parser(
|
|
1426
|
+
"status",
|
|
1427
|
+
help="Daemon status and agents (alias: forgexa daemon status)",
|
|
1428
|
+
)
|
|
1429
|
+
_status_sc.add_argument("--all", action="store_true", help="List all runtimes (platform admin only)")
|
|
1430
|
+
_status_sc.add_argument("-v", "--verbose", action="store_true", help="Expanded per-agent detail")
|
|
1431
|
+
|
|
1432
|
+
_logs_sc = sub.add_parser(
|
|
1433
|
+
"logs",
|
|
1434
|
+
help="Stream daemon logs (alias: forgexa daemon logs)",
|
|
1435
|
+
)
|
|
1436
|
+
_logs_sc.add_argument(
|
|
1437
|
+
"-n", "--lines",
|
|
1438
|
+
type=int, default=50, metavar="N",
|
|
1439
|
+
help="Show last N lines before following (default: 50)",
|
|
1440
|
+
)
|
|
1441
|
+
_logs_sc.add_argument(
|
|
1442
|
+
"-f", "--follow",
|
|
1443
|
+
action="store_true",
|
|
1444
|
+
help="Stream new log lines as they arrive (like tail -f)",
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
_agents_sc = sub.add_parser(
|
|
1448
|
+
"agents",
|
|
1449
|
+
help="List AI agents (alias: forgexa daemon agents)",
|
|
1450
|
+
)
|
|
1451
|
+
_agents_sc.add_argument("--all", action="store_true", help="Include all users' runtimes (admin only)")
|
|
1452
|
+
|
|
1390
1453
|
# runtimes
|
|
1391
1454
|
rt_p = sub.add_parser("runtimes", help="Runtime management")
|
|
1392
1455
|
rt_sub = rt_p.add_subparsers(dest="rt_cmd")
|
|
@@ -1489,6 +1552,13 @@ def main() -> None:
|
|
|
1489
1552
|
"logs": cmd_daemon_logs,
|
|
1490
1553
|
"agents": cmd_daemon_agents,
|
|
1491
1554
|
}.get(a.daemon_cmd, lambda _: daemon_p.print_help())(a),
|
|
1555
|
+
# Daemon shortcut aliases (identical handlers, no `daemon` prefix needed)
|
|
1556
|
+
"start": cmd_daemon_start,
|
|
1557
|
+
"stop": cmd_daemon_stop,
|
|
1558
|
+
"restart": cmd_daemon_restart,
|
|
1559
|
+
"status": cmd_daemon_status,
|
|
1560
|
+
"logs": cmd_daemon_logs,
|
|
1561
|
+
"agents": cmd_daemon_agents,
|
|
1492
1562
|
"runtimes": lambda a: {
|
|
1493
1563
|
"list": cmd_runtimes_list,
|
|
1494
1564
|
}.get(a.rt_cmd, lambda _: rt_p.print_help())(a),
|
|
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
|