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,345 @@
|
|
|
1
|
+
"""Compare-and-merge workflow.
|
|
2
|
+
|
|
3
|
+
``compare`` produces a side-by-side, metric-annotated view of two or more runs and
|
|
4
|
+
(for ``judge``) a heuristic winner. ``merge`` synthesizes a canonical final answer
|
|
5
|
+
and — when ``persist`` is set — writes it to durable workspace memory, linking the
|
|
6
|
+
``Comparison`` row to the resulting ``memory_item``. This is how multiple providers'
|
|
7
|
+
outputs become one persisted, vendor-neutral outcome.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from harness_sdk.base import SessionContext
|
|
16
|
+
from sqlalchemy import select
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
|
+
|
|
19
|
+
from harness.adapters.registry import build_adapter
|
|
20
|
+
from harness.memory.ingest import ingest_memory
|
|
21
|
+
from harness.memory.namespaces import workspace_ns
|
|
22
|
+
from harness.models.comparison import Comparison
|
|
23
|
+
from harness.models.enums import ComparisonStrategy, MemoryScope, MemoryType, RunStatus
|
|
24
|
+
from harness.models.provider_config import ProviderConfig
|
|
25
|
+
from harness.models.run import Run
|
|
26
|
+
from harness.models.session import Session
|
|
27
|
+
from harness.models.workspace import Workspace
|
|
28
|
+
from harness.observability.audit import write_audit
|
|
29
|
+
|
|
30
|
+
# How much of each candidate's output the judge gets to see. Generous on
|
|
31
|
+
# purpose: a few hundred characters is not enough to tell two answers apart.
|
|
32
|
+
PREVIEW_CHARS = 4000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _provider_of(graph_node_id: str | None) -> str:
|
|
36
|
+
"""Agent name behind a graph node id ('claude-0' -> 'claude')."""
|
|
37
|
+
return re.sub(r"-\d+$", "", graph_node_id or "") or "unknown"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _winner_from_verdict(verdict: str, scored: list[dict]) -> str | None:
|
|
41
|
+
"""Resolve the judge's prose answer to a run id.
|
|
42
|
+
|
|
43
|
+
Prefers the declared ``WINNER: <name>`` line, then any provider name or run
|
|
44
|
+
id mentioned anywhere in the text. Returns None when nothing matches, which
|
|
45
|
+
leaves the caller on its heuristic fallback.
|
|
46
|
+
"""
|
|
47
|
+
if not verdict:
|
|
48
|
+
return None
|
|
49
|
+
by_name = {s["provider"].lower(): s["run_id"] for s in scored}
|
|
50
|
+
|
|
51
|
+
match = re.search(r"^\s*#*\s*\**\s*WINNER\s*\**\s*:\s*\**\s*(.+?)\s*\**\s*$",
|
|
52
|
+
verdict, re.IGNORECASE | re.MULTILINE)
|
|
53
|
+
if match:
|
|
54
|
+
claimed = match.group(1).strip().strip("`").lower()
|
|
55
|
+
if claimed in by_name:
|
|
56
|
+
return by_name[claimed]
|
|
57
|
+
# The judge may decorate the name ("local (llama3.1)") — take the
|
|
58
|
+
# longest candidate name it contains so 'gpt' can't shadow 'gpt-fast'.
|
|
59
|
+
hits = [n for n in by_name if n and n in claimed]
|
|
60
|
+
if hits:
|
|
61
|
+
return by_name[max(hits, key=len)]
|
|
62
|
+
|
|
63
|
+
for s in scored:
|
|
64
|
+
if s["run_id"] in verdict:
|
|
65
|
+
return s["run_id"]
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _quality_score(run: Run) -> float:
|
|
70
|
+
"""Cheap, deterministic heuristic for fallback."""
|
|
71
|
+
if run.status != RunStatus.succeeded:
|
|
72
|
+
return 0.0
|
|
73
|
+
out = run.final_output or ""
|
|
74
|
+
length_score = min(len(out) / 1000.0, 1.0)
|
|
75
|
+
cost = run.cost or {}
|
|
76
|
+
tokens = cost.get("total_tokens", 0) or 0
|
|
77
|
+
efficiency = 1.0 / (1.0 + tokens / 1000.0)
|
|
78
|
+
return 0.7 * length_score + 0.3 * efficiency
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _load_runs(db: AsyncSession, run_ids: list[uuid.UUID]) -> list[Run]:
|
|
82
|
+
rows = (await db.execute(select(Run).where(Run.id.in_(run_ids)))).scalars().all()
|
|
83
|
+
order = {rid: i for i, rid in enumerate(run_ids)}
|
|
84
|
+
return sorted(rows, key=lambda r: order.get(r.id, 0))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _pick_provider(db: AsyncSession, name: str | None = None) -> ProviderConfig | None:
|
|
88
|
+
"""The provider to use for an internal (judge/merge) LLM call.
|
|
89
|
+
|
|
90
|
+
*name* selects a specific agent; when it is omitted — or names one that is
|
|
91
|
+
unknown or disabled — this falls back to the first enabled provider, which
|
|
92
|
+
is the historical behaviour.
|
|
93
|
+
"""
|
|
94
|
+
if name:
|
|
95
|
+
pc = (await db.execute(
|
|
96
|
+
select(ProviderConfig).where(
|
|
97
|
+
ProviderConfig.name == name, ProviderConfig.enabled.is_(True)
|
|
98
|
+
).limit(1)
|
|
99
|
+
)).scalar_one_or_none()
|
|
100
|
+
if pc is not None:
|
|
101
|
+
return pc
|
|
102
|
+
return (await db.execute(
|
|
103
|
+
select(ProviderConfig).where(ProviderConfig.enabled.is_(True)).limit(1)
|
|
104
|
+
)).scalar_one_or_none()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def _invoke_llm(
|
|
108
|
+
db: AsyncSession,
|
|
109
|
+
session_id: uuid.UUID,
|
|
110
|
+
prompt: str,
|
|
111
|
+
*,
|
|
112
|
+
pc: ProviderConfig | None = None,
|
|
113
|
+
model: str | None = None,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Run a one-off, non-streaming completion for judging/merging.
|
|
116
|
+
|
|
117
|
+
*pc* is the provider to ask (resolved by the caller so it can report which
|
|
118
|
+
agent judged); omitted means the first enabled one. *model* overrides that
|
|
119
|
+
provider's configured model for this call only.
|
|
120
|
+
"""
|
|
121
|
+
if pc is None:
|
|
122
|
+
pc = await _pick_provider(db)
|
|
123
|
+
|
|
124
|
+
if not pc:
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
if model:
|
|
128
|
+
from harness_sdk.base import AdapterConfig
|
|
129
|
+
|
|
130
|
+
from harness.adapters.registry import build_adapter_from_config
|
|
131
|
+
from harness.security.secrets import resolve_secret_ref
|
|
132
|
+
adapter = build_adapter_from_config(AdapterConfig(
|
|
133
|
+
name=pc.name,
|
|
134
|
+
kind=pc.kind,
|
|
135
|
+
base_url=pc.base_url,
|
|
136
|
+
model=model,
|
|
137
|
+
api_key=resolve_secret_ref(pc.secret_ref),
|
|
138
|
+
params=pc.params or {},
|
|
139
|
+
capabilities=pc.capabilities or {},
|
|
140
|
+
))
|
|
141
|
+
else:
|
|
142
|
+
adapter = build_adapter(pc)
|
|
143
|
+
ctx = SessionContext(
|
|
144
|
+
run_id=str(uuid.uuid4()),
|
|
145
|
+
session_id=str(session_id),
|
|
146
|
+
workspace_slug="ephemeral",
|
|
147
|
+
objective="internal_llm_call",
|
|
148
|
+
constraints={},
|
|
149
|
+
memory_context=""
|
|
150
|
+
)
|
|
151
|
+
provider_session = await adapter.start_session(ctx)
|
|
152
|
+
result = ""
|
|
153
|
+
async for ev in adapter.stream_events(provider_session, prompt, stream=False):
|
|
154
|
+
if ev.type == "message":
|
|
155
|
+
result = ev.text
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def compare_runs(
|
|
160
|
+
db: AsyncSession,
|
|
161
|
+
*,
|
|
162
|
+
run_ids: list[uuid.UUID],
|
|
163
|
+
strategy: str = "judge",
|
|
164
|
+
judge_provider: str | None = None,
|
|
165
|
+
judge_model: str | None = None,
|
|
166
|
+
) -> Comparison:
|
|
167
|
+
"""Score candidate runs and, for ``judge``, ask an LLM to pick a winner.
|
|
168
|
+
|
|
169
|
+
*judge_provider* / *judge_model* choose the agent and model doing the
|
|
170
|
+
judging; both fall back to the first enabled provider on its own model.
|
|
171
|
+
The judge answers in prose (stored as ``result["verdict"]``) so the user
|
|
172
|
+
reads a rationale, not a bare id.
|
|
173
|
+
"""
|
|
174
|
+
runs = await _load_runs(db, run_ids)
|
|
175
|
+
if not runs:
|
|
176
|
+
raise ValueError("no runs found for comparison")
|
|
177
|
+
session_id = runs[0].session_id
|
|
178
|
+
|
|
179
|
+
scored = [
|
|
180
|
+
{
|
|
181
|
+
"run_id": str(r.id),
|
|
182
|
+
"provider": _provider_of(r.graph_node_id),
|
|
183
|
+
"provider_role": r.role.value,
|
|
184
|
+
"adapter_kind": r.adapter_kind.value,
|
|
185
|
+
"status": r.status.value,
|
|
186
|
+
"score": round(_quality_score(r), 4),
|
|
187
|
+
"output_preview": (r.final_output or "")[:PREVIEW_CHARS],
|
|
188
|
+
"cost": r.cost or {},
|
|
189
|
+
}
|
|
190
|
+
for r in runs
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
winner_run_id = None
|
|
194
|
+
verdict = ""
|
|
195
|
+
judge_meta: dict | None = None
|
|
196
|
+
if strategy == "judge":
|
|
197
|
+
pc = await _pick_provider(db, judge_provider)
|
|
198
|
+
judge_meta = {
|
|
199
|
+
"provider": pc.name if pc else None,
|
|
200
|
+
"model": judge_model or (pc.model if pc else None),
|
|
201
|
+
"requested_provider": judge_provider,
|
|
202
|
+
}
|
|
203
|
+
names = ", ".join(s["provider"] for s in scored)
|
|
204
|
+
prompt = (
|
|
205
|
+
"You are judging the outputs of several AI agents that were given the "
|
|
206
|
+
"same task. Decide which one did it best.\n\n"
|
|
207
|
+
"Answer in plain text (light markdown is fine). Structure it exactly like "
|
|
208
|
+
"this:\n"
|
|
209
|
+
f"1. A first line of exactly `WINNER: <name>`, where <name> is one of: {names}.\n"
|
|
210
|
+
"2. A short paragraph explaining why that one won.\n"
|
|
211
|
+
"3. One bullet per agent noting its main strength and weakness.\n\n"
|
|
212
|
+
"Do not output JSON. Do not quote run ids.\n\n"
|
|
213
|
+
)
|
|
214
|
+
for s in scored:
|
|
215
|
+
prompt += (
|
|
216
|
+
f"## Agent: {s['provider']} ({s['adapter_kind']}, status={s['status']})\n"
|
|
217
|
+
f"{s['output_preview'] or '(no output)'}\n\n"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
verdict = (await _invoke_llm(db, session_id, prompt, pc=pc, model=judge_model)).strip()
|
|
221
|
+
winner_run_id = _winner_from_verdict(verdict, scored)
|
|
222
|
+
|
|
223
|
+
if not winner_run_id and scored:
|
|
224
|
+
# Judge unavailable or unparseable — fall back to the heuristic score.
|
|
225
|
+
winner_run_id = max(scored, key=lambda s: s["score"])["run_id"]
|
|
226
|
+
|
|
227
|
+
winner_provider = next(
|
|
228
|
+
(s["provider"] for s in scored if s["run_id"] == winner_run_id), None
|
|
229
|
+
)
|
|
230
|
+
comparison = Comparison(
|
|
231
|
+
session_id=session_id,
|
|
232
|
+
run_ids=run_ids,
|
|
233
|
+
strategy=ComparisonStrategy(strategy),
|
|
234
|
+
result={
|
|
235
|
+
"candidates": scored,
|
|
236
|
+
"winner_run_id": winner_run_id,
|
|
237
|
+
"winner_provider": winner_provider,
|
|
238
|
+
"verdict": verdict,
|
|
239
|
+
"judge": judge_meta,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
db.add(comparison)
|
|
243
|
+
await db.flush()
|
|
244
|
+
|
|
245
|
+
await write_audit(
|
|
246
|
+
db,
|
|
247
|
+
actor="system",
|
|
248
|
+
action="compare_runs",
|
|
249
|
+
target_type="comparison",
|
|
250
|
+
target_id=str(comparison.id),
|
|
251
|
+
after={
|
|
252
|
+
"strategy": strategy,
|
|
253
|
+
"winner_run_id": winner_run_id,
|
|
254
|
+
"winner_provider": winner_provider,
|
|
255
|
+
"judge": judge_meta,
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return comparison
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def merge_runs(
|
|
263
|
+
db: AsyncSession,
|
|
264
|
+
*,
|
|
265
|
+
run_ids: list[uuid.UUID],
|
|
266
|
+
persist: bool = True,
|
|
267
|
+
merged_content: str | None = None,
|
|
268
|
+
) -> Comparison:
|
|
269
|
+
"""Merge runs into a canonical answer and optionally persist it as memory."""
|
|
270
|
+
runs = await _load_runs(db, run_ids)
|
|
271
|
+
if not runs:
|
|
272
|
+
raise ValueError("no runs found for merge")
|
|
273
|
+
session = (
|
|
274
|
+
await db.execute(select(Session).where(Session.id == runs[0].session_id))
|
|
275
|
+
).scalar_one()
|
|
276
|
+
workspace = (
|
|
277
|
+
await db.execute(select(Workspace).where(Workspace.id == session.workspace_id))
|
|
278
|
+
).scalar_one()
|
|
279
|
+
|
|
280
|
+
if merged_content is None:
|
|
281
|
+
prompt = (
|
|
282
|
+
"You are an AI merging the outputs of several language model runs into a "
|
|
283
|
+
"single, canonical, best answer. Synthesize the following outputs into a "
|
|
284
|
+
"cohesive final answer.\n\n"
|
|
285
|
+
)
|
|
286
|
+
for r in runs:
|
|
287
|
+
if r.final_output:
|
|
288
|
+
prompt += (
|
|
289
|
+
f"### Output from {r.role.value} ({r.adapter_kind.value})\n"
|
|
290
|
+
f"{r.final_output}\n\n"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
ans = await _invoke_llm(db, session.id, prompt)
|
|
294
|
+
|
|
295
|
+
if ans:
|
|
296
|
+
merged_content = ans.strip()
|
|
297
|
+
else:
|
|
298
|
+
# Fallback
|
|
299
|
+
ranked = sorted(runs, key=_quality_score, reverse=True)
|
|
300
|
+
sections = [
|
|
301
|
+
f"## Canonical answer (from {ranked[0].role.value})",
|
|
302
|
+
ranked[0].final_output or "",
|
|
303
|
+
]
|
|
304
|
+
for r in ranked[1:]:
|
|
305
|
+
if r.final_output:
|
|
306
|
+
sections.append(f"\n### Alternative ({r.role.value}/{r.adapter_kind.value})")
|
|
307
|
+
sections.append(r.final_output)
|
|
308
|
+
merged_content = "\n".join(sections).strip()
|
|
309
|
+
|
|
310
|
+
memory_item_id: uuid.UUID | None = None
|
|
311
|
+
if persist:
|
|
312
|
+
item = await ingest_memory(
|
|
313
|
+
db,
|
|
314
|
+
namespace=workspace_ns(workspace.slug),
|
|
315
|
+
scope=MemoryScope.workspace,
|
|
316
|
+
mem_type=MemoryType.decision,
|
|
317
|
+
content=merged_content,
|
|
318
|
+
tags=["merged", "canonical", session.canonical_objective[:40]],
|
|
319
|
+
source_run_id=runs[0].id,
|
|
320
|
+
)
|
|
321
|
+
memory_item_id = item.id
|
|
322
|
+
|
|
323
|
+
comparison = Comparison(
|
|
324
|
+
session_id=session.id,
|
|
325
|
+
run_ids=run_ids,
|
|
326
|
+
strategy=ComparisonStrategy.merge,
|
|
327
|
+
result={"merged_content": merged_content, "persisted": persist},
|
|
328
|
+
canonical_memory_item_id=memory_item_id,
|
|
329
|
+
)
|
|
330
|
+
db.add(comparison)
|
|
331
|
+
await db.flush()
|
|
332
|
+
|
|
333
|
+
await write_audit(
|
|
334
|
+
db,
|
|
335
|
+
actor="system",
|
|
336
|
+
action="merge_runs",
|
|
337
|
+
target_type="comparison",
|
|
338
|
+
target_id=str(comparison.id),
|
|
339
|
+
after={
|
|
340
|
+
"persisted": persist,
|
|
341
|
+
"canonical_memory_item_id": str(memory_item_id) if memory_item_id else None,
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return comparison
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Doctor service — end-to-end provider preflight.
|
|
2
|
+
|
|
3
|
+
Iterates all *enabled* ProviderConfig rows and for each produces a health
|
|
4
|
+
report dict:
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"provider": str, # provider name
|
|
8
|
+
"kind": str, # AdapterKind value string
|
|
9
|
+
"binary": str | None, # resolved binary path (CLI kinds only)
|
|
10
|
+
"version": str | None, # version string from probe (CLI kinds only)
|
|
11
|
+
"health_ok": bool,
|
|
12
|
+
"latency_ms": float,
|
|
13
|
+
"model": str | None,
|
|
14
|
+
"mcp_ok": bool | None, # None for non-CLI kinds
|
|
15
|
+
"native_sessions": bool, # whether decide_resume would enable this
|
|
16
|
+
"problems": list[str], # human-readable problem descriptions
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Probe/health exceptions are caught and appended as problem entries — this
|
|
20
|
+
function never raises.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import os
|
|
27
|
+
|
|
28
|
+
from harness_sdk.cli_base import resolve_binary
|
|
29
|
+
from sqlalchemy import select
|
|
30
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
31
|
+
|
|
32
|
+
from harness.adapters.probe import probe_cli, supports_flag
|
|
33
|
+
from harness.adapters.registry import build_adapter
|
|
34
|
+
from harness.models.enums import AdapterKind
|
|
35
|
+
from harness.models.provider_config import ProviderConfig
|
|
36
|
+
|
|
37
|
+
# Kinds that are CLI-based (have a binary on PATH, support probe_cli, etc.)
|
|
38
|
+
_CLI_KINDS = {AdapterKind.claude_code, AdapterKind.antigravity}
|
|
39
|
+
|
|
40
|
+
# MCP flag checked per CLI kind
|
|
41
|
+
_MCP_FLAG: dict[AdapterKind, str] = {
|
|
42
|
+
AdapterKind.claude_code: "--mcp-config",
|
|
43
|
+
AdapterKind.antigravity: "--mcp-config",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Conversation / resume flag for antigravity native-sessions detection
|
|
47
|
+
_CONVERSATION_FLAG = "--conversation"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_existing(binary_name: str) -> str | None:
|
|
51
|
+
"""Resolve a CLI name to an existing executable path (PATH + common install dirs),
|
|
52
|
+
or None if nothing is found. Sync (filesystem checks) so ``_check_provider`` — which
|
|
53
|
+
is async — doesn't call ``os.path`` directly."""
|
|
54
|
+
resolved = resolve_binary(binary_name)
|
|
55
|
+
return resolved if os.path.isfile(resolved) else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def doctor_report(db: AsyncSession) -> list[dict]:
|
|
59
|
+
"""Run a preflight check on all enabled providers and return a report list."""
|
|
60
|
+
result = await db.execute(
|
|
61
|
+
select(ProviderConfig)
|
|
62
|
+
.where(ProviderConfig.enabled.is_(True))
|
|
63
|
+
.order_by(ProviderConfig.name)
|
|
64
|
+
)
|
|
65
|
+
configs = list(result.scalars())
|
|
66
|
+
|
|
67
|
+
results = await asyncio.gather(*[_check_provider(pc) for pc in configs])
|
|
68
|
+
return list(results)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _check_provider(pc: ProviderConfig) -> dict:
|
|
72
|
+
"""Probe a single provider config and return its report dict."""
|
|
73
|
+
problems: list[str] = []
|
|
74
|
+
is_cli = pc.kind in _CLI_KINDS
|
|
75
|
+
|
|
76
|
+
# --- Binary resolution (CLI kinds only) ---
|
|
77
|
+
binary_path: str | None = None
|
|
78
|
+
version: str | None = None
|
|
79
|
+
mcp_ok: bool | None = None
|
|
80
|
+
probe: dict | None = None
|
|
81
|
+
|
|
82
|
+
if is_cli:
|
|
83
|
+
# Determine the binary name from params or fallback default
|
|
84
|
+
if pc.kind == AdapterKind.claude_code:
|
|
85
|
+
binary_name = (pc.params or {}).get("bin", "claude")
|
|
86
|
+
else: # antigravity
|
|
87
|
+
p = pc.params or {}
|
|
88
|
+
binary_name = p.get("bin") or p.get("cmd") or "agy"
|
|
89
|
+
|
|
90
|
+
# Resolve via PATH + common install dirs (matches the spawn path), so an
|
|
91
|
+
# installed-but-off-PATH CLI isn't wrongly reported as missing.
|
|
92
|
+
binary_path = _resolve_existing(binary_name)
|
|
93
|
+
if binary_path is None:
|
|
94
|
+
problems.append(f"binary not on PATH or common install dirs: {binary_name!r}")
|
|
95
|
+
|
|
96
|
+
# Run CLI probe (fast; handles missing binary gracefully)
|
|
97
|
+
try:
|
|
98
|
+
probe = await probe_cli(binary_name)
|
|
99
|
+
version = probe.get("version")
|
|
100
|
+
except Exception as exc: # noqa: BLE001
|
|
101
|
+
problems.append(f"probe failed: {exc}")
|
|
102
|
+
probe = {"ok": False, "flags": set(), "version": None}
|
|
103
|
+
|
|
104
|
+
# MCP flag support check
|
|
105
|
+
mcp_flag = _MCP_FLAG.get(pc.kind, "--mcp-config")
|
|
106
|
+
if probe is not None and probe.get("ok"):
|
|
107
|
+
mcp_ok = supports_flag(probe, mcp_flag)
|
|
108
|
+
if not mcp_ok:
|
|
109
|
+
problems.append("MCP flag unsupported — servers will be skipped")
|
|
110
|
+
else:
|
|
111
|
+
mcp_ok = False
|
|
112
|
+
|
|
113
|
+
# --- Health check ---
|
|
114
|
+
health_ok = False
|
|
115
|
+
latency_ms = 0.0
|
|
116
|
+
try:
|
|
117
|
+
adapter = build_adapter(pc)
|
|
118
|
+
health = await adapter.health_check()
|
|
119
|
+
health_ok = health.ok
|
|
120
|
+
latency_ms = health.latency_ms
|
|
121
|
+
if not health.ok:
|
|
122
|
+
detail = health.detail or {}
|
|
123
|
+
problems.append(f"health check failed: {detail}")
|
|
124
|
+
except Exception as exc: # noqa: BLE001
|
|
125
|
+
problems.append(f"health check error: {exc}")
|
|
126
|
+
|
|
127
|
+
# --- Native-sessions eligibility ---
|
|
128
|
+
# claude_code: enabled by default unless params explicitly disable it
|
|
129
|
+
# antigravity: enabled only when probe found --conversation flag or params enable it
|
|
130
|
+
# others: False
|
|
131
|
+
explicit = (pc.params or {}).get("native_sessions")
|
|
132
|
+
if explicit is not None:
|
|
133
|
+
native_sessions = bool(explicit)
|
|
134
|
+
elif pc.kind == AdapterKind.claude_code:
|
|
135
|
+
native_sessions = True
|
|
136
|
+
elif pc.kind == AdapterKind.antigravity:
|
|
137
|
+
probed_flags = (probe or {}).get("flags", set()) if probe else None
|
|
138
|
+
native_sessions = (
|
|
139
|
+
probed_flags is not None
|
|
140
|
+
and _CONVERSATION_FLAG in probed_flags
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
native_sessions = False
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"provider": pc.name,
|
|
147
|
+
"kind": pc.kind.value,
|
|
148
|
+
"binary": binary_path,
|
|
149
|
+
"version": version,
|
|
150
|
+
"health_ok": health_ok,
|
|
151
|
+
"latency_ms": latency_ms,
|
|
152
|
+
"model": pc.model,
|
|
153
|
+
"mcp_ok": mcp_ok,
|
|
154
|
+
"native_sessions": native_sessions,
|
|
155
|
+
"problems": problems,
|
|
156
|
+
}
|