ags-cli 0.1.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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- harness/workers/inprocess.py +135 -0
cli/main.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""`ags` — the AgentS CLI entrypoint (Typer).
|
|
2
|
+
|
|
3
|
+
Thin client over the FastAPI service. Sub-apps mirror the platform surfaces:
|
|
4
|
+
task, run, memory, adapter, session, policy, artifact.
|
|
5
|
+
|
|
6
|
+
Bare prompts are treated as an ``ask``: ``ags "fix the bug"`` is shorthand for
|
|
7
|
+
``ags ask "fix the bug"``. Only when the first word is a real subcommand (or a
|
|
8
|
+
global option) is it routed there. See ``main()``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from cli.commands import (
|
|
18
|
+
adapter,
|
|
19
|
+
artifact,
|
|
20
|
+
ask,
|
|
21
|
+
chat,
|
|
22
|
+
diff,
|
|
23
|
+
doctor,
|
|
24
|
+
gui,
|
|
25
|
+
mcp,
|
|
26
|
+
memory,
|
|
27
|
+
policy,
|
|
28
|
+
project,
|
|
29
|
+
quota,
|
|
30
|
+
run,
|
|
31
|
+
service,
|
|
32
|
+
session,
|
|
33
|
+
stats,
|
|
34
|
+
status,
|
|
35
|
+
task,
|
|
36
|
+
usage,
|
|
37
|
+
)
|
|
38
|
+
from cli.commands import (
|
|
39
|
+
init as init_cmd,
|
|
40
|
+
)
|
|
41
|
+
from cli.commands import model as model_cmd
|
|
42
|
+
from cli.commands import (
|
|
43
|
+
serve as serve_cmd,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
app = typer.Typer(
|
|
47
|
+
help="AgentS — one interface over Claude Code, Google CLI (agy), and local LLMs.",
|
|
48
|
+
no_args_is_help=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Top-level convenience: `ags ask "<prompt>"` (auto-managed session + provider knob)
|
|
52
|
+
# and `ags use <provider>` to set the default provider for a workspace.
|
|
53
|
+
app.command("init")(init_cmd.init)
|
|
54
|
+
app.command("serve")(serve_cmd.serve)
|
|
55
|
+
app.command("ask")(ask.ask)
|
|
56
|
+
app.command("use")(ask.use)
|
|
57
|
+
app.command("chat")(chat.chat)
|
|
58
|
+
app.command("gui")(gui.gui)
|
|
59
|
+
app.command("status")(status.status)
|
|
60
|
+
app.command("quota")(quota.quota)
|
|
61
|
+
app.command("usage")(usage.usage)
|
|
62
|
+
app.command("doctor")(doctor.doctor)
|
|
63
|
+
app.command("diff")(diff.diff)
|
|
64
|
+
app.command("stats")(stats.stats)
|
|
65
|
+
app.add_typer(model_cmd.models_app, name="models")
|
|
66
|
+
app.add_typer(model_cmd.model_app, name="model")
|
|
67
|
+
app.add_typer(project.app, name="project")
|
|
68
|
+
app.add_typer(task.app, name="task")
|
|
69
|
+
app.add_typer(run.app, name="run")
|
|
70
|
+
app.add_typer(memory.app, name="memory")
|
|
71
|
+
app.add_typer(adapter.app, name="adapter")
|
|
72
|
+
app.add_typer(mcp.app, name="mcp")
|
|
73
|
+
app.add_typer(service.app, name="service")
|
|
74
|
+
app.add_typer(session.app, name="session")
|
|
75
|
+
app.add_typer(policy.app, name="policy")
|
|
76
|
+
app.add_typer(artifact.app, name="artifact")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Words that mean "this is a real command/global option", not a prompt. Anything
|
|
80
|
+
# else as the first argument is treated as the text of an `ask`.
|
|
81
|
+
_COMMANDS = {
|
|
82
|
+
"init", "serve", "ask", "use", "chat", "gui", "status", "quota", "usage", "doctor", "diff", "stats",
|
|
83
|
+
"models", "model", "project", "task", "run", "memory", "adapter", "mcp", "service", "session", "policy",
|
|
84
|
+
"artifact",
|
|
85
|
+
}
|
|
86
|
+
_GLOBALS = {"--help", "-h", "--install-completion", "--show-completion"}
|
|
87
|
+
# Provider tokens recognised in the `ags <provider> models|model …` shorthand.
|
|
88
|
+
_PROVIDERS = {"claude", "agy", "antigravity", "gemini", "google", "anthropic",
|
|
89
|
+
"local", "mock"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def route(args: list[str]) -> list[str]:
|
|
93
|
+
"""Given the args after ``ags``, normalize them for Typer:
|
|
94
|
+
|
|
95
|
+
* provider-first shorthand: ``<provider> models [list]`` -> ``models list
|
|
96
|
+
<provider>`` and ``<provider> model [set] <name>`` -> ``model set <provider>
|
|
97
|
+
<name>``.
|
|
98
|
+
* back-compat: bare ``models`` / ``models <provider>`` -> ``models list …``;
|
|
99
|
+
``model <provider> <name>`` -> ``model set <provider> <name>``.
|
|
100
|
+
* a bare prompt (first arg is neither a subcommand nor a global option) ->
|
|
101
|
+
``ask <prompt>`` so ``ags "do x"`` works.
|
|
102
|
+
"""
|
|
103
|
+
if not args:
|
|
104
|
+
return args
|
|
105
|
+
|
|
106
|
+
# provider-first shorthand: `<prov> models …` / `<prov> model …`
|
|
107
|
+
if args[0] in _PROVIDERS and len(args) >= 2 and args[1] in {"models", "model"}:
|
|
108
|
+
rest = args[2:]
|
|
109
|
+
if args[1] == "models":
|
|
110
|
+
if rest[:1] == ["list"]:
|
|
111
|
+
rest = rest[1:]
|
|
112
|
+
return ["models", "list", args[0], *rest]
|
|
113
|
+
if rest[:1] == ["set"]:
|
|
114
|
+
rest = rest[1:]
|
|
115
|
+
return ["model", "set", args[0], *rest]
|
|
116
|
+
|
|
117
|
+
# direct forms + back-compat: ensure the `list` / `set` verb is present.
|
|
118
|
+
if args[0] == "models" and args[1:2] != ["list"]:
|
|
119
|
+
return ["models", "list", *args[1:]]
|
|
120
|
+
if args[0] == "model" and args[1:2] != ["set"]:
|
|
121
|
+
return ["model", "set", *args[1:]]
|
|
122
|
+
|
|
123
|
+
if args[0] not in _COMMANDS and args[0] not in _GLOBALS:
|
|
124
|
+
return ["ask", *args]
|
|
125
|
+
return args
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main() -> None:
|
|
129
|
+
"""Console-script entrypoint. ``ags "do x"`` -> ``ags ask "do x"``.
|
|
130
|
+
|
|
131
|
+
Wraps the app so a transport/API failure prints a clean one-line message
|
|
132
|
+
instead of an httpx traceback (see cli.client.APIError)."""
|
|
133
|
+
from rich.console import Console
|
|
134
|
+
|
|
135
|
+
from cli.client import APIError
|
|
136
|
+
|
|
137
|
+
sys.argv = [sys.argv[0], *route(sys.argv[1:])]
|
|
138
|
+
try:
|
|
139
|
+
app()
|
|
140
|
+
except APIError as exc:
|
|
141
|
+
Console(stderr=True).print(f"[red]✗[/red] {exc}")
|
|
142
|
+
raise SystemExit(1) from exc
|
|
143
|
+
except KeyboardInterrupt:
|
|
144
|
+
Console(stderr=True).print("\n[dim]interrupted[/dim]")
|
|
145
|
+
raise SystemExit(130) from None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
harness/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from harness_sdk.base import (
|
|
2
|
+
AdapterConfig,
|
|
3
|
+
BaseAdapter,
|
|
4
|
+
Capabilities,
|
|
5
|
+
Cost,
|
|
6
|
+
HealthStatus,
|
|
7
|
+
ProviderSession,
|
|
8
|
+
SessionContext,
|
|
9
|
+
Usage,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from harness.adapters.registry import build_adapter, build_adapter_from_config, register
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AdapterConfig",
|
|
16
|
+
"BaseAdapter",
|
|
17
|
+
"Capabilities",
|
|
18
|
+
"Cost",
|
|
19
|
+
"HealthStatus",
|
|
20
|
+
"ProviderSession",
|
|
21
|
+
"SessionContext",
|
|
22
|
+
"Usage",
|
|
23
|
+
"build_adapter",
|
|
24
|
+
"build_adapter_from_config",
|
|
25
|
+
"register",
|
|
26
|
+
]
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""AntigravityCliAdapter — drives Google's official ``agy`` CLI (verified against
|
|
2
|
+
v1.0.16).
|
|
3
|
+
|
|
4
|
+
Empirically verified CLI contract (agy 1.0.16, run live on this machine):
|
|
5
|
+
|
|
6
|
+
1. **No ``--output-format``** — print mode emits plain text, not JSON. So we treat
|
|
7
|
+
stdout as the assistant's response (streamed line-by-line, plus a final
|
|
8
|
+
consolidated message).
|
|
9
|
+
2. **Non-TTY stdout bug** — when stdout is a pipe, ``agy`` emits nothing and hangs.
|
|
10
|
+
We therefore run it under a **PTY** (``default_use_pty = True``); the base class
|
|
11
|
+
allocates the pseudo-terminal.
|
|
12
|
+
3. **``--log-file <path>``** — overrides the CLI log file path (advertised in
|
|
13
|
+
``agy --help``). The conversation id is NOT printed to stdout/PTY; it only
|
|
14
|
+
appears in the glog-style log file. A new conversation writes::
|
|
15
|
+
|
|
16
|
+
I0706 17:28:58.599805 2463524 server.go:825] Created conversation <uuid>
|
|
17
|
+
|
|
18
|
+
Resuming an existing conversation writes::
|
|
19
|
+
|
|
20
|
+
I0706 17:29:14.336520 2463638 printmode.go:169] Print mode: resuming conversation <uuid>
|
|
21
|
+
|
|
22
|
+
4. **``--conversation <id>``** — resumes a previous conversation by ID (advertised
|
|
23
|
+
in ``agy --help``; verified: recalled prior-turn content).
|
|
24
|
+
5. **No ``--mcp-config``** in v1.0.16 — probe gating handles that.
|
|
25
|
+
|
|
26
|
+
The adapter captures the conversation id by writing a run-scoped ``--log-file``
|
|
27
|
+
(see ``_log_paths`` / ``_reap`` bookkeeping) and parsing it in ``finalize``.
|
|
28
|
+
|
|
29
|
+
Invocation (auth via the CLI's own login or an injected ``ANTIGRAVITY_API_KEY`` /
|
|
30
|
+
``GEMINI_API_KEY`` from a ``secret_ref`` — never token hijacking):
|
|
31
|
+
|
|
32
|
+
agy --model <model> --add-dir <cwd> --print-timeout <N>s \
|
|
33
|
+
--log-file <run-scoped-path> \
|
|
34
|
+
[--conversation <id>] [--dangerously-skip-permissions] -p "<message>"
|
|
35
|
+
|
|
36
|
+
The adapter is the abstract "official Google CLI" slot: the binary and flags are
|
|
37
|
+
config-driven (``params.bin`` / ``params.cmd``), so a future successor swaps in via
|
|
38
|
+
config without code changes.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import contextlib
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import shutil
|
|
48
|
+
import tempfile
|
|
49
|
+
from collections.abc import Iterable
|
|
50
|
+
|
|
51
|
+
from harness_sdk.base import Capabilities, SessionContext
|
|
52
|
+
from harness_sdk.cli_base import CliSubprocessAdapter, ParserState
|
|
53
|
+
from harness_sdk.events import AdapterMetaEvent, NormalizedEvent, TokenEvent
|
|
54
|
+
|
|
55
|
+
from harness.adapters.probe import probe_cli, supports_flag
|
|
56
|
+
from harness.models.enums import AdapterKind
|
|
57
|
+
|
|
58
|
+
log = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
|
61
|
+
# Lines agy prints as spinners/status that aren't part of the answer.
|
|
62
|
+
_NOISE = re.compile(r"^\s*(?:Thinking\b|Loading\b|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏)", re.IGNORECASE)
|
|
63
|
+
# Matches the conversation id from glog lines in the run-scoped --log-file.
|
|
64
|
+
_CONV_ID = re.compile(
|
|
65
|
+
r"(?:Created conversation|resuming conversation)"
|
|
66
|
+
r" ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AntigravityCliAdapter(CliSubprocessAdapter):
|
|
71
|
+
kind = AdapterKind.antigravity
|
|
72
|
+
default_use_pty = True # agy suppresses stdout on a non-TTY
|
|
73
|
+
|
|
74
|
+
def __init__(self, config) -> None: # noqa: ANN001 - AdapterConfig
|
|
75
|
+
super().__init__(config)
|
|
76
|
+
self._mcp_dirs: dict[str, str] = {}
|
|
77
|
+
# _log_paths: run_id → agy.log path inside its temp dir.
|
|
78
|
+
# Populated by build_argv; read and cleaned up in finalize().
|
|
79
|
+
# NOT touched by _reap because _reap runs before finalize in the base class.
|
|
80
|
+
self._log_paths: dict[str, str] = {}
|
|
81
|
+
self._probe: dict | None = None
|
|
82
|
+
|
|
83
|
+
async def ensure_probe(self) -> dict:
|
|
84
|
+
"""Run ``probe_cli`` once, cache the result on the instance, and return it.
|
|
85
|
+
|
|
86
|
+
On any failure returns a safe fallback ``{"ok": False, "flags": set(), "version": None}``
|
|
87
|
+
so callers never need to guard against exceptions from this method.
|
|
88
|
+
"""
|
|
89
|
+
if self._probe is not None:
|
|
90
|
+
return self._probe
|
|
91
|
+
try:
|
|
92
|
+
result = await probe_cli(self.binary)
|
|
93
|
+
except Exception:
|
|
94
|
+
return {"ok": False, "flags": set(), "version": None}
|
|
95
|
+
self._probe = result
|
|
96
|
+
return self._probe
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def binary(self) -> str:
|
|
100
|
+
return self.config.params.get("bin") or self.config.params.get("cmd") or "agy"
|
|
101
|
+
|
|
102
|
+
def env_overrides(self) -> dict[str, str]:
|
|
103
|
+
env: dict[str, str] = {}
|
|
104
|
+
if self.config.api_key:
|
|
105
|
+
# agy accepts either; set both so it works regardless of which it reads.
|
|
106
|
+
env["ANTIGRAVITY_API_KEY"] = self.config.api_key
|
|
107
|
+
env["GEMINI_API_KEY"] = self.config.api_key
|
|
108
|
+
# Opt-in managed-skill home. agy's config-dir env var must be verified against
|
|
109
|
+
# the pinned CLI, so we only set it when explicitly named via params.config_dir_env.
|
|
110
|
+
from harness.skills import provision_config_home
|
|
111
|
+
if (envname := self.config.params.get("config_dir_env")) and (
|
|
112
|
+
home := provision_config_home(self.config.name)
|
|
113
|
+
):
|
|
114
|
+
env[envname] = home
|
|
115
|
+
return env
|
|
116
|
+
|
|
117
|
+
def health_argv(self) -> list[str]:
|
|
118
|
+
return [self.binary, "--version"]
|
|
119
|
+
|
|
120
|
+
async def list_models(self) -> list[str]:
|
|
121
|
+
"""`agy models` prints one model per line (non-interactively, with stdin
|
|
122
|
+
detached). Verified against v1.0.10."""
|
|
123
|
+
import asyncio
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
proc = await asyncio.create_subprocess_exec(
|
|
127
|
+
self.binary, "models",
|
|
128
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
129
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
130
|
+
)
|
|
131
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
|
|
132
|
+
except (FileNotFoundError, TimeoutError, OSError):
|
|
133
|
+
return [self.config.model] if self.config.model else []
|
|
134
|
+
models = [ln.strip() for ln in out.decode(errors="replace").splitlines() if ln.strip()]
|
|
135
|
+
return models or ([self.config.model] if self.config.model else [])
|
|
136
|
+
|
|
137
|
+
def build_argv(self, *, run_id: str, ctx: SessionContext, message: str) -> list[str]:
|
|
138
|
+
p = self.config.params
|
|
139
|
+
argv = [self.binary]
|
|
140
|
+
if self.config.model:
|
|
141
|
+
argv += ["--model", self.config.model]
|
|
142
|
+
if (cwd := self.working_dir(ctx)):
|
|
143
|
+
argv += ["--add-dir", cwd]
|
|
144
|
+
argv += ["--print-timeout", f"{int(self.timeout_s)}s"]
|
|
145
|
+
if (conv := ctx.extra.get("conversation_id")):
|
|
146
|
+
argv += ["--conversation", str(conv)]
|
|
147
|
+
# Safe-by-default: no unattended edits. Write mode (the per-run workspace
|
|
148
|
+
# mode on the context) lets agy apply changes, in its own sandbox.
|
|
149
|
+
write = not ctx.read_only
|
|
150
|
+
if p.get("dangerously_skip_permissions") or write:
|
|
151
|
+
argv.append("--dangerously-skip-permissions")
|
|
152
|
+
if p.get("sandbox", write):
|
|
153
|
+
argv.append("--sandbox")
|
|
154
|
+
import os
|
|
155
|
+
argv += self._mcp_argv(run_id, ctx)
|
|
156
|
+
# Run-scoped log file so we can harvest the conversation id in finalize.
|
|
157
|
+
log_dir = tempfile.mkdtemp(prefix=f"ags-agy-{run_id[:8]}-")
|
|
158
|
+
log_path = os.path.join(log_dir, "agy.log")
|
|
159
|
+
self._log_paths[run_id] = log_path
|
|
160
|
+
argv += ["--log-file", log_path]
|
|
161
|
+
# System/memory context is prepended to the prompt (agy has no system flag).
|
|
162
|
+
system = "\n\n".join(x for x in (ctx.system_prompt, ctx.memory_context) if x)
|
|
163
|
+
prompt = f"{system}\n\n---\n\n{message}" if system else message
|
|
164
|
+
|
|
165
|
+
import os
|
|
166
|
+
# On Windows, `winpty` crashes if argv > 8191 chars.
|
|
167
|
+
# We can write the prompt to a temp file and tell agy to load it.
|
|
168
|
+
# Agy natively accepts an `--instruction-file` flag or `-p "$(cat file)"` equivalent
|
|
169
|
+
# Actually `agy` supports piping to stdin but we're in PTY mode.
|
|
170
|
+
# Let's write to a prompt.txt file and set an environment variable or flag, but since we only have `argv`,
|
|
171
|
+
# `agy` accepts `--instruction-file <path>` in modern versions for the prompt block.
|
|
172
|
+
# If the user says we should use "cat tmp.txt | agy", we can't easily pipe via PTY without shell.
|
|
173
|
+
# The best workaround for Windows subprocess length limit is to temporarily disable PTY and pipe the prompt.
|
|
174
|
+
if os.name == "nt" and len(prompt) > 4000:
|
|
175
|
+
self.config.params["pty"] = False
|
|
176
|
+
# `stdin_payload` handles the payload when `pty=False`.
|
|
177
|
+
else:
|
|
178
|
+
argv += ["-p", prompt]
|
|
179
|
+
|
|
180
|
+
# Save prompt to the adapter instance for `stdin_payload` to consume if needed
|
|
181
|
+
self._prompt_buffer = prompt.encode("utf-8")
|
|
182
|
+
return argv
|
|
183
|
+
|
|
184
|
+
def stdin_payload(self, message: str, ctx: SessionContext | None = None) -> bytes | None:
|
|
185
|
+
if getattr(self, "_prompt_buffer", None) is not None and self.config.params.get("pty") is False:
|
|
186
|
+
return self._prompt_buffer
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def _mcp_argv(self, run_id: str, ctx: SessionContext) -> list[str]:
|
|
190
|
+
"""agy is its own MCP client: write the harness-resolved servers to a run-scoped
|
|
191
|
+
config and point agy at it.
|
|
192
|
+
|
|
193
|
+
The flag used (``--mcp-config`` by default) is verified against the probed CLI
|
|
194
|
+
capability set. If the probe is available and the flag is not supported, MCP
|
|
195
|
+
servers are skipped (returning ``[]``) and a one-time warning is logged.
|
|
196
|
+
|
|
197
|
+
Manual override: setting ``params.mcp_config_flag`` explicitly bypasses probe
|
|
198
|
+
gating — the user-supplied flag is always passed, letting operators work with
|
|
199
|
+
pre-release CLIs or renamed flags without a code change."""
|
|
200
|
+
from harness.mcp import config_gen
|
|
201
|
+
|
|
202
|
+
servers = ctx.extra.get("mcp_servers") or []
|
|
203
|
+
if not servers:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
# If the user set an explicit flag, it bypasses probe gating (manual override wins).
|
|
207
|
+
explicit_flag = self.config.params.get("mcp_config_flag")
|
|
208
|
+
flag = explicit_flag or "--mcp-config"
|
|
209
|
+
|
|
210
|
+
# Probe gating: only applies when no explicit override AND a probe result is cached.
|
|
211
|
+
if not explicit_flag and self._probe is not None:
|
|
212
|
+
if not supports_flag(self._probe, flag):
|
|
213
|
+
log.warning(
|
|
214
|
+
"mcp_flag_not_supported_by_cli",
|
|
215
|
+
extra={"flag": flag, "binary": self.binary},
|
|
216
|
+
)
|
|
217
|
+
log.warning(
|
|
218
|
+
"Skipping MCP servers: flag %r not found in %r help output; "
|
|
219
|
+
"set params.mcp_config_flag to override",
|
|
220
|
+
flag, self.binary,
|
|
221
|
+
)
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
d = tempfile.mkdtemp(prefix=f"ags-mcp-{run_id[:8]}-")
|
|
225
|
+
self._mcp_dirs[run_id] = d
|
|
226
|
+
path = config_gen.write_json(
|
|
227
|
+
config_gen.antigravity_mcp_config(servers), d, "mcp.json"
|
|
228
|
+
)
|
|
229
|
+
return [flag, path]
|
|
230
|
+
|
|
231
|
+
async def cancel_run(self, run_id: str) -> None:
|
|
232
|
+
"""Cancel the run and clean up any unconsumed log temp dir.
|
|
233
|
+
|
|
234
|
+
On all three abandonment paths (stall, cooperative-cancel, deadline) the
|
|
235
|
+
engine calls ``cancel_run`` before closing the iterator. Cleaning the log
|
|
236
|
+
dir here means it is gone before ``finalize`` would normally read it — but
|
|
237
|
+
since ``finalize`` is only called on the happy path, there is no race:
|
|
238
|
+
``finalize`` checks ``_log_paths``; if we've already popped the entry,
|
|
239
|
+
``finalize`` skips it cleanly."""
|
|
240
|
+
log_path = self._log_paths.pop(run_id, None)
|
|
241
|
+
if log_path is not None:
|
|
242
|
+
with contextlib.suppress(Exception):
|
|
243
|
+
shutil.rmtree(os.path.dirname(log_path), ignore_errors=True)
|
|
244
|
+
await super().cancel_run(run_id)
|
|
245
|
+
|
|
246
|
+
async def _reap(self, proc, run_id: str) -> None: # noqa: ANN001
|
|
247
|
+
if (d := self._mcp_dirs.pop(run_id, None)):
|
|
248
|
+
with contextlib.suppress(Exception):
|
|
249
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
250
|
+
# NOTE: _log_paths is intentionally NOT cleaned here. _reap is called by
|
|
251
|
+
# the base class inside _run_pipe/_run_pty — before finalize() runs.
|
|
252
|
+
# Cleanup of the log temp dir happens in finalize() (primary path) or
|
|
253
|
+
# cancel_run() (abandonment path).
|
|
254
|
+
await super()._reap(proc, run_id)
|
|
255
|
+
|
|
256
|
+
def parse_chunk(self, line: str, state: ParserState) -> Iterable[NormalizedEvent]:
|
|
257
|
+
clean = _ANSI.sub("", line).rstrip()
|
|
258
|
+
if not clean or _NOISE.match(clean):
|
|
259
|
+
return
|
|
260
|
+
state.setdefault("text", []).append(clean + "\n")
|
|
261
|
+
yield TokenEvent(seq=0, text=clean + "\n")
|
|
262
|
+
|
|
263
|
+
def finalize(self, state: ParserState) -> Iterable[NormalizedEvent]:
|
|
264
|
+
# Consolidate the streamed lines into one canonical assistant message.
|
|
265
|
+
yield from self._final_text_message(state)
|
|
266
|
+
# Harvest the conversation id from the run-scoped log file (fail-soft).
|
|
267
|
+
# Read the most-recently-recorded log path; per-instance single-run usage is
|
|
268
|
+
# the common case. The dict preserves insertion order (Python 3.7+).
|
|
269
|
+
log_path: str | None = None
|
|
270
|
+
if self._log_paths:
|
|
271
|
+
log_path = next(reversed(self._log_paths.values()))
|
|
272
|
+
if log_path is not None:
|
|
273
|
+
conv_id = self._extract_conv_id(log_path)
|
|
274
|
+
if conv_id:
|
|
275
|
+
yield AdapterMetaEvent(seq=0, data={"conversation_id": conv_id})
|
|
276
|
+
# Clean up the temp dir. _reap does NOT remove log dirs (timing: _reap
|
|
277
|
+
# runs before finalize inside the base class stream loop), so finalize
|
|
278
|
+
# is the primary cleanup path.
|
|
279
|
+
with contextlib.suppress(Exception):
|
|
280
|
+
shutil.rmtree(os.path.dirname(log_path), ignore_errors=True)
|
|
281
|
+
# Remove from tracking so a subsequent finalize call skips the cleaned dir.
|
|
282
|
+
self._log_paths = {k: v for k, v in self._log_paths.items() if v != log_path}
|
|
283
|
+
|
|
284
|
+
def _extract_conv_id(self, log_path: str) -> str | None:
|
|
285
|
+
"""Read the glog log file and return the last conversation UUID found, or None."""
|
|
286
|
+
try:
|
|
287
|
+
text = open(log_path, errors="replace").read() # noqa: WPS515
|
|
288
|
+
except OSError:
|
|
289
|
+
log.debug("agy log file not readable or missing: %s", log_path)
|
|
290
|
+
return None
|
|
291
|
+
matches = _CONV_ID.findall(text)
|
|
292
|
+
return matches[-1] if matches else None
|
|
293
|
+
|
|
294
|
+
def list_capabilities(self) -> Capabilities:
|
|
295
|
+
return Capabilities(
|
|
296
|
+
streaming=True,
|
|
297
|
+
tool_calling=bool(self.config.capabilities.get("tool_calling", True)),
|
|
298
|
+
max_context=int(self.config.params.get("max_context", 1_000_000)),
|
|
299
|
+
reasoning=True, cost_known=False,
|
|
300
|
+
notes=f"Official Google CLI '{self.binary}' (plain-text, PTY).",
|
|
301
|
+
)
|