ags-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ags_sdk-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: ags-sdk
3
+ Version: 0.1.0
4
+ Summary: SDK for building custom adapters for AgentS
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pydantic>=2.9
7
+ Requires-Dist: pywinpty>=2.0; sys_platform == "win32"
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "ags-sdk"
3
+ version = "0.1.0"
4
+ description = "SDK for building custom adapters for AgentS"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "pydantic>=2.9",
8
+ # ConPTY wrapper — Windows has no POSIX pty/termios, so the PTY-mode CLI
9
+ # adapters (agy) need this to allocate a console there instead.
10
+ "pywinpty>=2.0; sys_platform == 'win32'",
11
+ ]
12
+
13
+ [build-system]
14
+ requires = ["setuptools>=68", "wheel"]
15
+ build-backend = "setuptools.build_meta"
16
+
17
+ [tool.setuptools]
18
+ package-dir = {"" = "src"}
19
+ packages = ["harness_sdk"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: ags-sdk
3
+ Version: 0.1.0
4
+ Summary: SDK for building custom adapters for AgentS
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pydantic>=2.9
7
+ Requires-Dist: pywinpty>=2.0; sys_platform == "win32"
@@ -0,0 +1,11 @@
1
+ pyproject.toml
2
+ src/ags_sdk.egg-info/PKG-INFO
3
+ src/ags_sdk.egg-info/SOURCES.txt
4
+ src/ags_sdk.egg-info/dependency_links.txt
5
+ src/ags_sdk.egg-info/requires.txt
6
+ src/ags_sdk.egg-info/top_level.txt
7
+ src/harness_sdk/__init__.py
8
+ src/harness_sdk/base.py
9
+ src/harness_sdk/cli_base.py
10
+ src/harness_sdk/events.py
11
+ src/harness_sdk/types.py
@@ -0,0 +1,4 @@
1
+ pydantic>=2.9
2
+
3
+ [:sys_platform == "win32"]
4
+ pywinpty>=2.0
@@ -0,0 +1 @@
1
+ harness_sdk
File without changes
@@ -0,0 +1,158 @@
1
+ """The one canonical adapter interface.
2
+
3
+ Every backend (mock, local OpenAI-compatible, Claude Code, Google CLI) implements
4
+ ``BaseAdapter``. The orchestrator and workers depend ONLY on this contract, never
5
+ on a concrete provider — this is the project's primary lever against vendor
6
+ lock-in. New adapters in the extension SDK subclass this and register in the
7
+ ``registry``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+ from collections.abc import AsyncIterator
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ from harness_sdk.events import NormalizedEvent
18
+ from harness_sdk.types import AdapterKind
19
+
20
+
21
+ class Capabilities(BaseModel):
22
+ """What a provider can do — used for routing decisions and capability gating."""
23
+
24
+ streaming: bool = True
25
+ tool_calling: bool = False
26
+ max_context: int = 8192
27
+ reasoning: bool = False
28
+ cost_known: bool = False
29
+ vision: bool = False
30
+ notes: str = ""
31
+
32
+
33
+ class HealthStatus(BaseModel):
34
+ ok: bool
35
+ latency_ms: float = 0.0
36
+ detail: dict = Field(default_factory=dict)
37
+
38
+
39
+ class Cost(BaseModel):
40
+ prompt_tokens: int = 0
41
+ completion_tokens: int = 0
42
+ total_tokens: int = 0
43
+ usd: float | None = None
44
+
45
+
46
+ class Usage(BaseModel):
47
+ """Raw usage numbers an adapter reports, fed to ``estimate_cost``."""
48
+
49
+ prompt_tokens: int = 0
50
+ completion_tokens: int = 0
51
+
52
+
53
+ class SessionContext(BaseModel):
54
+ """Everything an adapter needs to start a provider session. The harness owns
55
+ canonical state; this is the compact, normalized projection passed to a
56
+ provider for a single run — including retrieved memory context."""
57
+
58
+ run_id: str
59
+ session_id: str
60
+ workspace_slug: str
61
+ objective: str
62
+ constraints: dict = Field(default_factory=dict)
63
+ system_prompt: str | None = None
64
+ memory_context: str = ""
65
+ tools: list[dict] = Field(default_factory=list)
66
+ workspace_dir: str | None = None
67
+ read_only: bool = True # safe-by-default; the engine sets the workspace's mode
68
+ trust_policy: str = "restricted" # workspace.trust_policy; gates risky tool calls
69
+ dry_run: bool = False
70
+ extra: dict = Field(default_factory=dict)
71
+
72
+
73
+ class ProviderSession(BaseModel):
74
+ """Opaque handle returned by ``start_session``; carries provider-side ids."""
75
+
76
+ provider_session_id: str
77
+ adapter_kind: AdapterKind
78
+ meta: dict = Field(default_factory=dict)
79
+
80
+ model_config = {"arbitrary_types_allowed": True}
81
+
82
+
83
+ class AdapterConfig(BaseModel):
84
+ """Resolved configuration for one adapter instance (from ``provider_configs``
85
+ + resolved secret). The secret is held only in memory, never persisted back."""
86
+
87
+ name: str
88
+ kind: AdapterKind
89
+ base_url: str | None = None
90
+ model: str | None = None
91
+ api_key: str | None = None # resolved from secret_ref at construction time
92
+ params: dict = Field(default_factory=dict)
93
+ capabilities: dict = Field(default_factory=dict)
94
+
95
+
96
+ class BaseAdapter(ABC):
97
+ """Strict adapter contract. All methods that touch the network are async."""
98
+
99
+ kind: AdapterKind
100
+
101
+ def __init__(self, config: AdapterConfig) -> None:
102
+ self.config = config
103
+
104
+ # ── lifecycle ─────────────────────────────────────────────────────────────
105
+ @abstractmethod
106
+ async def start_session(self, ctx: SessionContext) -> ProviderSession:
107
+ """Begin a provider session for a single run. Idempotent per run_id."""
108
+
109
+ @abstractmethod
110
+ def stream_events(
111
+ self,
112
+ session: ProviderSession,
113
+ message: str,
114
+ *,
115
+ tools: list[dict] | None = None,
116
+ stream: bool = True,
117
+ ) -> AsyncIterator[NormalizedEvent]:
118
+ """Send a message and yield normalized events. The single hot path used by
119
+ workers; ``send_message`` is a thin convenience wrapper over this."""
120
+
121
+ async def send_message(
122
+ self,
123
+ session: ProviderSession,
124
+ message: str,
125
+ *,
126
+ tools: list[dict] | None = None,
127
+ stream: bool = True,
128
+ ) -> AsyncIterator[NormalizedEvent]:
129
+ """Convenience alias — delegates to ``stream_events``."""
130
+ return self.stream_events(session, message, tools=tools, stream=stream)
131
+
132
+ @abstractmethod
133
+ async def cancel_run(self, run_id: str) -> None:
134
+ """Best-effort cancellation of in-flight work for a run."""
135
+
136
+ # ── introspection ─────────────────────────────────────────────────────────
137
+ @abstractmethod
138
+ def list_capabilities(self) -> Capabilities: ...
139
+
140
+ @abstractmethod
141
+ async def health_check(self) -> HealthStatus: ...
142
+
143
+ async def list_models(self) -> list[str]:
144
+ """Models the provider exposes. Default: the configured model (if any).
145
+ Overridden per adapter to enumerate the backend's catalogue (e.g. the
146
+ OpenAI ``/v1/models`` endpoint or a CLI's ``models`` subcommand)."""
147
+ return [self.config.model] if self.config.model else []
148
+
149
+ def normalize_response(self, raw: dict) -> NormalizedEvent:
150
+ """Map a single provider chunk to a NormalizedEvent. Overridden per
151
+ adapter; the default treats ``raw`` as adapter metadata."""
152
+ from harness_sdk.events import AdapterMetaEvent
153
+
154
+ return AdapterMetaEvent(seq=raw.get("seq", 0), data=raw)
155
+
156
+ def estimate_cost(self, usage: Usage) -> Cost | None:
157
+ """Return a cost estimate where the provider exposes pricing; else None."""
158
+ return None
@@ -0,0 +1,584 @@
1
+ """Shared base for adapters that drive an official CLI as a subprocess.
2
+
3
+ Both ``ClaudeCodeAdapter`` and ``AntigravityCliAdapter`` subclass this. The base
4
+ owns the hard parts — async process spawn, line streaming, sequence numbering,
5
+ cooperative cancellation (process-group kill), env/secret injection, working-dir
6
+ gating, and an optional **PTY** mode — so each concrete adapter only declares its
7
+ argv and how to parse the CLI's output into ``NormalizedEvent``s.
8
+
9
+ Why PTY: the Antigravity CLI (``agy``) suppresses stdout when it detects a non-TTY
10
+ (pipe/subprocess) — verified empirically: a piped run emits 0 bytes and hangs.
11
+ Allocating a pseudo-terminal is the supported workaround. ``claude`` streams fine
12
+ on a plain pipe, so it leaves PTY off.
13
+
14
+ No token hijacking: these adapters invoke the official binaries with their own
15
+ supported auth. Any API key is injected into the child's environment from a
16
+ ``secret_ref`` and is never persisted.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import contextlib
23
+ import itertools
24
+ import os
25
+ import signal
26
+ import sys
27
+ from abc import abstractmethod
28
+ from collections.abc import AsyncIterator, Iterable
29
+
30
+ from harness.observability.logging import get_logger
31
+ from harness_sdk.base import (
32
+ BaseAdapter,
33
+ Capabilities,
34
+ HealthStatus,
35
+ ProviderSession,
36
+ SessionContext,
37
+ )
38
+ from harness_sdk.events import (
39
+ ErrorEvent,
40
+ MessageEvent,
41
+ NormalizedEvent,
42
+ StatusEvent,
43
+ )
44
+
45
+ log = get_logger("cli-adapter")
46
+
47
+ _IS_WINDOWS = sys.platform == "win32"
48
+
49
+ # Per-run parser scratch space carried across chunks (e.g. accumulating text).
50
+ ParserState = dict
51
+
52
+
53
+ def _extra_bin_dirs() -> list[str]:
54
+ """Common locations agent CLIs get installed to but which are often missing from
55
+ a GUI-app's inherited PATH (Finder/launchd on macOS, a bare service env on Linux).
56
+ Returned in priority order, expanded, deduped — existence is checked by the caller.
57
+
58
+ Covers user-local installs (``~/.local/bin``, ``~/bin``), Homebrew (Intel + Apple
59
+ Silicon), npm global prefixes (``~/.npm-global``, the standard system prefixes), the
60
+ official Claude installer (``~/.claude/local``), and Windows npm/Programs dirs."""
61
+ home = os.path.expanduser("~")
62
+ if _IS_WINDOWS:
63
+ appdata = os.environ.get("APPDATA", "")
64
+ localappdata = os.environ.get("LOCALAPPDATA", "")
65
+ dirs = [
66
+ os.path.join(appdata, "npm") if appdata else "",
67
+ os.path.join(localappdata, "Programs") if localappdata else "",
68
+ os.path.join(home, "AppData", "Roaming", "npm"),
69
+ ]
70
+ else:
71
+ dirs = [
72
+ os.path.join(home, ".local", "bin"),
73
+ os.path.join(home, "bin"),
74
+ "/opt/homebrew/bin", # Apple Silicon Homebrew
75
+ "/usr/local/bin", # Intel Homebrew / common local installs
76
+ os.path.join(home, ".npm-global", "bin"),
77
+ "/usr/local/lib/node_modules/.bin",
78
+ os.path.join(home, ".claude", "local"), # official Claude installer
79
+ "/usr/bin",
80
+ "/bin",
81
+ ]
82
+ return [d for d in dirs if d]
83
+
84
+
85
+ def _augment_path(base_path: str) -> str:
86
+ """Append common install dirs to ``base_path`` (deduped, existing only) so a
87
+ minimal inherited PATH can still find the CLI and its own subprocesses. Appended,
88
+ never prepended, so the user's own PATH entries always win. Sync (filesystem
89
+ checks) so it stays off the async hot path."""
90
+ current = base_path.split(os.pathsep) if base_path else []
91
+ extra = [d for d in _extra_bin_dirs() if os.path.isdir(d) and d not in current]
92
+ if not extra:
93
+ return base_path
94
+ return os.pathsep.join([*current, *extra])
95
+
96
+
97
+ def resolve_binary(name: str) -> str:
98
+ """Resolve a CLI name to a launchable path. Prefer PATH (``shutil.which``); when it
99
+ isn't on PATH, fall back to scanning common install dirs (see ``_extra_bin_dirs``).
100
+ Returns ``name`` unchanged when nothing is found, so a genuinely-missing binary
101
+ still surfaces a clear "not found" error rather than a fabricated path.
102
+
103
+ An absolute/relative path that already points at an existing file is returned as-is.
104
+ Shared by argv resolution, probing, and the doctor so all three agree."""
105
+ import shutil
106
+
107
+ # Already a usable path (absolute or contains a separator) — trust it if it exists.
108
+ if os.path.isfile(name) and os.access(name, os.X_OK):
109
+ return name
110
+ found = shutil.which(name)
111
+ if found:
112
+ return found
113
+ exts = ["", ".cmd", ".bat", ".exe"] if _IS_WINDOWS else [""]
114
+ for d in _extra_bin_dirs():
115
+ for ext in exts:
116
+ cand = os.path.join(d, name + ext)
117
+ if os.path.isfile(cand) and os.access(cand, os.X_OK):
118
+ return cand
119
+ return name
120
+
121
+
122
+ def _resolve_argv(argv: list[str]) -> list[str]:
123
+ """Resolve argv[0] to a launchable executable, cross-platform.
124
+
125
+ ``asyncio.create_subprocess_exec`` maps to ``CreateProcess`` on Windows, which
126
+ only finds ``.exe`` on PATH — it does NOT honor ``PATHEXT`` the way a shell does,
127
+ and it cannot execute ``.cmd``/``.bat`` shims at all. The ``claude`` and ``agy``
128
+ CLIs are typically installed by npm as ``claude.cmd`` / ``agy.cmd`` on Windows,
129
+ so a bare ``["claude", "--version"]`` raises ``FileNotFoundError`` even when the
130
+ CLI is installed and works in the terminal — surfacing as "binary not found".
131
+
132
+ We resolve the name with ``resolve_binary`` (PATH + common install dirs, honoring
133
+ ``PATHEXT`` via ``shutil.which``) and, for a batch shim, route it through
134
+ ``cmd.exe /c`` so it actually launches. On POSIX this substitutes the absolute path
135
+ (or leaves the name untouched when nothing is found, so a genuinely-missing binary
136
+ still reports as not found)."""
137
+ if not argv:
138
+ return argv
139
+ resolved = resolve_binary(argv[0])
140
+ if resolved == argv[0] and not os.path.isabs(resolved):
141
+ return argv # nothing found — keep bare name so a missing binary errors clearly
142
+ if _IS_WINDOWS and resolved.lower().endswith((".cmd", ".bat")):
143
+ comspec = os.environ.get("COMSPEC", "cmd.exe")
144
+ return [comspec, "/c", resolved, *argv[1:]]
145
+ return [resolved, *argv[1:]]
146
+
147
+
148
+ class _WinPtyHandle:
149
+ """Adapts a ``pywinpty`` ``PtyProcess`` to the same ``.pid``/``.returncode``/
150
+ ``.terminate()``/``.kill()``/``.wait()`` surface ``_reap``/``cancel_run`` use for
151
+ an ``asyncio.subprocess.Process`` — so those two methods don't need to know
152
+ which backend actually launched the run."""
153
+
154
+ def __init__(self, pty_process) -> None:
155
+ self._p = pty_process
156
+
157
+ @property
158
+ def pid(self) -> int:
159
+ return self._p.pid
160
+
161
+ @property
162
+ def returncode(self) -> int | None:
163
+ return None if self._p.isalive() else self._p.exitstatus
164
+
165
+ def terminate(self) -> None:
166
+ self._p.terminate(force=False)
167
+
168
+ def kill(self) -> None:
169
+ self._p.terminate(force=True)
170
+
171
+ async def wait(self) -> int | None:
172
+ while self._p.isalive():
173
+ await asyncio.sleep(0.05)
174
+ return self._p.exitstatus
175
+
176
+
177
+ class CliSubprocessAdapter(BaseAdapter):
178
+ """Base class for CLI-backed adapters. Concrete adapters implement
179
+ ``build_argv``, ``parse_chunk``, ``finalize``, ``env_overrides`` and
180
+ ``health_argv``; everything else is handled here."""
181
+
182
+ #: default PTY usage; subclasses/config may override via ``params.pty``
183
+ default_use_pty: bool = False
184
+ #: default seconds before the child is killed
185
+ default_timeout_s: float = 600.0
186
+
187
+ def __init__(self, config) -> None: # noqa: ANN001 - AdapterConfig
188
+ super().__init__(config)
189
+ self._procs: dict[str, asyncio.subprocess.Process] = {}
190
+ self._cancelled: set[str] = set()
191
+
192
+ # ── hooks for subclasses ──────────────────────────────────────────────────
193
+ @abstractmethod
194
+ def build_argv(self, *, run_id: str, ctx: SessionContext, message: str) -> list[str]:
195
+ """Full argv for a single non-interactive run."""
196
+
197
+ @abstractmethod
198
+ def parse_chunk(self, line: str, state: ParserState) -> Iterable[NormalizedEvent]:
199
+ """Map one raw output line to zero or more events (seq assigned by base)."""
200
+
201
+ def finalize(self, state: ParserState) -> Iterable[NormalizedEvent]:
202
+ """Emit any end-of-stream events (e.g. a final MessageEvent from
203
+ accumulated plain-text output). Default: nothing."""
204
+ return ()
205
+
206
+ def env_overrides(self) -> dict[str, str]:
207
+ """Extra environment for the child (e.g. injected API key)."""
208
+ return {}
209
+
210
+ def stdin_payload(self, message: str, ctx: SessionContext | None = None) -> bytes | None:
211
+ """Bytes to write to the child's stdin, or None to use /dev/null. Adapters
212
+ that pass the prompt via stdin (e.g. claude, to avoid variadic-flag
213
+ ambiguity) return the encoded message here and omit it from argv. ``ctx``
214
+ (when available) lets an adapter enrich the payload — e.g. image
215
+ attachment content blocks."""
216
+ return None
217
+
218
+ @abstractmethod
219
+ def health_argv(self) -> list[str]:
220
+ """argv for a lightweight health probe (e.g. ``[bin, "--version"]``)."""
221
+
222
+ @property
223
+ def binary(self) -> str:
224
+ return self.config.params.get("bin") or self.config.params.get("cmd") or self.config.name
225
+
226
+ @property
227
+ def use_pty(self) -> bool:
228
+ return bool(self.config.params.get("pty", self.default_use_pty))
229
+
230
+ @property
231
+ def timeout_s(self) -> float:
232
+ return float(self.config.params.get("timeout_s", self.default_timeout_s))
233
+
234
+ def working_dir(self, ctx: SessionContext) -> str | None:
235
+ """Workspace directory passed to the CLI. Prefers the per-run project folder
236
+ from the session context (set from the workspace's runtime settings), then a
237
+ configured override, then cwd. Gate writes via the engine's permission layer."""
238
+ return ctx.workspace_dir or self.config.params.get("workspace_dir") or os.getcwd()
239
+
240
+ def _launch_cwd(self, ctx: SessionContext) -> str | None:
241
+ """The directory to spawn the CLI subprocess in — the project folder, if it
242
+ exists. Sync (filesystem check) so it stays off the async hot path."""
243
+ d = self.working_dir(ctx)
244
+ return d if d and os.path.isdir(d) else None
245
+
246
+ def remote_host(self, ctx: SessionContext) -> str | None:
247
+ """The ssh host this session's project lives on (set by the engine from the
248
+ workspace's ``project_host``), or None for a local project."""
249
+ return (ctx.extra or {}).get("ssh_host") or None
250
+
251
+ def _wrap_remote_argv(self, argv: list[str], ctx: SessionContext) -> list[str]:
252
+ """Wrap ``argv`` to run on the remote host via the system ``ssh`` client
253
+ (honors ~/.ssh/config; BatchMode = key/agent auth only, never prompts):
254
+ ``ssh -T|-tt <host> "cd <dir> && exec <argv…>"``, everything shlex-quoted.
255
+
256
+ Cancellation: PTY adapters use ``-tt``, so killing the local ssh client
257
+ makes sshd SIGHUP the remote process group — clean. Pipe adapters (``-T``)
258
+ rely on the remote CLI dying on EPIPE at its next stdout write; a silent
259
+ remote process can linger (accepted MVP tradeoff)."""
260
+ import shlex
261
+
262
+ host = self.remote_host(ctx)
263
+ # Run the binary by its BARE NAME on the remote so the remote login shell
264
+ # resolves it via the REMOTE PATH. argv[0] holds the locally-configured binary
265
+ # (``params.bin``), which may be a local absolute path (e.g. a workaround for a
266
+ # local off-PATH install like ``/Users/me/.local/bin/agy``). Shipping that local
267
+ # path verbatim fails on the remote with ``exec: <path>: not found`` (exit 127);
268
+ # the basename does not. A caller that truly needs a remote absolute path can set
269
+ # a bare ``bin`` and rely on the remote PATH, which is the common case.
270
+ remote_argv = [os.path.basename(argv[0]), *argv[1:]] if argv else argv
271
+ cmd = " ".join(shlex.quote(a) for a in remote_argv)
272
+ if (d := self.working_dir(ctx)):
273
+ cmd = f"cd {shlex.quote(d)} && exec {cmd}"
274
+ # Login shell: a bare ssh command runs non-login, so ~/.profile PATH
275
+ # additions (~/.local/bin — where agent CLIs usually live) are missing.
276
+ cmd = f"sh -lc {shlex.quote(cmd)}"
277
+ return [
278
+ os.environ.get("HARNESS_SSH_BIN", "ssh"),
279
+ "-tt" if self.use_pty else "-T",
280
+ "-o", "BatchMode=yes",
281
+ host,
282
+ cmd,
283
+ ]
284
+
285
+ # ── lifecycle ─────────────────────────────────────────────────────────────
286
+ async def start_session(self, ctx: SessionContext) -> ProviderSession:
287
+ return ProviderSession(
288
+ provider_session_id=ctx.run_id,
289
+ adapter_kind=self.kind,
290
+ meta={
291
+ "binary": self.binary,
292
+ "session_id": ctx.session_id,
293
+ "workspace_slug": ctx.workspace_slug,
294
+ "system_prompt": ctx.system_prompt,
295
+ "memory_context": ctx.memory_context,
296
+ "workspace_dir": ctx.workspace_dir,
297
+ "read_only": ctx.read_only,
298
+ "extra": ctx.extra,
299
+ },
300
+ )
301
+
302
+ async def stream_events(
303
+ self, session: ProviderSession, message: str, *, tools=None, stream: bool = True
304
+ ) -> AsyncIterator[NormalizedEvent]:
305
+ run_id = session.provider_session_id
306
+ ctx = SessionContext(
307
+ run_id=run_id,
308
+ session_id=session.meta.get("session_id", run_id),
309
+ workspace_slug=session.meta.get("workspace_slug", "default"),
310
+ objective=message,
311
+ system_prompt=session.meta.get("system_prompt"),
312
+ memory_context=session.meta.get("memory_context", ""),
313
+ workspace_dir=session.meta.get("workspace_dir"),
314
+ read_only=session.meta.get("read_only", True),
315
+ extra=session.meta.get("extra", {}) or {},
316
+ )
317
+ argv = self.build_argv(run_id=run_id, ctx=ctx, message=message)
318
+ # Run the CLI *in* the project folder so the agent's cwd (where "current
319
+ # project" and relative paths resolve) is the one set via `project set` —
320
+ # --add-dir only grants access, it doesn't change the working directory.
321
+ # Remote projects: the cd happens on the remote host inside the ssh command,
322
+ # and the local isdir gate must not see the (remote-only) path.
323
+ if self.remote_host(ctx):
324
+ argv = self._wrap_remote_argv(argv, ctx)
325
+ cwd = None
326
+ else:
327
+ cwd = self._launch_cwd(ctx)
328
+ seq = itertools.count()
329
+ state: ParserState = {"text": []}
330
+
331
+ def _tag(ev: NormalizedEvent) -> NormalizedEvent:
332
+ ev.seq = next(seq)
333
+ return ev
334
+
335
+ yield _tag(StatusEvent(seq=0, status="running"))
336
+ try:
337
+ async for line in self._run_process(argv, run_id, message, state, cwd=cwd, ctx=ctx):
338
+ if run_id in self._cancelled:
339
+ yield _tag(StatusEvent(seq=0, status="cancelled"))
340
+ return
341
+ for ev in self.parse_chunk(line, state):
342
+ yield _tag(ev)
343
+ for ev in self.finalize(state):
344
+ yield _tag(ev)
345
+ except FileNotFoundError:
346
+ yield _tag(ErrorEvent(
347
+ seq=0, message=f"CLI binary not found: {argv[0]!r}", retriable=False
348
+ ))
349
+ return
350
+ except TimeoutError:
351
+ yield _tag(ErrorEvent(seq=0, message="CLI run timed out", retriable=True))
352
+ return
353
+ except Exception as exc: # contain any spawn/parse failure to this run
354
+ yield _tag(ErrorEvent(seq=0, message=f"{type(exc).__name__}: {exc}", retriable=True))
355
+ return
356
+
357
+ rc = state.get("returncode")
358
+ produced = bool(state.get("text")) or state.get("had_message")
359
+ stderr = (state.get("stderr") or "").strip()
360
+ if not produced:
361
+ # Nothing parseable — surface the failure cause (e.g. silent non-zero exit).
362
+ if rc not in (0, None):
363
+ msg = f"CLI exited with code {rc}" + (f": {stderr[-300:]}" if stderr else "")
364
+ yield _tag(ErrorEvent(seq=0, message=msg, retriable=rc in (124, 137)))
365
+ else:
366
+ yield _tag(ErrorEvent(
367
+ seq=0,
368
+ message="CLI produced no output" + (f": {stderr[-300:]}" if stderr else ""),
369
+ retriable=False,
370
+ ))
371
+ return
372
+ # Output was produced; any error was already emitted by the parser. Reflect
373
+ # the true terminal status (a non-zero exit means the run failed).
374
+ yield _tag(StatusEvent(seq=0, status="succeeded" if rc in (0, None) else "failed"))
375
+
376
+ async def _run_process(
377
+ self, argv: list[str], run_id: str, message: str, state: ParserState,
378
+ *, cwd: str | None = None, ctx: SessionContext | None = None,
379
+ ) -> AsyncIterator[str]:
380
+ env = {**os.environ, **self.env_overrides()}
381
+ # Ensure the CLI (and the subprocesses it spawns, e.g. node) can be found even
382
+ # when this process inherited a minimal PATH (macOS GUI launch, bare service env).
383
+ # Skipped for remote runs — argv is an ``ssh …`` invocation and the remote PATH
384
+ # is set by the remote login shell, not by our local env.
385
+ if not (ctx and self.remote_host(ctx)):
386
+ env["PATH"] = _augment_path(env.get("PATH", ""))
387
+ if self.use_pty:
388
+ async for line in self._run_pty(argv, run_id, env, state, cwd=cwd):
389
+ yield line
390
+ else:
391
+ async for line in self._run_pipe(argv, run_id, env, message, state,
392
+ cwd=cwd, ctx=ctx):
393
+ yield line
394
+
395
+ async def _run_pipe(self, argv, run_id, env, message, state, *, cwd=None,
396
+ ctx=None) -> AsyncIterator[str]:
397
+ payload = self.stdin_payload(message, ctx)
398
+ proc = await asyncio.create_subprocess_exec(
399
+ *_resolve_argv(argv),
400
+ stdout=asyncio.subprocess.PIPE,
401
+ stderr=asyncio.subprocess.PIPE,
402
+ stdin=asyncio.subprocess.PIPE if payload is not None else asyncio.subprocess.DEVNULL,
403
+ env=env,
404
+ cwd=cwd,
405
+ # own process group for clean cancellation — POSIX only; Windows has no
406
+ # process-group session concept here, see _reap/cancel_run below.
407
+ **({} if _IS_WINDOWS else {"start_new_session": True}),
408
+ )
409
+ self._procs[run_id] = proc
410
+ if payload is not None and proc.stdin is not None:
411
+ proc.stdin.write(payload)
412
+ await proc.stdin.drain()
413
+ proc.stdin.close()
414
+ try:
415
+ assert proc.stdout is not None
416
+ async with asyncio.timeout(self.timeout_s):
417
+ async for raw in proc.stdout:
418
+ yield raw.decode(errors="replace").rstrip("\r\n")
419
+ if proc.stderr is not None:
420
+ state["stderr"] = (await proc.stderr.read()).decode(errors="replace")
421
+ await proc.wait()
422
+ finally:
423
+ state["returncode"] = proc.returncode
424
+ await self._reap(proc, run_id)
425
+
426
+ async def _run_pty(self, argv, run_id, env, state, *, cwd=None) -> AsyncIterator[str]:
427
+ """Run with a pseudo-terminal so TTY-gated CLIs (agy) emit output."""
428
+ if _IS_WINDOWS:
429
+ # POSIX's `pty` module (and the `termios`/`tty` modules it depends on)
430
+ # doesn't exist on Windows at all — there's no `import pty` fallback.
431
+ # Windows' equivalent is the native ConPTY API, wrapped by `pywinpty`.
432
+ async for line in self._run_pty_windows(argv, run_id, env, state, cwd=cwd):
433
+ yield line
434
+ return
435
+
436
+ import pty
437
+
438
+ master_fd, slave_fd = pty.openpty()
439
+ proc = await asyncio.create_subprocess_exec(
440
+ *_resolve_argv(argv),
441
+ stdout=slave_fd,
442
+ stderr=slave_fd,
443
+ stdin=slave_fd,
444
+ env=env,
445
+ cwd=cwd,
446
+ start_new_session=True,
447
+ )
448
+ os.close(slave_fd)
449
+ self._procs[run_id] = proc
450
+
451
+ loop = asyncio.get_running_loop()
452
+ reader = asyncio.StreamReader()
453
+ protocol = asyncio.StreamReaderProtocol(reader)
454
+ master = os.fdopen(master_fd, "rb", buffering=0)
455
+ transport, _ = await loop.connect_read_pipe(lambda: protocol, master)
456
+ try:
457
+ async with asyncio.timeout(self.timeout_s):
458
+ while True:
459
+ try:
460
+ raw = await reader.readline()
461
+ except OSError: # PTY master raises EIO at child exit -> EOF
462
+ break
463
+ if not raw:
464
+ break
465
+ yield raw.decode(errors="replace").rstrip("\r\n")
466
+ await proc.wait()
467
+ finally:
468
+ transport.close()
469
+ with contextlib.suppress(Exception):
470
+ master.close()
471
+ state["returncode"] = proc.returncode
472
+ await self._reap(proc, run_id)
473
+
474
+ async def _run_pty_windows(self, argv, run_id, env, state, *, cwd=None) -> AsyncIterator[str]:
475
+ """Windows equivalent of ``_run_pty`` using ConPTY (via ``pywinpty``), since
476
+ POSIX's ``pty``/``termios``/``tty`` modules don't exist there at all."""
477
+ try:
478
+ from winpty import PtyProcess
479
+ except ImportError as exc:
480
+ raise RuntimeError(
481
+ "agy needs a console to produce output on Windows, which requires the "
482
+ "'pywinpty' package — it isn't installed in this build."
483
+ ) from exc
484
+
485
+ loop = asyncio.get_running_loop()
486
+ proc = await loop.run_in_executor(
487
+ None, lambda: PtyProcess.spawn(_resolve_argv(argv), cwd=cwd, env=env)
488
+ )
489
+ handle = _WinPtyHandle(proc)
490
+ self._procs[run_id] = handle
491
+ buf = b""
492
+ try:
493
+ async with asyncio.timeout(self.timeout_s):
494
+ while True:
495
+ try:
496
+ chunk = await loop.run_in_executor(None, proc.read, 1024)
497
+ except EOFError: # ConPTY signals child exit this way
498
+ break
499
+ if not chunk:
500
+ break
501
+ buf += chunk if isinstance(chunk, (bytes, bytearray)) else chunk.encode()
502
+ while b"\n" in buf:
503
+ line, buf = buf.split(b"\n", 1)
504
+ yield line.decode(errors="replace").rstrip("\r")
505
+ if buf:
506
+ yield buf.decode(errors="replace").rstrip("\r")
507
+ await handle.wait()
508
+ finally:
509
+ state["returncode"] = handle.returncode
510
+ await self._reap(handle, run_id)
511
+
512
+ async def _reap(self, proc, run_id: str) -> None:
513
+ if proc.returncode is None:
514
+ if _IS_WINDOWS:
515
+ with contextlib.suppress(Exception):
516
+ proc.terminate()
517
+ with contextlib.suppress(Exception):
518
+ await asyncio.wait_for(proc.wait(), timeout=5)
519
+ if proc.returncode is None:
520
+ with contextlib.suppress(Exception):
521
+ proc.kill()
522
+ else:
523
+ with contextlib.suppress(ProcessLookupError):
524
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
525
+ with contextlib.suppress(Exception):
526
+ await asyncio.wait_for(proc.wait(), timeout=5)
527
+ if proc.returncode is None:
528
+ with contextlib.suppress(ProcessLookupError):
529
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
530
+ self._procs.pop(run_id, None)
531
+
532
+ async def cancel_run(self, run_id: str) -> None:
533
+ self._cancelled.add(run_id)
534
+ proc = self._procs.get(run_id)
535
+ if proc is not None and proc.returncode is None:
536
+ if _IS_WINDOWS:
537
+ with contextlib.suppress(Exception):
538
+ proc.terminate()
539
+ else:
540
+ with contextlib.suppress(ProcessLookupError):
541
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
542
+
543
+ # ── introspection ─────────────────────────────────────────────────────────
544
+ @abstractmethod
545
+ def list_capabilities(self) -> Capabilities: ...
546
+
547
+ async def health_check(self) -> HealthStatus:
548
+ import time
549
+
550
+ start = time.perf_counter()
551
+ try:
552
+ proc = await asyncio.create_subprocess_exec(
553
+ *_resolve_argv(self.health_argv()),
554
+ stdout=asyncio.subprocess.PIPE,
555
+ stderr=asyncio.subprocess.PIPE,
556
+ stdin=asyncio.subprocess.DEVNULL,
557
+ env={**os.environ, **self.env_overrides()},
558
+ )
559
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=20)
560
+ except FileNotFoundError:
561
+ return HealthStatus(ok=False, detail={"error": f"binary not found: {self.binary!r}"})
562
+ except (TimeoutError, Exception) as exc: # noqa: BLE001
563
+ return HealthStatus(ok=False, detail={"error": str(exc)})
564
+ detail: dict[str, object] = {
565
+ "version": out.decode(errors="replace").strip()[:120],
566
+ "returncode": proc.returncode,
567
+ }
568
+ # On failure, surface the process's stderr — otherwise a nonzero exit shows an
569
+ # empty version with no clue why (e.g. the `claude` CLI exiting on a deleted cwd
570
+ # writes its ENOENT to stderr). Only on failure, so success stays quiet.
571
+ if proc.returncode != 0 and (stderr := err.decode(errors="replace").strip()):
572
+ detail["stderr"] = stderr[:500]
573
+ return HealthStatus(
574
+ ok=proc.returncode == 0,
575
+ latency_ms=(time.perf_counter() - start) * 1000,
576
+ detail=detail,
577
+ )
578
+
579
+ @staticmethod
580
+ def _final_text_message(state: ParserState) -> Iterable[NormalizedEvent]:
581
+ text = "".join(state.get("text", [])).strip()
582
+ if text:
583
+ state["had_message"] = True
584
+ yield MessageEvent(seq=0, role="assistant", text=text)
@@ -0,0 +1,94 @@
1
+ """Normalized, vendor-neutral event model.
2
+
3
+ Every adapter maps its provider-specific stream onto this discriminated union so
4
+ the orchestrator and the ``run_events`` ledger stay provider-agnostic. The
5
+ ``type`` discriminator matches ``harness.models.enums.RunEventType``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated, Literal
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ class _BaseEvent(BaseModel):
16
+ seq: int = Field(description="Monotonic per-run sequence number.")
17
+
18
+
19
+ class TokenEvent(_BaseEvent):
20
+ type: Literal["token"] = "token"
21
+ text: str
22
+ # True for a reasoning/"thinking" delta (models that stream chain-of-thought
23
+ # separately from the answer). Surfaced live but kept out of the final answer.
24
+ reasoning: bool = False
25
+
26
+
27
+ class MessageEvent(_BaseEvent):
28
+ type: Literal["message"] = "message"
29
+ role: Literal["assistant", "user", "system", "tool"] = "assistant"
30
+ text: str
31
+ # User-message attachments (GUI uploads): [{id, name, media_type, size, image}].
32
+ # Carried on the event so the transcript can re-render chips/thumbnails.
33
+ attachments: list[dict] = Field(default_factory=list)
34
+
35
+
36
+ class ToolCallEvent(_BaseEvent):
37
+ type: Literal["tool_call"] = "tool_call"
38
+ name: str
39
+ arguments: dict = Field(default_factory=dict)
40
+ call_id: str | None = None
41
+
42
+
43
+ class ToolResultEvent(_BaseEvent):
44
+ type: Literal["tool_result"] = "tool_result"
45
+ call_id: str | None = None
46
+ output: str = ""
47
+ is_error: bool = False
48
+
49
+
50
+ class ArtifactEvent(_BaseEvent):
51
+ type: Literal["artifact"] = "artifact"
52
+ kind: Literal["file", "patch", "plan", "doc"] = "doc"
53
+ uri: str
54
+ sha256: str | None = None
55
+ meta: dict = Field(default_factory=dict)
56
+
57
+
58
+ class StatusEvent(_BaseEvent):
59
+ type: Literal["status"] = "status"
60
+ status: str
61
+ detail: str | None = None
62
+
63
+
64
+ class CostEvent(_BaseEvent):
65
+ type: Literal["cost"] = "cost"
66
+ prompt_tokens: int = 0
67
+ completion_tokens: int = 0
68
+ total_tokens: int = 0
69
+ usd: float | None = None
70
+
71
+
72
+ class AdapterMetaEvent(_BaseEvent):
73
+ type: Literal["adapter_meta"] = "adapter_meta"
74
+ data: dict = Field(default_factory=dict)
75
+
76
+
77
+ class ErrorEvent(_BaseEvent):
78
+ type: Literal["error"] = "error"
79
+ message: str
80
+ retriable: bool = False
81
+
82
+
83
+ NormalizedEvent = Annotated[
84
+ TokenEvent
85
+ | MessageEvent
86
+ | ToolCallEvent
87
+ | ToolResultEvent
88
+ | ArtifactEvent
89
+ | StatusEvent
90
+ | CostEvent
91
+ | AdapterMetaEvent
92
+ | ErrorEvent,
93
+ Field(discriminator="type"),
94
+ ]
@@ -0,0 +1,5 @@
1
+ """Types shared across the SDK."""
2
+
3
+ # Instead of an enum, we use a simple string type alias in the SDK
4
+ # so third-parties can define custom adapter names easily.
5
+ AdapterKind = str