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.
Files changed (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. 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,4 @@
1
+ """AgentS — unified interface over Claude Code, Google CLI (agy), and
2
+ OpenAI-compatible local LLMs with shared, vendor-neutral memory."""
3
+
4
+ __version__ = "0.1.0"
@@ -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
+ )