ags-cli 0.1.0__py3-none-any.whl

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