ags-sdk 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_sdk-0.1.0.dist-info/METADATA +7 -0
- ags_sdk-0.1.0.dist-info/RECORD +9 -0
- ags_sdk-0.1.0.dist-info/WHEEL +5 -0
- ags_sdk-0.1.0.dist-info/top_level.txt +1 -0
- harness_sdk/__init__.py +0 -0
- harness_sdk/base.py +158 -0
- harness_sdk/cli_base.py +584 -0
- harness_sdk/events.py +94 -0
- harness_sdk/types.py +5 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
harness_sdk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
harness_sdk/base.py,sha256=MVmsZYjvIQBa4A8vJtYS8wTUy_TAgHTDdp6V-Zz2PNI,5593
|
|
3
|
+
harness_sdk/cli_base.py,sha256=xedUZ4Xxi6pPC1NV2-AWUTfDV58TZDBWD39hQEewOX8,26525
|
|
4
|
+
harness_sdk/events.py,sha256=Lw51hXCTa13ZZLxgyU-A_vHedg5HJs7IT7_zFmsYDFk,2491
|
|
5
|
+
harness_sdk/types.py,sha256=BQP2LbLAkH7lQomqOSR4y_F525LiM8lURgoqNiLFIdU,181
|
|
6
|
+
ags_sdk-0.1.0.dist-info/METADATA,sha256=K7PkWFOUS5DarfMJ5WTKhKxnlEJl-tou6eEgkY7oPBg,211
|
|
7
|
+
ags_sdk-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
ags_sdk-0.1.0.dist-info/top_level.txt,sha256=Fmz5FUPlwHvRiywbi5Nqm7qQC3_Ziqj4-Mb0lYcbrRo,12
|
|
9
|
+
ags_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
harness_sdk
|
harness_sdk/__init__.py
ADDED
|
File without changes
|
harness_sdk/base.py
ADDED
|
@@ -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
|
harness_sdk/cli_base.py
ADDED
|
@@ -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)
|
harness_sdk/events.py
ADDED
|
@@ -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
|
+
]
|