agentack 0.6.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.
- agentack/__init__.py +22 -0
- agentack/__main__.py +3 -0
- agentack/adapters/__init__.py +14 -0
- agentack/adapters/base.py +66 -0
- agentack/adapters/claude.py +38 -0
- agentack/adapters/claude_analysis.py +342 -0
- agentack/adapters/claude_capture.py +112 -0
- agentack/adapters/claude_live.py +186 -0
- agentack/adapters/codex.py +173 -0
- agentack/adapters/codex_analysis.py +237 -0
- agentack/adapters/codex_protocol.py +549 -0
- agentack/adapters/otel.py +150 -0
- agentack/canonical.py +82 -0
- agentack/cli.py +366 -0
- agentack/coverage.py +49 -0
- agentack/demo.py +106 -0
- agentack/engine.py +279 -0
- agentack/findings.py +91 -0
- agentack/models.py +303 -0
- agentack/parser.py +73 -0
- agentack/policy.py +78 -0
- agentack/provenance.py +168 -0
- agentack/recorder.py +163 -0
- agentack/report.py +271 -0
- agentack/ux.py +48 -0
- agentack-0.6.0.dist-info/METADATA +263 -0
- agentack-0.6.0.dist-info/RECORD +31 -0
- agentack-0.6.0.dist-info/WHEEL +5 -0
- agentack-0.6.0.dist-info/entry_points.txt +2 -0
- agentack-0.6.0.dist-info/licenses/LICENSE +21 -0
- agentack-0.6.0.dist-info/top_level.txt +1 -0
agentack/recorder.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TextIO
|
|
7
|
+
|
|
8
|
+
from .models import Action, TraceEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Recorder:
|
|
12
|
+
"""Small instrumentation helper for emitting AgentAck JSONL traces."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, path: str | Path, session_id: str, *, append: bool = False) -> None:
|
|
15
|
+
self.path = Path(path)
|
|
16
|
+
self.session_id = session_id
|
|
17
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
self._handle: TextIO = self.path.open("a" if append else "w", encoding="utf-8", newline="\n")
|
|
19
|
+
self._ended = False
|
|
20
|
+
|
|
21
|
+
def close(self) -> None:
|
|
22
|
+
if not self._handle.closed:
|
|
23
|
+
self._handle.close()
|
|
24
|
+
|
|
25
|
+
def __enter__(self) -> "Recorder":
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[no-untyped-def]
|
|
29
|
+
if not self._ended:
|
|
30
|
+
self.end(reason="recorder context closed" if exc_type is None else "recorder context closed after error")
|
|
31
|
+
self.close()
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _now() -> datetime:
|
|
35
|
+
return datetime.now(timezone.utc)
|
|
36
|
+
|
|
37
|
+
def _write(self, event: TraceEvent) -> None:
|
|
38
|
+
if self._ended:
|
|
39
|
+
raise RuntimeError("cannot record events after session_end")
|
|
40
|
+
self._handle.write(json.dumps(event.to_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=False))
|
|
41
|
+
self._handle.write("\n")
|
|
42
|
+
self._handle.flush()
|
|
43
|
+
|
|
44
|
+
def propose(self, action_id: str, action: Action, *, intent_id: str | None = None) -> None:
|
|
45
|
+
self._write(
|
|
46
|
+
TraceEvent(
|
|
47
|
+
type="action_proposed",
|
|
48
|
+
timestamp=self._now(),
|
|
49
|
+
session_id=self.session_id,
|
|
50
|
+
action_id=action_id,
|
|
51
|
+
intent_id=intent_id,
|
|
52
|
+
action=action,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def request_approval(
|
|
57
|
+
self,
|
|
58
|
+
approval_id: str,
|
|
59
|
+
action_id: str,
|
|
60
|
+
presented_action: Action,
|
|
61
|
+
*,
|
|
62
|
+
intent_id: str | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._write(
|
|
65
|
+
TraceEvent(
|
|
66
|
+
type="approval_requested",
|
|
67
|
+
timestamp=self._now(),
|
|
68
|
+
session_id=self.session_id,
|
|
69
|
+
approval_id=approval_id,
|
|
70
|
+
action_id=action_id,
|
|
71
|
+
intent_id=intent_id,
|
|
72
|
+
action=presented_action,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def decide(
|
|
77
|
+
self,
|
|
78
|
+
approval_id: str,
|
|
79
|
+
action_id: str,
|
|
80
|
+
decision: str,
|
|
81
|
+
*,
|
|
82
|
+
intent_id: str | None = None,
|
|
83
|
+
ttl_seconds: int | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
if decision not in {"allow", "deny"}:
|
|
86
|
+
raise ValueError("decision must be 'allow' or 'deny'")
|
|
87
|
+
timestamp = self._now()
|
|
88
|
+
self._write(
|
|
89
|
+
TraceEvent(
|
|
90
|
+
type="approval_decision",
|
|
91
|
+
timestamp=timestamp,
|
|
92
|
+
session_id=self.session_id,
|
|
93
|
+
approval_id=approval_id,
|
|
94
|
+
action_id=action_id,
|
|
95
|
+
intent_id=intent_id,
|
|
96
|
+
decision=decision, # type: ignore[arg-type]
|
|
97
|
+
expires_at=(timestamp + timedelta(seconds=ttl_seconds)) if ttl_seconds is not None else None,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def execute(
|
|
102
|
+
self,
|
|
103
|
+
action_id: str,
|
|
104
|
+
action: Action,
|
|
105
|
+
*,
|
|
106
|
+
approval_id: str | None = None,
|
|
107
|
+
intent_id: str | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._write(
|
|
110
|
+
TraceEvent(
|
|
111
|
+
type="action_executed",
|
|
112
|
+
timestamp=self._now(),
|
|
113
|
+
session_id=self.session_id,
|
|
114
|
+
action_id=action_id,
|
|
115
|
+
approval_id=approval_id,
|
|
116
|
+
intent_id=intent_id,
|
|
117
|
+
action=action,
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def block(
|
|
122
|
+
self,
|
|
123
|
+
action_id: str,
|
|
124
|
+
*,
|
|
125
|
+
approval_id: str | None = None,
|
|
126
|
+
intent_id: str | None = None,
|
|
127
|
+
reason: str | None = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
self._write(
|
|
130
|
+
TraceEvent(
|
|
131
|
+
type="action_blocked",
|
|
132
|
+
timestamp=self._now(),
|
|
133
|
+
session_id=self.session_id,
|
|
134
|
+
action_id=action_id,
|
|
135
|
+
approval_id=approval_id,
|
|
136
|
+
intent_id=intent_id,
|
|
137
|
+
reason=reason,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def interrupt(self, *, reason: str | None = None) -> None:
|
|
142
|
+
self._write(
|
|
143
|
+
TraceEvent(
|
|
144
|
+
type="interrupt",
|
|
145
|
+
timestamp=self._now(),
|
|
146
|
+
session_id=self.session_id,
|
|
147
|
+
reason=reason,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def end(self, *, reason: str | None = None) -> None:
|
|
152
|
+
if self._ended:
|
|
153
|
+
return
|
|
154
|
+
event = TraceEvent(
|
|
155
|
+
type="session_end",
|
|
156
|
+
timestamp=self._now(),
|
|
157
|
+
session_id=self.session_id,
|
|
158
|
+
reason=reason,
|
|
159
|
+
)
|
|
160
|
+
self._handle.write(json.dumps(event.to_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=False))
|
|
161
|
+
self._handle.write("\n")
|
|
162
|
+
self._handle.flush()
|
|
163
|
+
self._ended = True
|
agentack/report.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .adapters.base import AdapterTestResult
|
|
10
|
+
from .findings import RULES
|
|
11
|
+
from .models import EvaluationReport, Finding, TraceEvent
|
|
12
|
+
from .policy import Policy
|
|
13
|
+
from .provenance import (
|
|
14
|
+
adapter_input_provenance,
|
|
15
|
+
base_provenance,
|
|
16
|
+
trace_action_identities,
|
|
17
|
+
trace_input_provenance,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_text(report: EvaluationReport) -> str:
|
|
22
|
+
lines = [f"AgentAck {report.status}"]
|
|
23
|
+
if report.source:
|
|
24
|
+
lines.append(f"Trace: {report.source}")
|
|
25
|
+
lines.append(f"Events: {report.events}")
|
|
26
|
+
lines.append(f"Findings: {len(report.findings)}")
|
|
27
|
+
if not report.findings:
|
|
28
|
+
lines.append("")
|
|
29
|
+
lines.append("Approval-integrity evidence is complete and no enabled rule failed.")
|
|
30
|
+
return "\n".join(lines)
|
|
31
|
+
lines.append("")
|
|
32
|
+
for finding in report.findings:
|
|
33
|
+
location = f" line {finding.line}" if finding.line is not None else ""
|
|
34
|
+
spec = RULES[finding.rule_id]
|
|
35
|
+
lines.append(f"{finding.severity.upper():8} {finding.rule_id} {finding.title}{location}")
|
|
36
|
+
lines.append(f" Evidence: {finding.message}")
|
|
37
|
+
lines.append(f" Why: {spec.description}")
|
|
38
|
+
lines.append(f" Next: {spec.remediation}")
|
|
39
|
+
if report.status == "INCOMPLETE":
|
|
40
|
+
lines.append("")
|
|
41
|
+
lines.append("Result is INCOMPLETE because the trace lacks evidence required to establish approval integrity.")
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _finding_payload(finding: Finding) -> dict[str, Any]:
|
|
46
|
+
spec = RULES[finding.rule_id]
|
|
47
|
+
payload = finding.to_dict()
|
|
48
|
+
payload["why"] = spec.description
|
|
49
|
+
payload["remediation"] = spec.remediation
|
|
50
|
+
return payload
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def trace_report_payload(
|
|
54
|
+
report: EvaluationReport,
|
|
55
|
+
events: list[TraceEvent],
|
|
56
|
+
policy: Policy,
|
|
57
|
+
*,
|
|
58
|
+
trace_sha256: str,
|
|
59
|
+
trace_source: str | Path | None,
|
|
60
|
+
policy_source: str | Path | None = None,
|
|
61
|
+
run_id: str | None = None,
|
|
62
|
+
evaluated_at: str | None = None,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
session_id = events[0].session_id if events else None
|
|
65
|
+
payload = base_provenance(
|
|
66
|
+
agentack_version=__version__,
|
|
67
|
+
kind="trace",
|
|
68
|
+
session_id=session_id,
|
|
69
|
+
run_id=run_id,
|
|
70
|
+
evaluated_at=evaluated_at,
|
|
71
|
+
)
|
|
72
|
+
payload.update(
|
|
73
|
+
{
|
|
74
|
+
"input": trace_input_provenance(
|
|
75
|
+
trace_sha256=trace_sha256,
|
|
76
|
+
policy=policy,
|
|
77
|
+
trace_source=trace_source,
|
|
78
|
+
policy_source=policy_source,
|
|
79
|
+
),
|
|
80
|
+
"adapter": None,
|
|
81
|
+
"actions": [item.to_dict() for item in trace_action_identities(events)],
|
|
82
|
+
"result": {
|
|
83
|
+
"status": report.status,
|
|
84
|
+
"event_count": report.events,
|
|
85
|
+
"finding_count": len(report.findings),
|
|
86
|
+
"rule_counts": report.rule_counts,
|
|
87
|
+
"findings": [_finding_payload(item) for item in report.findings],
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
return payload
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def adapter_report_payload(
|
|
95
|
+
result: AdapterTestResult,
|
|
96
|
+
*,
|
|
97
|
+
run_id: str | None = None,
|
|
98
|
+
evaluated_at: str | None = None,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
payload = base_provenance(
|
|
101
|
+
agentack_version=__version__,
|
|
102
|
+
kind="adapter",
|
|
103
|
+
session_id=result.session_id,
|
|
104
|
+
run_id=run_id,
|
|
105
|
+
evaluated_at=evaluated_at,
|
|
106
|
+
)
|
|
107
|
+
payload.update(
|
|
108
|
+
{
|
|
109
|
+
"input": adapter_input_provenance(result.evidence_sha256),
|
|
110
|
+
"adapter": {
|
|
111
|
+
"name": result.adapter,
|
|
112
|
+
"display_name": result.display_name,
|
|
113
|
+
"version": result.adapter_version,
|
|
114
|
+
},
|
|
115
|
+
"actions": [item.to_dict() for item in result.actions],
|
|
116
|
+
"result": {
|
|
117
|
+
"status": result.status,
|
|
118
|
+
"checks": [check.to_dict() for check in result.checks],
|
|
119
|
+
"notes": list(result.notes),
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
return payload
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def write_json_report(path: str | Path, payload: dict[str, Any]) -> None:
|
|
127
|
+
target = Path(path)
|
|
128
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _sarif_level(severity: str) -> str:
|
|
133
|
+
return {"critical": "error", "high": "error", "medium": "warning", "low": "note"}[severity]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _run_properties(document: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
+
run = document["run"]
|
|
138
|
+
input_data = document["input"]
|
|
139
|
+
trace = input_data.get("trace") or {}
|
|
140
|
+
policy = input_data.get("policy") or {}
|
|
141
|
+
evidence = input_data.get("evidence") or {}
|
|
142
|
+
adapter = document.get("adapter") or {}
|
|
143
|
+
return {
|
|
144
|
+
"agentackReportSchemaVersion": document["report_schema_version"],
|
|
145
|
+
"runId": run["run_id"],
|
|
146
|
+
"evaluationKind": run["kind"],
|
|
147
|
+
"evaluatedAt": run["evaluated_at"],
|
|
148
|
+
"sessionId": run.get("session_id"),
|
|
149
|
+
"traceSchemaVersion": trace.get("schema_version"),
|
|
150
|
+
"traceSha256": trace.get("sha256"),
|
|
151
|
+
"policySha256": policy.get("sha256"),
|
|
152
|
+
"evidenceSha256": evidence.get("sha256"),
|
|
153
|
+
"adapterName": adapter.get("name"),
|
|
154
|
+
"adapterVersion": adapter.get("version"),
|
|
155
|
+
"status": document["result"]["status"],
|
|
156
|
+
"actionIdentities": document["actions"],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def trace_sarif_payload(report: EvaluationReport, document: dict[str, Any]) -> dict[str, Any]:
|
|
161
|
+
rules = []
|
|
162
|
+
for rule_id, spec in RULES.items():
|
|
163
|
+
rules.append(
|
|
164
|
+
{
|
|
165
|
+
"id": rule_id,
|
|
166
|
+
"name": spec.title.replace(" ", ""),
|
|
167
|
+
"shortDescription": {"text": spec.title},
|
|
168
|
+
"fullDescription": {"text": spec.description},
|
|
169
|
+
"help": {"text": spec.remediation},
|
|
170
|
+
"defaultConfiguration": {"level": _sarif_level(spec.severity)},
|
|
171
|
+
"properties": {"standards": list(spec.standards)},
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
results = []
|
|
175
|
+
source_name = (document["input"].get("trace") or {}).get("source")
|
|
176
|
+
for finding in report.findings:
|
|
177
|
+
spec = RULES[finding.rule_id]
|
|
178
|
+
result: dict[str, Any] = {
|
|
179
|
+
"ruleId": finding.rule_id,
|
|
180
|
+
"level": _sarif_level(finding.severity),
|
|
181
|
+
"message": {"text": finding.message},
|
|
182
|
+
"properties": {
|
|
183
|
+
"actionId": finding.action_id,
|
|
184
|
+
"approvalId": finding.approval_id,
|
|
185
|
+
"standards": list(finding.standards),
|
|
186
|
+
"remediation": spec.remediation,
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
if source_name and finding.line is not None:
|
|
190
|
+
result["locations"] = [
|
|
191
|
+
{
|
|
192
|
+
"physicalLocation": {
|
|
193
|
+
"artifactLocation": {"uri": source_name},
|
|
194
|
+
"region": {"startLine": finding.line},
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
]
|
|
198
|
+
results.append(result)
|
|
199
|
+
return {
|
|
200
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
201
|
+
"version": "2.1.0",
|
|
202
|
+
"runs": [
|
|
203
|
+
{
|
|
204
|
+
"automationDetails": {"id": document["run"]["run_id"]},
|
|
205
|
+
"tool": {
|
|
206
|
+
"driver": {
|
|
207
|
+
"name": "AgentAck",
|
|
208
|
+
"version": __version__,
|
|
209
|
+
"informationUri": "https://github.com/sylvesterkaczmarek/agentack",
|
|
210
|
+
"rules": rules,
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
"properties": _run_properties(document),
|
|
214
|
+
"results": results,
|
|
215
|
+
}
|
|
216
|
+
],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _adapter_rule_id(label: str) -> str:
|
|
221
|
+
slug = re.sub(r"[^A-Z0-9]+", "_", label.upper()).strip("_")
|
|
222
|
+
return f"ADAPTER_{slug}"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def adapter_sarif_payload(result: AdapterTestResult, document: dict[str, Any]) -> dict[str, Any]:
|
|
226
|
+
rules = []
|
|
227
|
+
results = []
|
|
228
|
+
for check in result.checks:
|
|
229
|
+
rule_id = _adapter_rule_id(check.label)
|
|
230
|
+
rules.append(
|
|
231
|
+
{
|
|
232
|
+
"id": rule_id,
|
|
233
|
+
"name": rule_id,
|
|
234
|
+
"shortDescription": {"text": check.label},
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
if check.status not in {"FAIL", "INCOMPLETE"}:
|
|
238
|
+
continue
|
|
239
|
+
results.append(
|
|
240
|
+
{
|
|
241
|
+
"ruleId": rule_id,
|
|
242
|
+
"level": "error" if check.status == "FAIL" else "warning",
|
|
243
|
+
"message": {"text": check.detail},
|
|
244
|
+
"properties": {"checkStatus": check.status},
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
return {
|
|
248
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
249
|
+
"version": "2.1.0",
|
|
250
|
+
"runs": [
|
|
251
|
+
{
|
|
252
|
+
"automationDetails": {"id": document["run"]["run_id"]},
|
|
253
|
+
"tool": {
|
|
254
|
+
"driver": {
|
|
255
|
+
"name": "AgentAck",
|
|
256
|
+
"version": __version__,
|
|
257
|
+
"informationUri": "https://github.com/sylvesterkaczmarek/agentack",
|
|
258
|
+
"rules": rules,
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
"properties": _run_properties(document),
|
|
262
|
+
"results": results,
|
|
263
|
+
}
|
|
264
|
+
],
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def write_sarif(path: str | Path, payload: dict[str, Any]) -> None:
|
|
269
|
+
target = Path(path)
|
|
270
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
agentack/ux.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from .adapters.base import AdapterStatus, AdapterTestResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _line(label: str, status: str, width: int = 28) -> str:
|
|
9
|
+
return f"{label:<{width}} {status}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_adapter_test(result: AdapterTestResult) -> str:
|
|
13
|
+
lines = [f"AgentAck {result.status}", f"Integration: {result.display_name}", ""]
|
|
14
|
+
for check in result.checks:
|
|
15
|
+
lines.append(_line(check.label, check.status))
|
|
16
|
+
problems = [check for check in result.checks if check.status in {"FAIL", "INCOMPLETE"}]
|
|
17
|
+
if problems:
|
|
18
|
+
lines.append("")
|
|
19
|
+
lines.append("Details")
|
|
20
|
+
for check in problems:
|
|
21
|
+
lines.append(f"- {check.label}: {check.detail}")
|
|
22
|
+
if result.notes:
|
|
23
|
+
lines.append("")
|
|
24
|
+
lines.append("Notes")
|
|
25
|
+
lines.extend(f"- {note}" for note in result.notes)
|
|
26
|
+
return "\n".join(lines)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_doctor(statuses: Iterable[AdapterStatus], discovered: Iterable[tuple[str, str, str | None]]) -> str:
|
|
30
|
+
status_list = list(statuses)
|
|
31
|
+
lines = ["AgentAck doctor", ""]
|
|
32
|
+
for status in status_list:
|
|
33
|
+
state = "READY" if status.testable else "NOT FOUND" if not status.installed else "DETECTED"
|
|
34
|
+
extra = status.version or status.executable or ""
|
|
35
|
+
lines.append(f"{status.display_name:<20} {state:<10} {extra}".rstrip())
|
|
36
|
+
if status.detail:
|
|
37
|
+
lines.append(f" {status.detail}")
|
|
38
|
+
for display_name, state, detail in discovered:
|
|
39
|
+
lines.append(f"{display_name:<20} {state:<10}".rstrip())
|
|
40
|
+
if detail:
|
|
41
|
+
lines.append(f" {detail}")
|
|
42
|
+
ready = [status.name for status in status_list if status.testable]
|
|
43
|
+
lines.append("")
|
|
44
|
+
if ready:
|
|
45
|
+
lines.append("Run: " + " | ".join(f"agentack test {name}" for name in ready))
|
|
46
|
+
else:
|
|
47
|
+
lines.append("No live AgentAck adapter is ready on this machine. `agentack demo` still works without an agent account.")
|
|
48
|
+
return "\n".join(lines)
|