ags-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- harness/workers/inprocess.py +135 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""OpenAI-compatible adapter — local **or** remote.
|
|
2
|
+
|
|
3
|
+
Targets any ``/v1/chat/completions`` endpoint, whether self-hosted (Ollama, LM Studio,
|
|
4
|
+
vLLM, LocalAI) or a remote SaaS (OpenAI, Groq, OpenRouter, Together, Azure OpenAI). Every
|
|
5
|
+
endpoint quirk is driven from ``config.params`` so a new provider needs only a
|
|
6
|
+
``config.yaml`` entry — no code:
|
|
7
|
+
|
|
8
|
+
- ``headers`` static extra headers (e.g. ``OpenAI-Organization``, OpenRouter ``X-Title``)
|
|
9
|
+
- ``auth_header`` / ``auth_prefix`` where/how the resolved secret is sent
|
|
10
|
+
(default ``Authorization`` + ``"Bearer "``; Azure → ``api-key`` + ``""``)
|
|
11
|
+
- ``query`` extra query params on the request URL (e.g. Azure ``api-version``)
|
|
12
|
+
- ``extra_body`` arbitrary request-body params (``top_p``, ``stop``, ``seed``, …)
|
|
13
|
+
- ``timeout_s`` read timeout override
|
|
14
|
+
- ``temperature``/``max_tokens``/``max_context``/``reasoning`` common knobs
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import itertools
|
|
20
|
+
import json
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import AsyncIterator
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from harness_sdk.base import (
|
|
26
|
+
AdapterConfig,
|
|
27
|
+
BaseAdapter,
|
|
28
|
+
Capabilities,
|
|
29
|
+
Cost,
|
|
30
|
+
HealthStatus,
|
|
31
|
+
ProviderSession,
|
|
32
|
+
SessionContext,
|
|
33
|
+
Usage,
|
|
34
|
+
)
|
|
35
|
+
from harness_sdk.events import (
|
|
36
|
+
AdapterMetaEvent,
|
|
37
|
+
CostEvent,
|
|
38
|
+
ErrorEvent,
|
|
39
|
+
MessageEvent,
|
|
40
|
+
NormalizedEvent,
|
|
41
|
+
StatusEvent,
|
|
42
|
+
TokenEvent,
|
|
43
|
+
ToolCallEvent,
|
|
44
|
+
ToolResultEvent,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from harness.models.enums import AdapterKind
|
|
48
|
+
|
|
49
|
+
# read = max idle between bytes (so a non-responding endpoint fails in ~2 min, not 5+);
|
|
50
|
+
# a streaming model emits chunks well within this. Override per-provider via params.timeout_s.
|
|
51
|
+
DEFAULT_TIMEOUT = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
|
52
|
+
_MAX_TOOL_ROUNDS = 40 # cap the agentic loop so a misbehaving model can't spin forever
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OpenAICompatibleLocalAdapter(BaseAdapter):
|
|
56
|
+
kind = AdapterKind.openai_local
|
|
57
|
+
|
|
58
|
+
def __init__(self, config: AdapterConfig) -> None:
|
|
59
|
+
super().__init__(config)
|
|
60
|
+
self._base_url = (config.base_url or "http://localhost:11434/v1").rstrip("/")
|
|
61
|
+
self._cancelled: set[str] = set()
|
|
62
|
+
|
|
63
|
+
def _headers(self) -> dict[str, str]:
|
|
64
|
+
params = self.config.params
|
|
65
|
+
headers = {"Content-Type": "application/json"}
|
|
66
|
+
# Static custom headers (e.g. OpenAI-Organization, OpenRouter HTTP-Referer/X-Title).
|
|
67
|
+
headers.update(params.get("headers") or {})
|
|
68
|
+
# Resolve the secret into a configurable auth header. Defaults to OpenAI's
|
|
69
|
+
# ``Authorization: Bearer <key>``; Azure uses ``auth_header: api-key`` + empty prefix.
|
|
70
|
+
if self.config.api_key:
|
|
71
|
+
name = params.get("auth_header", "Authorization")
|
|
72
|
+
prefix = params.get("auth_prefix", "Bearer ")
|
|
73
|
+
headers[name] = f"{prefix}{self.config.api_key}"
|
|
74
|
+
return headers
|
|
75
|
+
|
|
76
|
+
def _timeout(self) -> httpx.Timeout:
|
|
77
|
+
"""Per-request timeout: ``params.timeout_s`` overrides only the read timeout."""
|
|
78
|
+
ts = self.config.params.get("timeout_s")
|
|
79
|
+
if ts is None:
|
|
80
|
+
return DEFAULT_TIMEOUT
|
|
81
|
+
return httpx.Timeout(connect=10.0, read=float(ts), write=30.0, pool=10.0)
|
|
82
|
+
|
|
83
|
+
def _build_messages(self, ctx_system: str | None, memory: str, message: str) -> list[dict]:
|
|
84
|
+
msgs: list[dict] = []
|
|
85
|
+
system_parts = [p for p in (ctx_system, memory) if p]
|
|
86
|
+
if system_parts:
|
|
87
|
+
msgs.append({"role": "system", "content": "\n\n".join(system_parts)})
|
|
88
|
+
msgs.append({"role": "user", "content": message})
|
|
89
|
+
return msgs
|
|
90
|
+
|
|
91
|
+
async def start_session(self, ctx: SessionContext) -> ProviderSession:
|
|
92
|
+
# OpenAI-compatible endpoints are stateless; we carry context per-message.
|
|
93
|
+
return ProviderSession(
|
|
94
|
+
provider_session_id=f"local-{ctx.run_id}",
|
|
95
|
+
adapter_kind=self.kind,
|
|
96
|
+
meta={
|
|
97
|
+
"system_prompt": ctx.system_prompt,
|
|
98
|
+
"memory_context": ctx.memory_context,
|
|
99
|
+
# Project context for the agentic file-tool loop (used only when the
|
|
100
|
+
# provider advertises tool_calling and a project dir is set).
|
|
101
|
+
"workspace_dir": ctx.workspace_dir,
|
|
102
|
+
"read_only": ctx.read_only,
|
|
103
|
+
"trust_policy": ctx.trust_policy,
|
|
104
|
+
# MCP servers offered to this run (resolved by the engine). The harness
|
|
105
|
+
# is the MCP client here (this endpoint has no native MCP support).
|
|
106
|
+
"mcp_servers": ctx.extra.get("mcp_servers", []),
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async 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
|
+
run_id = session.provider_session_id.removeprefix("local-")
|
|
119
|
+
seq = itertools.count()
|
|
120
|
+
messages = self._build_messages(
|
|
121
|
+
session.meta.get("system_prompt"),
|
|
122
|
+
session.meta.get("memory_context", ""),
|
|
123
|
+
message,
|
|
124
|
+
)
|
|
125
|
+
# Agentic tool loop: when the provider advertises tool_calling AND there is
|
|
126
|
+
# something to call — a project dir (file tools) and/or MCP servers. Otherwise
|
|
127
|
+
# behave as a plain chat endpoint.
|
|
128
|
+
use_tools = bool(
|
|
129
|
+
self.list_capabilities().tool_calling
|
|
130
|
+
and (session.meta.get("workspace_dir") or session.meta.get("mcp_servers"))
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
yield StatusEvent(seq=next(seq), status="running")
|
|
134
|
+
# A terminal status (needs_approval / cancelled) yielded mid-stream means the
|
|
135
|
+
# loop already ended for a specific reason — the unconditional "succeeded"
|
|
136
|
+
# below must not overwrite it.
|
|
137
|
+
already_terminal = False
|
|
138
|
+
try:
|
|
139
|
+
if use_tools:
|
|
140
|
+
async for ev in self._agentic_loop(messages, session, seq):
|
|
141
|
+
if ev.type == "status" and ev.status in ("needs_approval", "cancelled"):
|
|
142
|
+
already_terminal = True
|
|
143
|
+
yield ev
|
|
144
|
+
elif stream:
|
|
145
|
+
async for ev in self._stream(self._payload(messages, stream=True), run_id, seq):
|
|
146
|
+
if ev.type == "status" and ev.status == "cancelled":
|
|
147
|
+
already_terminal = True
|
|
148
|
+
yield ev
|
|
149
|
+
else:
|
|
150
|
+
async for ev in self._complete(self._payload(messages, stream=False), seq):
|
|
151
|
+
yield ev
|
|
152
|
+
except httpx.HTTPError as exc:
|
|
153
|
+
yield ErrorEvent(seq=next(seq), message=f"local llm error: {exc}", retriable=True)
|
|
154
|
+
return
|
|
155
|
+
if not already_terminal:
|
|
156
|
+
yield StatusEvent(seq=next(seq), status="succeeded")
|
|
157
|
+
|
|
158
|
+
def _payload(
|
|
159
|
+
self, messages: list[dict], *, stream: bool, tools: list[dict] | None = None
|
|
160
|
+
) -> dict:
|
|
161
|
+
"""Build a /chat/completions body, applying the configured knobs."""
|
|
162
|
+
p = self.config.params
|
|
163
|
+
payload: dict = {
|
|
164
|
+
"model": self.config.model or "llama3.1",
|
|
165
|
+
"messages": messages,
|
|
166
|
+
"stream": stream,
|
|
167
|
+
"temperature": p.get("temperature", 0.2),
|
|
168
|
+
}
|
|
169
|
+
if (mt := p.get("max_tokens")) is not None:
|
|
170
|
+
payload["max_tokens"] = mt
|
|
171
|
+
if (reasoning := p.get("reasoning")) is not None:
|
|
172
|
+
payload["reasoning"] = reasoning
|
|
173
|
+
if (extra := p.get("extra_body")):
|
|
174
|
+
payload.update(extra) # top_p, stop, seed, … (can override defaults above)
|
|
175
|
+
if stream and p.get("stream_usage", True):
|
|
176
|
+
payload["stream_options"] = {"include_usage": True}
|
|
177
|
+
if tools:
|
|
178
|
+
payload["tools"] = tools
|
|
179
|
+
return payload
|
|
180
|
+
|
|
181
|
+
async def _post_json(self, payload: dict) -> dict:
|
|
182
|
+
async with httpx.AsyncClient(timeout=self._timeout()) as client:
|
|
183
|
+
resp = await client.post(
|
|
184
|
+
f"{self._base_url}/chat/completions",
|
|
185
|
+
headers=self._headers(),
|
|
186
|
+
params=self.config.params.get("query"),
|
|
187
|
+
json=payload,
|
|
188
|
+
)
|
|
189
|
+
resp.raise_for_status()
|
|
190
|
+
return resp.json()
|
|
191
|
+
|
|
192
|
+
async def _agentic_loop(
|
|
193
|
+
self, messages: list[dict], session: ProviderSession, seq
|
|
194
|
+
) -> AsyncIterator[NormalizedEvent]:
|
|
195
|
+
"""Drive a tool-calling model over the project files and/or MCP servers,
|
|
196
|
+
executing each tool call in the harness and feeding results back, until the
|
|
197
|
+
model returns a final answer (or the round cap is hit). Streams text while
|
|
198
|
+
accumulating tool calls, then executes tools synchronously between rounds."""
|
|
199
|
+
from harness.mcp.registry import McpManager
|
|
200
|
+
from harness.models.enums import TrustPolicy
|
|
201
|
+
from harness.tools.fs_tools import FileTools, tool_specs
|
|
202
|
+
|
|
203
|
+
run_id = session.provider_session_id
|
|
204
|
+
workspace_dir = session.meta.get("workspace_dir")
|
|
205
|
+
fs = (
|
|
206
|
+
FileTools(
|
|
207
|
+
workspace_dir, session.meta.get("read_only", True),
|
|
208
|
+
trust_policy=TrustPolicy(session.meta.get("trust_policy", "restricted")),
|
|
209
|
+
)
|
|
210
|
+
if workspace_dir else None
|
|
211
|
+
)
|
|
212
|
+
specs = tool_specs(fs.read_only) if fs else []
|
|
213
|
+
|
|
214
|
+
# Connect the run's MCP servers (the harness is the MCP client here). Failures
|
|
215
|
+
# are soft: an unreachable server / missing SDK just contributes no tools.
|
|
216
|
+
mcp = McpManager(session.meta.get("mcp_servers") or [])
|
|
217
|
+
try:
|
|
218
|
+
await mcp.connect()
|
|
219
|
+
specs = specs + await mcp.specs()
|
|
220
|
+
except Exception as exc: # never sink the run on MCP setup
|
|
221
|
+
yield ErrorEvent(seq=next(seq), message=f"mcp setup: {exc}", retriable=False)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
async for ev in self._tool_rounds(messages, specs, fs, mcp, run_id, seq):
|
|
225
|
+
yield ev
|
|
226
|
+
finally:
|
|
227
|
+
await mcp.aclose()
|
|
228
|
+
|
|
229
|
+
async def _tool_rounds(
|
|
230
|
+
self, messages, specs, fs, mcp, run_id, seq
|
|
231
|
+
) -> AsyncIterator[NormalizedEvent]:
|
|
232
|
+
from harness.mcp.tools import is_mcp_tool
|
|
233
|
+
from harness.security.approval_policy import ApprovalRequired
|
|
234
|
+
|
|
235
|
+
executed_tools: list[tuple[str, bool]] = [] # (name, is_error), in call order
|
|
236
|
+
|
|
237
|
+
for _ in range(_MAX_TOOL_ROUNDS):
|
|
238
|
+
payload = self._payload(messages, stream=True, tools=specs)
|
|
239
|
+
collected_text: list[str] = []
|
|
240
|
+
tool_calls: dict[int, dict] = {}
|
|
241
|
+
usage: dict | None = None
|
|
242
|
+
|
|
243
|
+
async with httpx.AsyncClient(timeout=self._timeout()) as client, client.stream(
|
|
244
|
+
"POST",
|
|
245
|
+
f"{self._base_url}/chat/completions",
|
|
246
|
+
headers=self._headers(),
|
|
247
|
+
params=self.config.params.get("query"),
|
|
248
|
+
json=payload,
|
|
249
|
+
) as resp:
|
|
250
|
+
resp.raise_for_status()
|
|
251
|
+
async for line in resp.aiter_lines():
|
|
252
|
+
if run_id in self._cancelled:
|
|
253
|
+
yield StatusEvent(seq=next(seq), status="cancelled")
|
|
254
|
+
return
|
|
255
|
+
if not line or not line.startswith("data:"):
|
|
256
|
+
continue
|
|
257
|
+
data = line[len("data:"):].strip()
|
|
258
|
+
if data == "[DONE]":
|
|
259
|
+
break
|
|
260
|
+
try:
|
|
261
|
+
chunk = json.loads(data)
|
|
262
|
+
except json.JSONDecodeError:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
if (u := chunk.get("usage")):
|
|
266
|
+
usage = u
|
|
267
|
+
|
|
268
|
+
delta = (chunk.get("choices") or [{}])[0].get("delta", {})
|
|
269
|
+
|
|
270
|
+
# Reasoning models stream their chain-of-thought separately
|
|
271
|
+
# (delta.reasoning / reasoning_content). Surface it live so the
|
|
272
|
+
# GUI shows activity, but keep it out of the stored answer.
|
|
273
|
+
if (rsn := delta.get("reasoning") or delta.get("reasoning_content")):
|
|
274
|
+
yield TokenEvent(seq=next(seq), text=rsn, reasoning=True)
|
|
275
|
+
|
|
276
|
+
if (content := delta.get("content")):
|
|
277
|
+
collected_text.append(content)
|
|
278
|
+
yield TokenEvent(seq=next(seq), text=content)
|
|
279
|
+
|
|
280
|
+
# Accumulate streamed tool calls
|
|
281
|
+
if (tcs := delta.get("tool_calls")):
|
|
282
|
+
for tc in tcs:
|
|
283
|
+
idx = tc.get("index", 0)
|
|
284
|
+
if idx not in tool_calls:
|
|
285
|
+
tool_calls[idx] = {"id": tc.get("id"), "type": "function", "function": {"name": "", "arguments": ""}}
|
|
286
|
+
fn = tc.get("function", {})
|
|
287
|
+
if (name := fn.get("name")):
|
|
288
|
+
tool_calls[idx]["function"]["name"] += name
|
|
289
|
+
if (args := fn.get("arguments")):
|
|
290
|
+
tool_calls[idx]["function"]["arguments"] += args
|
|
291
|
+
|
|
292
|
+
final_text = "".join(collected_text)
|
|
293
|
+
final_calls = list(tool_calls.values())
|
|
294
|
+
|
|
295
|
+
# Emit final message if there was text
|
|
296
|
+
if final_text:
|
|
297
|
+
yield MessageEvent(seq=next(seq), role="assistant", text=final_text)
|
|
298
|
+
if usage:
|
|
299
|
+
yield CostEvent(
|
|
300
|
+
seq=next(seq),
|
|
301
|
+
prompt_tokens=usage.get("prompt_tokens", 0),
|
|
302
|
+
completion_tokens=usage.get("completion_tokens", 0),
|
|
303
|
+
total_tokens=usage.get("total_tokens", 0),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if not final_calls:
|
|
307
|
+
# No tool calls requested; we are done. Small local models often
|
|
308
|
+
# execute a tool successfully and then simply stop instead of
|
|
309
|
+
# narrating the result — without this, the run ends "succeeded"
|
|
310
|
+
# with an empty transcript, which looks indistinguishable from a
|
|
311
|
+
# hang. Leave a trace of what actually happened instead.
|
|
312
|
+
if not final_text and executed_tools:
|
|
313
|
+
yield MessageEvent(
|
|
314
|
+
seq=next(seq), role="assistant",
|
|
315
|
+
text=_default_completion_message(executed_tools),
|
|
316
|
+
)
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
# Echo the assistant's turn back to messages
|
|
320
|
+
assistant_msg: dict = {"role": "assistant"}
|
|
321
|
+
if final_text:
|
|
322
|
+
assistant_msg["content"] = final_text
|
|
323
|
+
if final_calls:
|
|
324
|
+
assistant_msg["tool_calls"] = final_calls
|
|
325
|
+
messages.append(assistant_msg)
|
|
326
|
+
|
|
327
|
+
# Execute each tool and append results
|
|
328
|
+
for tc in final_calls:
|
|
329
|
+
fn = tc.get("function", {})
|
|
330
|
+
name = fn.get("name", "")
|
|
331
|
+
args_raw = fn.get("arguments", "{}")
|
|
332
|
+
args = _safe_json(args_raw)
|
|
333
|
+
|
|
334
|
+
yield ToolCallEvent(seq=next(seq), name=name, arguments=args, call_id=tc.get("id"))
|
|
335
|
+
if is_mcp_tool(name):
|
|
336
|
+
output, is_error = await mcp.dispatch(name, args)
|
|
337
|
+
elif fs is not None:
|
|
338
|
+
try:
|
|
339
|
+
output, is_error = fs.execute(name, args)
|
|
340
|
+
except ApprovalRequired as exc:
|
|
341
|
+
reason = exc.decision.reason
|
|
342
|
+
yield ToolResultEvent(
|
|
343
|
+
seq=next(seq), call_id=tc.get("id"),
|
|
344
|
+
output=f"paused: {reason}", is_error=False,
|
|
345
|
+
)
|
|
346
|
+
yield AdapterMetaEvent(seq=next(seq), data={"approval_request": {
|
|
347
|
+
"tool_name": exc.tool_name,
|
|
348
|
+
"arguments": exc.tool_args,
|
|
349
|
+
"action_class": (
|
|
350
|
+
exc.decision.action_class.value
|
|
351
|
+
if exc.decision.action_class else None
|
|
352
|
+
),
|
|
353
|
+
"reason": reason,
|
|
354
|
+
"workspace_dir": str(fs.root),
|
|
355
|
+
"provider": self.config.name,
|
|
356
|
+
}})
|
|
357
|
+
yield StatusEvent(seq=next(seq), status="needs_approval", detail=reason)
|
|
358
|
+
return
|
|
359
|
+
else:
|
|
360
|
+
output, is_error = f"error: unknown tool {name!r}", True
|
|
361
|
+
yield ToolResultEvent(
|
|
362
|
+
seq=next(seq), call_id=tc.get("id"), output=output, is_error=is_error
|
|
363
|
+
)
|
|
364
|
+
executed_tools.append((name, is_error))
|
|
365
|
+
|
|
366
|
+
messages.append(
|
|
367
|
+
{"role": "tool", "tool_call_id": tc.get("id"), "content": output}
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
yield MessageEvent(
|
|
371
|
+
seq=next(seq), role="assistant",
|
|
372
|
+
text=f"(stopped after {_MAX_TOOL_ROUNDS} tool rounds without a final answer)",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
async def _stream(self, payload, run_id, seq) -> AsyncIterator[NormalizedEvent]:
|
|
376
|
+
collected: list[str] = []
|
|
377
|
+
usage: dict | None = None
|
|
378
|
+
async with httpx.AsyncClient(timeout=self._timeout()) as client, client.stream(
|
|
379
|
+
"POST",
|
|
380
|
+
f"{self._base_url}/chat/completions",
|
|
381
|
+
headers=self._headers(),
|
|
382
|
+
params=self.config.params.get("query"),
|
|
383
|
+
json=payload,
|
|
384
|
+
) as resp:
|
|
385
|
+
resp.raise_for_status()
|
|
386
|
+
async for line in resp.aiter_lines():
|
|
387
|
+
if run_id in self._cancelled:
|
|
388
|
+
yield StatusEvent(seq=next(seq), status="cancelled")
|
|
389
|
+
return
|
|
390
|
+
if not line or not line.startswith("data:"):
|
|
391
|
+
continue
|
|
392
|
+
data = line[len("data:"):].strip()
|
|
393
|
+
if data == "[DONE]":
|
|
394
|
+
break
|
|
395
|
+
chunk = json.loads(data)
|
|
396
|
+
if (u := chunk.get("usage")):
|
|
397
|
+
usage = u # final usage chunk (typically carries empty choices)
|
|
398
|
+
delta = (chunk.get("choices") or [{}])[0].get("delta", {})
|
|
399
|
+
# Reasoning ("thinking") deltas: stream live but exclude from the answer.
|
|
400
|
+
if (rsn := delta.get("reasoning") or delta.get("reasoning_content")):
|
|
401
|
+
yield TokenEvent(seq=next(seq), text=rsn, reasoning=True)
|
|
402
|
+
if (content := delta.get("content")):
|
|
403
|
+
collected.append(content)
|
|
404
|
+
yield TokenEvent(seq=next(seq), text=content)
|
|
405
|
+
for tc in delta.get("tool_calls", []) or []:
|
|
406
|
+
fn = tc.get("function", {})
|
|
407
|
+
yield ToolCallEvent(
|
|
408
|
+
seq=next(seq),
|
|
409
|
+
name=fn.get("name", ""),
|
|
410
|
+
arguments=_safe_json(fn.get("arguments", "{}")),
|
|
411
|
+
call_id=tc.get("id"),
|
|
412
|
+
)
|
|
413
|
+
if collected:
|
|
414
|
+
yield MessageEvent(seq=next(seq), role="assistant", text="".join(collected))
|
|
415
|
+
if usage:
|
|
416
|
+
yield CostEvent(
|
|
417
|
+
seq=next(seq),
|
|
418
|
+
prompt_tokens=usage.get("prompt_tokens", 0),
|
|
419
|
+
completion_tokens=usage.get("completion_tokens", 0),
|
|
420
|
+
total_tokens=usage.get("total_tokens", 0),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
async def _complete(self, payload, seq) -> AsyncIterator[NormalizedEvent]:
|
|
424
|
+
async with httpx.AsyncClient(timeout=self._timeout()) as client:
|
|
425
|
+
resp = await client.post(
|
|
426
|
+
f"{self._base_url}/chat/completions",
|
|
427
|
+
headers=self._headers(),
|
|
428
|
+
params=self.config.params.get("query"),
|
|
429
|
+
json=payload,
|
|
430
|
+
)
|
|
431
|
+
resp.raise_for_status()
|
|
432
|
+
body = resp.json()
|
|
433
|
+
choice = (body.get("choices") or [{}])[0]
|
|
434
|
+
text = choice.get("message", {}).get("content", "") or ""
|
|
435
|
+
yield MessageEvent(seq=next(seq), role="assistant", text=text)
|
|
436
|
+
if (usage := body.get("usage")):
|
|
437
|
+
yield CostEvent(
|
|
438
|
+
seq=next(seq),
|
|
439
|
+
prompt_tokens=usage.get("prompt_tokens", 0),
|
|
440
|
+
completion_tokens=usage.get("completion_tokens", 0),
|
|
441
|
+
total_tokens=usage.get("total_tokens", 0),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
async def cancel_run(self, run_id: str) -> None:
|
|
445
|
+
self._cancelled.add(run_id)
|
|
446
|
+
|
|
447
|
+
def list_capabilities(self) -> Capabilities:
|
|
448
|
+
caps = self.config.capabilities
|
|
449
|
+
return Capabilities(
|
|
450
|
+
streaming=caps.get("streaming", True),
|
|
451
|
+
tool_calling=caps.get("tool_calling", False),
|
|
452
|
+
max_context=int(self.config.params.get("max_context", 8192)),
|
|
453
|
+
reasoning=caps.get("reasoning", False),
|
|
454
|
+
cost_known=False,
|
|
455
|
+
notes="OpenAI-compatible endpoint (local or remote).",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
async def _probe_url(self, url: str) -> tuple[bool, float, dict]:
|
|
459
|
+
start = time.perf_counter()
|
|
460
|
+
try:
|
|
461
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(2.0)) as client:
|
|
462
|
+
resp = await client.get(f"{url}/models", headers=self._headers())
|
|
463
|
+
if resp.status_code < 500:
|
|
464
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
465
|
+
return True, elapsed_ms, {"status_code": resp.status_code}
|
|
466
|
+
except httpx.HTTPError:
|
|
467
|
+
pass
|
|
468
|
+
return False, 0.0, {}
|
|
469
|
+
|
|
470
|
+
async def health_check(self) -> HealthStatus:
|
|
471
|
+
ok, latency, detail = await self._probe_url(self._base_url)
|
|
472
|
+
if ok:
|
|
473
|
+
return HealthStatus(ok=True, latency_ms=latency, detail=detail)
|
|
474
|
+
|
|
475
|
+
# Fallback detection
|
|
476
|
+
import urllib.parse
|
|
477
|
+
try:
|
|
478
|
+
parsed = urllib.parse.urlparse(self._base_url)
|
|
479
|
+
if parsed.hostname in ("localhost", "127.0.0.1"):
|
|
480
|
+
common_ports = [11434, 1234, 8000, 8080]
|
|
481
|
+
for port in common_ports:
|
|
482
|
+
fallback_url = f"{parsed.scheme}://{parsed.hostname}:{port}/v1"
|
|
483
|
+
if fallback_url == self._base_url:
|
|
484
|
+
continue
|
|
485
|
+
ok, latency, detail = await self._probe_url(fallback_url)
|
|
486
|
+
if ok:
|
|
487
|
+
self._base_url = fallback_url
|
|
488
|
+
detail["auto_detected_url"] = fallback_url
|
|
489
|
+
return HealthStatus(ok=True, latency_ms=latency, detail=detail)
|
|
490
|
+
except Exception as exc:
|
|
491
|
+
return HealthStatus(ok=False, detail={"error": str(exc)})
|
|
492
|
+
|
|
493
|
+
return HealthStatus(ok=False, detail={"error": "endpoint unreachable"})
|
|
494
|
+
|
|
495
|
+
async def list_models(self) -> list[str]:
|
|
496
|
+
"""Enumerate models via the OpenAI-compatible ``GET /v1/models`` endpoint
|
|
497
|
+
(supported by Ollama, LM Studio, vLLM, LocalAI, …)."""
|
|
498
|
+
try:
|
|
499
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
|
|
500
|
+
resp = await client.get(f"{self._base_url}/models", headers=self._headers())
|
|
501
|
+
resp.raise_for_status()
|
|
502
|
+
data = resp.json().get("data", [])
|
|
503
|
+
except (httpx.HTTPError, ValueError):
|
|
504
|
+
return [self.config.model] if self.config.model else []
|
|
505
|
+
models = [m.get("id") for m in data if isinstance(m, dict) and m.get("id")]
|
|
506
|
+
return models or ([self.config.model] if self.config.model else [])
|
|
507
|
+
|
|
508
|
+
def estimate_cost(self, usage: Usage) -> Cost | None:
|
|
509
|
+
# Local inference is free at the API boundary; report tokens, usd=0.
|
|
510
|
+
return Cost(
|
|
511
|
+
prompt_tokens=usage.prompt_tokens,
|
|
512
|
+
completion_tokens=usage.completion_tokens,
|
|
513
|
+
total_tokens=usage.prompt_tokens + usage.completion_tokens,
|
|
514
|
+
usd=0.0,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _safe_json(raw: str) -> dict:
|
|
519
|
+
try:
|
|
520
|
+
return json.loads(raw) if raw else {}
|
|
521
|
+
except json.JSONDecodeError:
|
|
522
|
+
return {"_raw": raw}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _default_completion_message(executed: list[tuple[str, bool]]) -> str:
|
|
526
|
+
"""Synthesized narration for a tool round that ends with no assistant text."""
|
|
527
|
+
ok = list(dict.fromkeys(n for n, is_error in executed if not is_error))
|
|
528
|
+
failed = list(dict.fromkeys(n for n, is_error in executed if is_error))
|
|
529
|
+
parts = []
|
|
530
|
+
if ok:
|
|
531
|
+
parts.append("ran " + ", ".join(f"`{n}`" for n in ok))
|
|
532
|
+
if failed:
|
|
533
|
+
parts.append(", ".join(f"`{n}`" for n in failed) + " failed")
|
|
534
|
+
return ("Done — " + "; ".join(parts) + ".") if parts else "Done."
|
harness/adapters/mock.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Deterministic mock adapter — the reference implementation and CI workhorse.
|
|
2
|
+
|
|
3
|
+
Produces a predictable token stream, a cost event, and a final message without
|
|
4
|
+
any network access, so the orchestrator, ledger, memory, and contract tests can
|
|
5
|
+
run fully offline. Behaviour is tunable via ``params`` (``latency_ms``, ``tokens``,
|
|
6
|
+
``fail``)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import itertools
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
|
|
14
|
+
from harness_sdk.base import (
|
|
15
|
+
AdapterConfig,
|
|
16
|
+
BaseAdapter,
|
|
17
|
+
Capabilities,
|
|
18
|
+
Cost,
|
|
19
|
+
HealthStatus,
|
|
20
|
+
ProviderSession,
|
|
21
|
+
SessionContext,
|
|
22
|
+
Usage,
|
|
23
|
+
)
|
|
24
|
+
from harness_sdk.events import (
|
|
25
|
+
CostEvent,
|
|
26
|
+
ErrorEvent,
|
|
27
|
+
MessageEvent,
|
|
28
|
+
NormalizedEvent,
|
|
29
|
+
StatusEvent,
|
|
30
|
+
TokenEvent,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from harness.models.enums import AdapterKind
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MockAdapter(BaseAdapter):
|
|
37
|
+
kind = AdapterKind.mock
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: AdapterConfig) -> None:
|
|
40
|
+
super().__init__(config)
|
|
41
|
+
self._cancelled: set[str] = set()
|
|
42
|
+
|
|
43
|
+
async def start_session(self, ctx: SessionContext) -> ProviderSession:
|
|
44
|
+
return ProviderSession(
|
|
45
|
+
provider_session_id=f"mock-{ctx.run_id}",
|
|
46
|
+
adapter_kind=self.kind,
|
|
47
|
+
meta={"objective": ctx.objective},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def stream_events(
|
|
51
|
+
self,
|
|
52
|
+
session: ProviderSession,
|
|
53
|
+
message: str,
|
|
54
|
+
*,
|
|
55
|
+
tools: list[dict] | None = None,
|
|
56
|
+
stream: bool = True,
|
|
57
|
+
) -> AsyncIterator[NormalizedEvent]:
|
|
58
|
+
params = self.config.params
|
|
59
|
+
run_id = session.provider_session_id.removeprefix("mock-")
|
|
60
|
+
seq = itertools.count()
|
|
61
|
+
|
|
62
|
+
if params.get("fail"):
|
|
63
|
+
yield ErrorEvent(seq=next(seq), message="mock forced failure", retriable=False)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
yield StatusEvent(seq=next(seq), status="running")
|
|
67
|
+
|
|
68
|
+
n_tokens = int(params.get("tokens", 40))
|
|
69
|
+
latency = float(params.get("latency_ms", 50)) / 1000.0
|
|
70
|
+
words = (
|
|
71
|
+
f"[mock:{self.config.model or 'mock-1'}] response to: "
|
|
72
|
+
f"{message[:80]}"
|
|
73
|
+
).split()
|
|
74
|
+
emitted: list[str] = []
|
|
75
|
+
for i in range(min(n_tokens, max(len(words), 8))):
|
|
76
|
+
if run_id in self._cancelled:
|
|
77
|
+
yield StatusEvent(seq=next(seq), status="cancelled")
|
|
78
|
+
return
|
|
79
|
+
tok = (words[i] if i < len(words) else f"tok{i}") + " "
|
|
80
|
+
emitted.append(tok)
|
|
81
|
+
yield TokenEvent(seq=next(seq), text=tok)
|
|
82
|
+
await asyncio.sleep(latency / max(n_tokens, 1))
|
|
83
|
+
|
|
84
|
+
final = "".join(emitted).strip()
|
|
85
|
+
yield MessageEvent(seq=next(seq), role="assistant", text=final)
|
|
86
|
+
yield CostEvent(
|
|
87
|
+
seq=next(seq),
|
|
88
|
+
prompt_tokens=len(message.split()),
|
|
89
|
+
completion_tokens=len(emitted),
|
|
90
|
+
total_tokens=len(message.split()) + len(emitted),
|
|
91
|
+
)
|
|
92
|
+
yield StatusEvent(seq=next(seq), status="succeeded")
|
|
93
|
+
|
|
94
|
+
async def cancel_run(self, run_id: str) -> None:
|
|
95
|
+
self._cancelled.add(run_id)
|
|
96
|
+
|
|
97
|
+
def list_capabilities(self) -> Capabilities:
|
|
98
|
+
return Capabilities(
|
|
99
|
+
streaming=True, tool_calling=False, max_context=8192, cost_known=False,
|
|
100
|
+
notes="Deterministic offline mock.",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def health_check(self) -> HealthStatus:
|
|
104
|
+
return HealthStatus(ok=True, latency_ms=1.0, detail={"adapter": "mock"})
|
|
105
|
+
|
|
106
|
+
def estimate_cost(self, usage: Usage) -> Cost | None:
|
|
107
|
+
return Cost(
|
|
108
|
+
prompt_tokens=usage.prompt_tokens,
|
|
109
|
+
completion_tokens=usage.completion_tokens,
|
|
110
|
+
total_tokens=usage.prompt_tokens + usage.completion_tokens,
|
|
111
|
+
usd=0.0,
|
|
112
|
+
)
|