codi-api-agent 0.3.1__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.
- api_agent/__init__.py +60 -0
- api_agent/__main__.py +5 -0
- api_agent/_version.py +2 -0
- api_agent/agent.py +6901 -0
- api_agent/catalog.py +147 -0
- api_agent/chart.py +144 -0
- api_agent/cli.py +31 -0
- api_agent/config.py +296 -0
- api_agent/doc_extract.py +431 -0
- api_agent/graphql_loader.py +309 -0
- api_agent/llm.py +470 -0
- api_agent/log.py +138 -0
- api_agent/metrics.py +1030 -0
- api_agent/openapi_loader.py +560 -0
- api_agent/prompts/__init__.py +59 -0
- api_agent/prompts/advisory.py +52 -0
- api_agent/prompts/executor.py +175 -0
- api_agent/prompts/judges.py +207 -0
- api_agent/prompts/support.py +59 -0
- api_agent/prompts/synthesis.py +350 -0
- api_agent/router.py +294 -0
- api_agent/schemas.py +250 -0
- api_agent/spec_convert.py +86 -0
- api_agent/sql_loader.py +1254 -0
- api_agent/supervisor.py +178 -0
- api_agent/ui.py +918 -0
- codi_api_agent-0.3.1.dist-info/METADATA +260 -0
- codi_api_agent-0.3.1.dist-info/RECORD +31 -0
- codi_api_agent-0.3.1.dist-info/WHEEL +5 -0
- codi_api_agent-0.3.1.dist-info/entry_points.txt +2 -0
- codi_api_agent-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""Load an OpenAPI 3.x spec and turn it into catalog Tools.
|
|
2
|
+
|
|
3
|
+
This is the mechanism by which a *real* API spec becomes agent-callable tools —
|
|
4
|
+
the same path your own APIs will use. It produces the same `Tool` objects the
|
|
5
|
+
hand-built catalog uses, so the rest of the pipeline is unchanged.
|
|
6
|
+
|
|
7
|
+
Design choices that mirror the architecture:
|
|
8
|
+
* READ-ONLY by default — only GET/HEAD operations are exposed. This enforces
|
|
9
|
+
the agreed "read-only / Tier-2 GET-only" guardrail at the loader boundary.
|
|
10
|
+
* One operation -> one tool (operationId -> name, summary/description -> the
|
|
11
|
+
NL the model routes on, parameters -> the tool's args, server -> base URL).
|
|
12
|
+
|
|
13
|
+
NOT yet implemented (deferred to Phase-3 hardening): SSRF egress controls on the
|
|
14
|
+
outbound HTTP, and per-user credential injection. The loader currently fetches
|
|
15
|
+
the spec URL and calls endpoints as given — fine for dev against trusted specs
|
|
16
|
+
(e.g. Petstore), but it must be gated before pointing it at untrusted specs.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import replace
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
|
|
27
|
+
from .catalog import Catalog, Tool
|
|
28
|
+
|
|
29
|
+
_UA = {"User-Agent": "api-agent-demo/0.1"}
|
|
30
|
+
READ_METHODS = {"get", "head"} # only these become CALLABLE tools (read-only guardrail)
|
|
31
|
+
_ALL_METHODS = {"get", "head", "post", "put", "patch", "delete"} # documented, not all called
|
|
32
|
+
_SCALARS = {"string", "integer", "number", "boolean"}
|
|
33
|
+
# Infrastructure / auth headers are handled by the agent (UA set here, auth injected from
|
|
34
|
+
# the Auth field) — never expose them as model-fillable parameters, or the model invents a
|
|
35
|
+
# value (e.g. a fake Cookie) that clobbers the real one.
|
|
36
|
+
_NOISE_HEADERS = {
|
|
37
|
+
"cookie", "authorization", "user-agent", "content-type", "accept",
|
|
38
|
+
"accept-encoding", "content-length", "host", "connection",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Privacy guardrail: personal / contact details must never reach the user. We redact such
|
|
42
|
+
# values in API responses BEFORE they reach the model (so it physically cannot leak them).
|
|
43
|
+
# Redaction is by field NAME (precise — won't blank numeric ids) plus email-pattern masking
|
|
44
|
+
# for free-text fields. Business data (names, ids, statuses, counts) flows through untouched.
|
|
45
|
+
# Sensitive identifiers whose VALUE is itself the PII — always redact, even when the name
|
|
46
|
+
# ends in `_id` (e.g. tax_id, national_id).
|
|
47
|
+
_PII_ALWAYS_RE = re.compile(
|
|
48
|
+
r"\bssn\b|social[-_ ]?security|tax[-_ ]?id|\bein\b|national[-_ ]?id|passport|"
|
|
49
|
+
r"date[-_ ]?of[-_ ]?birth|\bdob\b|birth[-_ ]?date|birthday",
|
|
50
|
+
re.IGNORECASE,
|
|
51
|
+
)
|
|
52
|
+
# Other contact-detail fields — redact unless the name is a `_id` reference (address_id is a
|
|
53
|
+
# foreign key, not the address itself). Phone is handled separately (value-aware) below.
|
|
54
|
+
_PII_KEY_RE = re.compile(
|
|
55
|
+
r"e[-_ ]?mail|address|street|\bzip\b|postal",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
# Phone-ish fields — but only redact when the VALUE is a real phone NUMBER. A country calling /
|
|
59
|
+
# dialing code ("+33", "91") is general public info, NOT personal, so it must NOT be redacted.
|
|
60
|
+
_PHONE_KEY_RE = re.compile(r"phone|mobile|\bcell\b|\bfax\b|telephone|whatsapp", re.IGNORECASE)
|
|
61
|
+
# ...unless the field is explicitly a CODE/prefix (country/dialing/area code) — never PII.
|
|
62
|
+
_CODE_KEY_RE = re.compile(
|
|
63
|
+
r"(?:phone|dial(?:ing)?|call(?:ing)?|country|area|std|idd)[-_ ]?(?:code|prefix)|"
|
|
64
|
+
r"prefix|callingcode|dialcode|countrycode",
|
|
65
|
+
re.IGNORECASE,
|
|
66
|
+
)
|
|
67
|
+
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
|
68
|
+
_REDACTED = "[redacted]"
|
|
69
|
+
|
|
70
|
+
# Some APIs answer an expired/invalid session with HTTP 200 and a "please log in" body (a login
|
|
71
|
+
# redirect) instead of 401. We detect that in small responses and treat it as an auth failure,
|
|
72
|
+
# so it's reported clearly ("re-authenticate") instead of as a vague "no data".
|
|
73
|
+
_AUTH_FAIL_BODY = re.compile(
|
|
74
|
+
r"session (?:has )?expired|please log\s?in|log\s?in again|not (?:authenticated|authorized|logged)|"
|
|
75
|
+
r"authentication (?:failed|required)|unauthenticated|invalid (?:session|token|credential)|"
|
|
76
|
+
r"expired (?:token|session)|login required|\"state\"\s*:\s*\"login\"",
|
|
77
|
+
re.IGNORECASE,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_pii_key(key: str) -> bool:
|
|
82
|
+
k = key.lower()
|
|
83
|
+
if _PII_ALWAYS_RE.search(k):
|
|
84
|
+
return True
|
|
85
|
+
if k == "id" or k.endswith("_id"): # a reference (e.g. address_id), not the PII itself
|
|
86
|
+
return False
|
|
87
|
+
return bool(_PII_KEY_RE.search(k))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _has_phone_number(v) -> bool:
|
|
91
|
+
"""True if a value looks like a real phone NUMBER (>=7 digits) rather than a short country /
|
|
92
|
+
dialing code. So `phone: "33"` (a calling code) is kept, but `phone: "+1 202-555-0143"` is
|
|
93
|
+
redacted. Recurses into lists (e.g. a country's list of dialing codes)."""
|
|
94
|
+
if isinstance(v, (list, tuple)):
|
|
95
|
+
return any(_has_phone_number(x) for x in v)
|
|
96
|
+
return len(re.sub(r"\D", "", str(v))) >= 7
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_phone_field(key: str) -> bool:
|
|
100
|
+
k = key.lower()
|
|
101
|
+
return bool(_PHONE_KEY_RE.search(k)) and not _CODE_KEY_RE.search(k)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _redact_text(s: str) -> str:
|
|
105
|
+
"""Mask email addresses anywhere in a string (catches PII in free-text fields)."""
|
|
106
|
+
return _EMAIL_RE.sub(_REDACTED, s)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
# Loading / parsing
|
|
111
|
+
# --------------------------------------------------------------------------- #
|
|
112
|
+
def load_openapi(source: str, methods: set[str] | None = None,
|
|
113
|
+
max_operations: int = 1000, timeout: int = 20,
|
|
114
|
+
auth_header: str | None = None, auth_value: str | None = None) -> Catalog:
|
|
115
|
+
"""Load a spec (URL or local path) and build a read-only Catalog from it.
|
|
116
|
+
|
|
117
|
+
Optionally inject an auth header into every call — covers API-key-in-header
|
|
118
|
+
and bearer tokens, e.g. ``auth_header='Authorization', auth_value='Bearer <t>'``
|
|
119
|
+
or ``auth_header='X-API-Key', auth_value='<key>'``.
|
|
120
|
+
"""
|
|
121
|
+
spec, origin = load_spec(source, timeout)
|
|
122
|
+
auth = (auth_header, auth_value) if auth_header and auth_value else None
|
|
123
|
+
return build_catalog(spec, origin, methods=methods or READ_METHODS,
|
|
124
|
+
max_operations=max_operations, auth=auth)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_spec(source: str, timeout: int = 20) -> tuple[dict, str | None]:
|
|
128
|
+
if source.startswith(("http://", "https://")):
|
|
129
|
+
resp = requests.get(source, timeout=timeout, headers=_UA)
|
|
130
|
+
resp.raise_for_status()
|
|
131
|
+
return _parse(resp.text), source
|
|
132
|
+
with open(source, encoding="utf-8") as f:
|
|
133
|
+
return _parse(f.read()), None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse(text: str) -> dict:
|
|
137
|
+
if text.lstrip().startswith("{"):
|
|
138
|
+
return json.loads(text)
|
|
139
|
+
try:
|
|
140
|
+
import yaml
|
|
141
|
+
except ImportError as e: # pragma: no cover
|
|
142
|
+
raise RuntimeError("PyYAML is required for YAML specs: pip install pyyaml") from e
|
|
143
|
+
data = yaml.safe_load(text)
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
raise ValueError("Spec did not parse to an object.")
|
|
146
|
+
return data
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --------------------------------------------------------------------------- #
|
|
150
|
+
# Spec -> Catalog
|
|
151
|
+
# --------------------------------------------------------------------------- #
|
|
152
|
+
def build_catalog(spec: dict, origin: str | None = None,
|
|
153
|
+
methods: set[str] = READ_METHODS, max_operations: int = 1000,
|
|
154
|
+
auth: tuple[str, str] | None = None) -> Catalog:
|
|
155
|
+
base = _base_url(spec, origin)
|
|
156
|
+
tools: list[Tool] = []
|
|
157
|
+
reference: list[dict] = [] # documentation for ALL ops (incl. writes); never called
|
|
158
|
+
seen: set[str] = set()
|
|
159
|
+
for path, item in (spec.get("paths") or {}).items():
|
|
160
|
+
if not isinstance(item, dict):
|
|
161
|
+
continue
|
|
162
|
+
shared = item.get("parameters") or []
|
|
163
|
+
for method, op in item.items():
|
|
164
|
+
m = method.lower()
|
|
165
|
+
if m not in _ALL_METHODS or not isinstance(op, dict):
|
|
166
|
+
continue
|
|
167
|
+
params = list(shared) + list(op.get("parameters") or [])
|
|
168
|
+
if len(reference) < 2000:
|
|
169
|
+
reference.append(_reference_entry(spec, path, m, op, params))
|
|
170
|
+
if m in methods and len(tools) < max_operations:
|
|
171
|
+
tools.append(_build_tool(spec, base, path, m, op, params, seen, auth))
|
|
172
|
+
return Catalog(tools, reference=reference)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _base_url(spec: dict, origin: str | None) -> str:
|
|
176
|
+
servers = spec.get("servers") or []
|
|
177
|
+
url = (servers[0].get("url") if servers and isinstance(servers[0], dict) else "") or ""
|
|
178
|
+
# Swagger 2.0 has no `servers`; it uses host + basePath + schemes.
|
|
179
|
+
if not url and spec.get("host"):
|
|
180
|
+
scheme = (spec.get("schemes") or ["https"])[0]
|
|
181
|
+
return f"{scheme}://{spec['host']}{spec.get('basePath', '')}".rstrip("/")
|
|
182
|
+
if url.startswith(("http://", "https://")):
|
|
183
|
+
return url.rstrip("/")
|
|
184
|
+
if origin: # resolve a relative server url (e.g. "/api/v3") against the spec origin
|
|
185
|
+
p = urlparse(origin)
|
|
186
|
+
root = f"{p.scheme}://{p.netloc}"
|
|
187
|
+
return (root + "/" + url.lstrip("/")).rstrip("/") if url else root
|
|
188
|
+
return url.rstrip("/")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _build_tool(spec: dict, base: str, path: str, method: str,
|
|
192
|
+
op: dict, params: list, seen: set[str],
|
|
193
|
+
auth: tuple[str, str] | None = None) -> Tool:
|
|
194
|
+
name = _unique(_sanitize(op.get("operationId") or f"{method}_{path}"), seen)
|
|
195
|
+
summary = (op.get("summary") or "").strip()
|
|
196
|
+
desc = (op.get("description") or "").strip()
|
|
197
|
+
description = ((summary + ("\n" + desc if desc else "")).strip() or f"{method.upper()} {path}")[:300]
|
|
198
|
+
|
|
199
|
+
properties: dict = {}
|
|
200
|
+
required: list[str] = []
|
|
201
|
+
locations: list[tuple[str, str]] = [] # (name, in)
|
|
202
|
+
for p in params:
|
|
203
|
+
p = _deref(spec, p)
|
|
204
|
+
if not isinstance(p, dict):
|
|
205
|
+
continue
|
|
206
|
+
pname, loc = p.get("name"), p.get("in")
|
|
207
|
+
if not pname or loc not in ("query", "path", "header"):
|
|
208
|
+
continue
|
|
209
|
+
if loc == "header" and pname.lower() in _NOISE_HEADERS:
|
|
210
|
+
continue # auth/infra header — the agent sets these, not the model
|
|
211
|
+
schema = _deref(spec, p.get("schema") or {})
|
|
212
|
+
jtype = schema.get("type", "string") if isinstance(schema, dict) else "string"
|
|
213
|
+
if jtype not in _SCALARS:
|
|
214
|
+
jtype = "string"
|
|
215
|
+
# Include the parameter's example so the model knows the expected FORMAT — crucial
|
|
216
|
+
# for things like a daterange (YYYY-MM-DD|YYYY-MM-DD vs a relative token vs unix ts).
|
|
217
|
+
pdesc = (p.get("description") or "").strip()
|
|
218
|
+
ex = p.get("example", schema.get("example") if isinstance(schema, dict) else None)
|
|
219
|
+
if ex not in (None, ""):
|
|
220
|
+
pdesc = (pdesc + f" (example: {ex})").strip()
|
|
221
|
+
properties[pname] = {"type": jtype, "description": pdesc[:160]}
|
|
222
|
+
if p.get("required") or loc == "path":
|
|
223
|
+
required.append(pname)
|
|
224
|
+
locations.append((pname, loc))
|
|
225
|
+
|
|
226
|
+
parameters = {"type": "object", "properties": properties, "required": required}
|
|
227
|
+
return Tool(
|
|
228
|
+
name=name,
|
|
229
|
+
description=description,
|
|
230
|
+
parameters=parameters,
|
|
231
|
+
data_type="structured",
|
|
232
|
+
fn=_make_executor(base, path, method, dict(locations), auth),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _reference_entry(spec: dict, path: str, method: str, op: dict, params: list) -> dict:
|
|
237
|
+
"""A documentation record for ONE operation (any method) — its signature and params,
|
|
238
|
+
so the agent can DESCRIBE it (including write ops) without ever calling it."""
|
|
239
|
+
entry = {
|
|
240
|
+
"name": _sanitize(op.get("operationId") or f"{method}_{path}"),
|
|
241
|
+
"method": method.upper(),
|
|
242
|
+
"path": path,
|
|
243
|
+
"summary": (op.get("summary") or "").strip(),
|
|
244
|
+
"description": (op.get("description") or "").strip()[:300],
|
|
245
|
+
"params": [],
|
|
246
|
+
"body": [],
|
|
247
|
+
}
|
|
248
|
+
for p in params:
|
|
249
|
+
p = _deref(spec, p)
|
|
250
|
+
if not isinstance(p, dict):
|
|
251
|
+
continue
|
|
252
|
+
pname, loc = p.get("name"), p.get("in")
|
|
253
|
+
if not pname or loc not in ("query", "path", "header"):
|
|
254
|
+
continue
|
|
255
|
+
if loc == "header" and pname.lower() in _NOISE_HEADERS:
|
|
256
|
+
continue
|
|
257
|
+
schema = _deref(spec, p.get("schema") or {})
|
|
258
|
+
jtype = schema.get("type", "string") if isinstance(schema, dict) else "string"
|
|
259
|
+
entry["params"].append({
|
|
260
|
+
"name": pname, "in": loc, "required": bool(p.get("required") or loc == "path"),
|
|
261
|
+
"type": jtype,
|
|
262
|
+
"example": p.get("example", schema.get("example") if isinstance(schema, dict) else None),
|
|
263
|
+
})
|
|
264
|
+
# Request body (for writes) — pull fields from the schema's properties, or from its example
|
|
265
|
+
# (the Postman-converted specs usually only carry an example object).
|
|
266
|
+
rb = _deref(spec, op.get("requestBody") or {})
|
|
267
|
+
content = rb.get("content") if isinstance(rb, dict) else None
|
|
268
|
+
jsonc = (content.get("application/json") if isinstance(content, dict) else None) or {}
|
|
269
|
+
schema = _deref(spec, jsonc.get("schema") or {}) if isinstance(jsonc, dict) else {}
|
|
270
|
+
req = set(schema.get("required") or []) if isinstance(schema, dict) else set()
|
|
271
|
+
props = schema.get("properties") if isinstance(schema, dict) else None
|
|
272
|
+
example = schema.get("example") if isinstance(schema, dict) else None
|
|
273
|
+
if isinstance(props, dict):
|
|
274
|
+
for fname, fdef in props.items():
|
|
275
|
+
fdef = _deref(spec, fdef) if isinstance(fdef, dict) else {}
|
|
276
|
+
entry["body"].append({
|
|
277
|
+
"name": fname, "required": fname in req,
|
|
278
|
+
"type": (fdef.get("type") if isinstance(fdef, dict) else "") or "",
|
|
279
|
+
"example": fdef.get("example") if isinstance(fdef, dict) else None,
|
|
280
|
+
})
|
|
281
|
+
elif isinstance(example, dict):
|
|
282
|
+
for fname, val in example.items():
|
|
283
|
+
entry["body"].append({"name": fname, "required": fname in req, "type": "", "example": val})
|
|
284
|
+
return entry
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def format_reference(entry: dict) -> str:
|
|
288
|
+
"""Render a reference entry as a readable block (used as evidence for documentation answers)."""
|
|
289
|
+
label = f"[{entry['label']}] " if entry.get("label") else ""
|
|
290
|
+
head = f"{label}{entry['method']} {entry['path']}"
|
|
291
|
+
if entry.get("summary"):
|
|
292
|
+
head += f" — {entry['summary']}"
|
|
293
|
+
lines = [head]
|
|
294
|
+
if entry.get("description"):
|
|
295
|
+
lines.append(entry["description"])
|
|
296
|
+
if entry["params"]:
|
|
297
|
+
lines.append("Parameters:")
|
|
298
|
+
for p in entry["params"]:
|
|
299
|
+
meta = ", ".join([p["in"]] + ([p["type"]] if p["type"] else []) + (["required"] if p["required"] else []))
|
|
300
|
+
ex = f" — example: {p['example']}" if p.get("example") not in (None, "") else ""
|
|
301
|
+
lines.append(f" - {p['name']} ({meta}){ex}")
|
|
302
|
+
if entry["body"]:
|
|
303
|
+
# Note: many converted specs don't mark which body fields are required, so we only
|
|
304
|
+
# label the ones explicitly marked required — never assert "optional" (we don't know).
|
|
305
|
+
lines.append("Request body fields:")
|
|
306
|
+
for b in entry["body"]:
|
|
307
|
+
tags = ", ".join(([b["type"]] if b["type"] else []) + (["required"] if b["required"] else []))
|
|
308
|
+
tags = f" ({tags})" if tags else ""
|
|
309
|
+
ex = f" — example: {b['example']}" if b.get("example") not in (None, "") else ""
|
|
310
|
+
lines.append(f" - {b['name']}{tags}{ex}")
|
|
311
|
+
if not entry["params"] and not entry["body"]:
|
|
312
|
+
lines.append("(no parameters)")
|
|
313
|
+
return "\n".join(lines)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _make_executor(base: str, path: str, method: str, locations: dict[str, str],
|
|
317
|
+
auth: tuple[str, str] | None = None):
|
|
318
|
+
# Tolerate the model mangling array/odd param names: it often emits `clusterIds` (or
|
|
319
|
+
# `clusterids`) for a spec param literally named `clusterIds[]`. Map a normalized form
|
|
320
|
+
# (trailing [] stripped, lowercased) back to the REAL param name so the value still gets sent.
|
|
321
|
+
_norm = {re.sub(r"\[\]$", "", k).lower(): k for k in locations}
|
|
322
|
+
|
|
323
|
+
def fn(args: dict, timeout: int, list_limit: int | None = None) -> dict:
|
|
324
|
+
n = list_limit or 50 # how many items of a list response to keep (per-query override)
|
|
325
|
+
url = base + path
|
|
326
|
+
query: dict = {}
|
|
327
|
+
headers = dict(_UA)
|
|
328
|
+
if auth:
|
|
329
|
+
headers[auth[0]] = auth[1]
|
|
330
|
+
for pname, value in (args or {}).items():
|
|
331
|
+
real = pname if pname in locations else _norm.get(re.sub(r"\[\]$", "", pname).lower())
|
|
332
|
+
loc = locations.get(real) if real else None
|
|
333
|
+
if loc == "path":
|
|
334
|
+
url = url.replace("{" + real + "}", str(value))
|
|
335
|
+
elif loc == "query":
|
|
336
|
+
query[real] = value # real keeps the spec's exact name, e.g. clusterIds[]
|
|
337
|
+
elif loc == "header":
|
|
338
|
+
# The configured auth header wins — a model-supplied value (e.g. an invented
|
|
339
|
+
# Cookie) must never override the real credential injected from the Auth field.
|
|
340
|
+
if auth and real.lower() == auth[0].lower():
|
|
341
|
+
continue
|
|
342
|
+
headers[real] = str(value)
|
|
343
|
+
# A required path param the model left out (classically GitHub's `path` for a repo's ROOT
|
|
344
|
+
# contents) is filled with empty string so we list the collection/root rather than send a
|
|
345
|
+
# literal `{path}` and 404. Strip only within the PATH — NEVER the scheme/host: a base like
|
|
346
|
+
# `https://{host}` (a tenant placeholder) must not collapse to `https:///` with no host.
|
|
347
|
+
_hp = re.match(r"(https?://[^/]+)(.*)", url)
|
|
348
|
+
if _hp:
|
|
349
|
+
url = (_hp.group(1) + re.sub(r"\{[^}]+\}", "", _hp.group(2))).rstrip("/")
|
|
350
|
+
else:
|
|
351
|
+
url = re.sub(r"\{[^}]+\}", "", url).rstrip("/")
|
|
352
|
+
try:
|
|
353
|
+
resp = requests.request(method.upper(), url, params=query, headers=headers, timeout=timeout)
|
|
354
|
+
except Exception as e:
|
|
355
|
+
return {"ok": False, "error": str(e), "content": "", "source": url}
|
|
356
|
+
src = resp.url
|
|
357
|
+
if resp.status_code >= 400:
|
|
358
|
+
return {"ok": False, "error": f"HTTP {resp.status_code}", "content": resp.text[:500], "source": src}
|
|
359
|
+
# 200-but-not-logged-in: a small "session expired / please log in" body means the auth
|
|
360
|
+
# (cookie/token) is invalid. Flag it as an auth failure (the agent keys on "401").
|
|
361
|
+
body = resp.text
|
|
362
|
+
if len(body) < 800 and _AUTH_FAIL_BODY.search(body):
|
|
363
|
+
return {"ok": False, "error": "HTTP 401 — session/authentication expired",
|
|
364
|
+
"content": body[:300], "source": src}
|
|
365
|
+
content = None
|
|
366
|
+
if "json" in resp.headers.get("content-type", "").lower():
|
|
367
|
+
try:
|
|
368
|
+
payload = resp.json()
|
|
369
|
+
except Exception:
|
|
370
|
+
payload = None
|
|
371
|
+
if isinstance(payload, (dict, list)):
|
|
372
|
+
emsg = _error_envelope(payload)
|
|
373
|
+
if emsg: # HTTP 200, but the body is an application error envelope (status:"failure"…)
|
|
374
|
+
return {"ok": False, "error": emsg,
|
|
375
|
+
"content": _compact_json(payload, max_items=n), "source": src}
|
|
376
|
+
content = _compact_json(payload, max_items=n) # redacts PII field-by-field
|
|
377
|
+
if content is None:
|
|
378
|
+
content = _redact_text(resp.text)
|
|
379
|
+
return {"ok": True, "content": content[: max(8000, n * 600)], "source": src}
|
|
380
|
+
|
|
381
|
+
return fn
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# Keys that merely WRAP a response's primary collection (REST envelopes + GraphQL + the ARQ
|
|
385
|
+
# `{status, data}` shape) — a list under one of these is the main payload, not an incidental
|
|
386
|
+
# sub-list, so it should get the full item cap rather than the small nested-list cap.
|
|
387
|
+
_LIST_ENVELOPE_KEYS = frozenset({
|
|
388
|
+
"data", "result", "results", "items", "rows", "records", "payload", "list", "edges", "nodes",
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
_ERROR_STATUS = frozenset({"failure", "fail", "failed", "error"})
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _error_envelope(payload) -> str | None:
|
|
395
|
+
"""Detect an application-level error returned with a 200 body instead of an HTTP error status.
|
|
396
|
+
|
|
397
|
+
Some backends wrap failures in an envelope (e.g. the ARQ shape
|
|
398
|
+
``{"status_code": 502, "status": "failure", "message": "Warehouse query failed: …"}``) and return
|
|
399
|
+
it with HTTP 200 — so a failed call would otherwise be counted as a SUCCESS with an error message
|
|
400
|
+
buried in its body, and the real detail never surfaces. Return the human-readable error message
|
|
401
|
+
when the payload is clearly such an envelope, else ``None``.
|
|
402
|
+
|
|
403
|
+
Conservative on purpose (a real data record whose own ``status`` field is "failed" must NOT be
|
|
404
|
+
mistaken for an API error): trigger only on a top-level ``status_code`` ≥ 400, OR an error-ish
|
|
405
|
+
``status`` that comes WITH an error message and WITHOUT any data payload.
|
|
406
|
+
"""
|
|
407
|
+
if not isinstance(payload, dict):
|
|
408
|
+
return None
|
|
409
|
+
code = payload.get("status_code")
|
|
410
|
+
code_bad = isinstance(code, int) and not isinstance(code, bool) and code >= 400
|
|
411
|
+
status = str(payload.get("status", "")).strip().lower()
|
|
412
|
+
msg = ""
|
|
413
|
+
for k in ("message", "error", "detail", "msg", "error_message", "reason"):
|
|
414
|
+
v = payload.get(k)
|
|
415
|
+
if isinstance(v, str) and v.strip():
|
|
416
|
+
msg = v.strip()
|
|
417
|
+
break
|
|
418
|
+
if not msg and isinstance(payload.get("errors"), list) and payload["errors"]:
|
|
419
|
+
first = payload["errors"][0]
|
|
420
|
+
msg = (first if isinstance(first, str) else str(first)).strip()
|
|
421
|
+
has_data = any(k in payload for k in ("data", "rows", "results", "items", "records"))
|
|
422
|
+
status_err = status in _ERROR_STATUS and bool(msg) and not has_data
|
|
423
|
+
if not (code_bad or status_err):
|
|
424
|
+
return None
|
|
425
|
+
prefix = f"HTTP {code}: " if code_bad else ""
|
|
426
|
+
# Keep enough of the message that the ACTUAL cause survives — a DB/SQL error puts the useful part
|
|
427
|
+
# (the missing signature + the "HINT: No function matches …" tail) well past 300 chars.
|
|
428
|
+
return (prefix + msg)[:800] if (msg or prefix) else f"upstream error (status={status or code})"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _compact_json(data, max_items: int = 30, max_fields: int = 20,
|
|
432
|
+
max_str: int = 300, max_depth: int = 3) -> str:
|
|
433
|
+
"""Make a JSON response digestible WITHOUT losing the meaningful fields.
|
|
434
|
+
|
|
435
|
+
The bulk of a typical API record is noise — every object carries ~dozens of
|
|
436
|
+
``*_url`` links — while the substance is often *nested* (a commit's ``message``
|
|
437
|
+
lives under ``commit.message``, an author's ``login`` under ``author.login``).
|
|
438
|
+
So we keep scalar values at any nesting level up to ``max_depth`` and drop the
|
|
439
|
+
noise (``*_url`` link fields; ``url``/``html_url`` are kept as the one canonical
|
|
440
|
+
link). That lets MANY records fit the budget instead of one bloated one, while
|
|
441
|
+
still surfacing nested substance like commit messages. Single objects are slimmed
|
|
442
|
+
the same way (detail preserved)."""
|
|
443
|
+
def is_noise(key: str) -> bool:
|
|
444
|
+
k = key.lower()
|
|
445
|
+
return k.endswith("_url") and k != "html_url"
|
|
446
|
+
|
|
447
|
+
# `eff` is the list-nesting level for the cap, and it does NOT count "envelope" wrapper keys —
|
|
448
|
+
# a response's PRIMARY collection is often wrapped (`{"data":{"rows":[...]}}`, `{"results":[...]}`,
|
|
449
|
+
# GraphQL `{"data":{...}}`, or the app's `{"status","data":{...}}`). Without this those primary
|
|
450
|
+
# rows land at object-depth ≥2 and get truncated to 5. Real incidental sub-lists (an order's
|
|
451
|
+
# `steps`) sit under a NON-envelope key, so they still cap at 5.
|
|
452
|
+
def slim_list(items: list, depth: int, eff: int):
|
|
453
|
+
cap = max_items if eff <= 1 else 5
|
|
454
|
+
out = [slim(x, depth + 1, eff + 1) for x in items[:cap]]
|
|
455
|
+
# Always keep the TOTAL when truncating — even for a nested list — so counts/aggregates
|
|
456
|
+
# (e.g. "which continent has the most countries" over each continent's nested `countries`)
|
|
457
|
+
# can read the real size instead of silently seeing only the first few.
|
|
458
|
+
if len(items) > cap:
|
|
459
|
+
return {"total": len(items), "showing": cap, "items": out}
|
|
460
|
+
return out
|
|
461
|
+
|
|
462
|
+
def slim(val, depth: int, eff: int):
|
|
463
|
+
if isinstance(val, dict):
|
|
464
|
+
out: dict = {}
|
|
465
|
+
for k, v in val.items():
|
|
466
|
+
if len(out) >= max_fields:
|
|
467
|
+
break
|
|
468
|
+
if is_noise(k):
|
|
469
|
+
continue
|
|
470
|
+
if _is_pii_key(k): # personal/contact detail — never surface its value
|
|
471
|
+
out[k] = _REDACTED
|
|
472
|
+
continue
|
|
473
|
+
if _is_phone_field(k) and _has_phone_number(v): # a real phone NUMBER (not a code)
|
|
474
|
+
out[k] = _REDACTED
|
|
475
|
+
continue
|
|
476
|
+
child_eff = eff if str(k).lower() in _LIST_ENVELOPE_KEYS else eff + 1
|
|
477
|
+
if isinstance(v, str):
|
|
478
|
+
out[k] = _redact_text(v[:max_str])
|
|
479
|
+
elif isinstance(v, (int, float, bool)) or v is None:
|
|
480
|
+
out[k] = v
|
|
481
|
+
elif isinstance(v, dict) and depth < max_depth:
|
|
482
|
+
s = slim(v, depth + 1, child_eff)
|
|
483
|
+
if s:
|
|
484
|
+
out[k] = s
|
|
485
|
+
elif isinstance(v, list) and depth < max_depth:
|
|
486
|
+
out[k] = slim_list(v, depth + 1, child_eff)
|
|
487
|
+
return out
|
|
488
|
+
if isinstance(val, list):
|
|
489
|
+
return slim_list(val, depth, eff)
|
|
490
|
+
return val[:max_str] if isinstance(val, str) else val
|
|
491
|
+
|
|
492
|
+
return json.dumps(slim(data, 0, 0), ensure_ascii=False)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# --------------------------------------------------------------------------- #
|
|
496
|
+
# helpers
|
|
497
|
+
# --------------------------------------------------------------------------- #
|
|
498
|
+
def _deref(spec: dict, node):
|
|
499
|
+
"""Resolve a single local ``$ref`` (``#/components/...``); else return node."""
|
|
500
|
+
if isinstance(node, dict) and "$ref" in node:
|
|
501
|
+
ref = node["$ref"]
|
|
502
|
+
if not ref.startswith("#/"):
|
|
503
|
+
return {}
|
|
504
|
+
target: object = spec
|
|
505
|
+
for part in ref[2:].split("/"):
|
|
506
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
507
|
+
target = target.get(part, {}) if isinstance(target, dict) else {}
|
|
508
|
+
return target
|
|
509
|
+
return node
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _sanitize(name: str) -> str:
|
|
513
|
+
return (re.sub(r"[^a-zA-Z0-9_-]", "_", name)[:64]) or "op"
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _unique(name: str, seen: set[str]) -> str:
|
|
517
|
+
out, i = name, 2
|
|
518
|
+
while out in seen:
|
|
519
|
+
out = f"{name}_{i}"[:64]
|
|
520
|
+
i += 1
|
|
521
|
+
seen.add(out)
|
|
522
|
+
return out
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# --------------------------------------------------------------------------- #
|
|
526
|
+
# Multiple specs
|
|
527
|
+
# --------------------------------------------------------------------------- #
|
|
528
|
+
def spec_label(source: str) -> str:
|
|
529
|
+
"""A short, readable name for a spec source (used to tag merged operations)."""
|
|
530
|
+
base = source.rstrip("/").split("/")[-1] or source
|
|
531
|
+
base = re.sub(r"\.(openapi|swagger)?\.?(ya?ml|json|graphql|gql|graphqls|sdl)$", "", base,
|
|
532
|
+
flags=re.IGNORECASE)
|
|
533
|
+
return base or source
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def merge_catalogs(labeled: list[tuple[str, Catalog]]) -> Catalog:
|
|
537
|
+
"""Combine several loaded catalogs into one so the agent can answer queries that
|
|
538
|
+
span multiple APIs. Each tool keeps its own base URL and auth (both are baked into
|
|
539
|
+
its executor), so specs with different credentials work side by side. Tool names are
|
|
540
|
+
made unique across specs, and — when more than one spec is loaded — each operation's
|
|
541
|
+
description is tagged with its source so the router/model knows which API it belongs to.
|
|
542
|
+
"""
|
|
543
|
+
multi = len(labeled) > 1
|
|
544
|
+
merged: dict[str, Tool] = {}
|
|
545
|
+
reference: list[dict] = []
|
|
546
|
+
for label, cat in labeled:
|
|
547
|
+
tag = _sanitize(label)
|
|
548
|
+
for name, tool in cat.tools.items():
|
|
549
|
+
new_name = name
|
|
550
|
+
if new_name in merged: # collision across specs — disambiguate by source
|
|
551
|
+
new_name = _unique(f"{name}__{tag}", set(merged))
|
|
552
|
+
desc = f"[{label}] {tool.description}" if multi else tool.description
|
|
553
|
+
merged[new_name] = replace(tool, name=new_name, description=desc)
|
|
554
|
+
for entry in getattr(cat, "reference", []):
|
|
555
|
+
reference.append({**entry, "label": label if multi else entry.get("label", "")})
|
|
556
|
+
out = Catalog(list(merged.values()), reference=reference)
|
|
557
|
+
# Carry the location resolver across the merge. Without this, loading the warehouse ALONGSIDE
|
|
558
|
+
# an HTTP spec silently disables location binding — the UI's normal multi-source setup.
|
|
559
|
+
out.locations = next((c.locations for _l, c in labeled if getattr(c, "locations", None)), None)
|
|
560
|
+
return out
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Every prompt in the system, grouped by the stage that uses it.
|
|
2
|
+
|
|
3
|
+
Extracted from `agent.py`, where 59,000 characters of instructions sat inline among the
|
|
4
|
+
control flow. They are re-exported here so `from api_agent.agent import SYNTHESIS_SYSTEM`
|
|
5
|
+
keeps working for existing callers and tests.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .synthesis import ( # noqa: F401
|
|
10
|
+
_TABLES_NOTE,
|
|
11
|
+
SYNTHESIS_SYSTEM,
|
|
12
|
+
_REPORT_MODE_NOTE,
|
|
13
|
+
_ANALYSIS_MODE_NOTE,
|
|
14
|
+
_JUDGEMENT_ALLOWED_NOTE,
|
|
15
|
+
_VOICE_NOTE,
|
|
16
|
+
_BRIEFING_MODE_NOTE,
|
|
17
|
+
_NO_TABLES_NOTE,
|
|
18
|
+
)
|
|
19
|
+
from .advisory import ( # noqa: F401
|
|
20
|
+
ADVISORY_SYSTEM,
|
|
21
|
+
)
|
|
22
|
+
from .executor import ( # noqa: F401
|
|
23
|
+
EXECUTOR_SYSTEM,
|
|
24
|
+
PLAN_SYSTEM,
|
|
25
|
+
SECTION_PLAN_SYSTEM,
|
|
26
|
+
)
|
|
27
|
+
from .judges import ( # noqa: F401
|
|
28
|
+
JUDGE_SYSTEM,
|
|
29
|
+
RESPONSIVENESS_SYSTEM,
|
|
30
|
+
COMMUNICATION_SYSTEM,
|
|
31
|
+
REVIEW_SYSTEM,
|
|
32
|
+
DOC_JUDGE_SYSTEM,
|
|
33
|
+
)
|
|
34
|
+
from .support import ( # noqa: F401
|
|
35
|
+
DOC_SYSTEM,
|
|
36
|
+
CACHE_MATCH_SYSTEM,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"_TABLES_NOTE",
|
|
41
|
+
"SYNTHESIS_SYSTEM",
|
|
42
|
+
"_REPORT_MODE_NOTE",
|
|
43
|
+
"_ANALYSIS_MODE_NOTE",
|
|
44
|
+
"_JUDGEMENT_ALLOWED_NOTE",
|
|
45
|
+
"_VOICE_NOTE",
|
|
46
|
+
"_BRIEFING_MODE_NOTE",
|
|
47
|
+
"_NO_TABLES_NOTE",
|
|
48
|
+
"ADVISORY_SYSTEM",
|
|
49
|
+
"EXECUTOR_SYSTEM",
|
|
50
|
+
"PLAN_SYSTEM",
|
|
51
|
+
"SECTION_PLAN_SYSTEM",
|
|
52
|
+
"JUDGE_SYSTEM",
|
|
53
|
+
"RESPONSIVENESS_SYSTEM",
|
|
54
|
+
"COMMUNICATION_SYSTEM",
|
|
55
|
+
"REVIEW_SYSTEM",
|
|
56
|
+
"DOC_JUDGE_SYSTEM",
|
|
57
|
+
"DOC_SYSTEM",
|
|
58
|
+
"CACHE_MATCH_SYSTEM",
|
|
59
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The ADVISORY prompt — what to DO about findings already established."""
|
|
2
|
+
|
|
3
|
+
ADVISORY_SYSTEM = """You are advising a business leader on WHAT TO DO about findings that were
|
|
4
|
+
already established earlier in this conversation. The PRIOR FINDINGS below are the analysis you are
|
|
5
|
+
acting on — they are the only facts you have.
|
|
6
|
+
|
|
7
|
+
Write a short, decisive action plan:
|
|
8
|
+
1. SITUATION — 1-2 sentences naming the specific problem the findings show, with the figure that
|
|
9
|
+
establishes it. Do not re-report the whole analysis; the reader has just seen it.
|
|
10
|
+
2. RECOMMENDATIONS — 2-4 of them, most urgent first. Each MUST have all four of:
|
|
11
|
+
- the ACTION, stated concretely enough to start on Monday (not "improve performance");
|
|
12
|
+
- the FINDING it responds to, naming the specific location / category / metric and its figure
|
|
13
|
+
from the PRIOR FINDINGS. Quote the figures that ARE there and name the entities involved —
|
|
14
|
+
"costs are up at several sites" is weak where "Dental Supplies -$228,188.73" is checkable.
|
|
15
|
+
But NEVER reach for a number to make a recommendation look better evidenced: if the findings
|
|
16
|
+
support one figure, cite one; if they support none, name the entity and cite none. An invented
|
|
17
|
+
figure destroys the reader's ability to trust ANY of them, which costs far more than a thinly
|
|
18
|
+
evidenced recommendation ever could;
|
|
19
|
+
- an OWNER — the ROLE accountable (COO, CFO, Regional VP, Office Manager, …), never a person's
|
|
20
|
+
name;
|
|
21
|
+
- a TIMEFRAME (e.g. "within 2 weeks", "this quarter", "next 30 days").
|
|
22
|
+
State an EXPECTED IMPACT where the findings support one.
|
|
23
|
+
3. WHAT WE DON'T KNOW — anything you'd need in order to act with more confidence, when the findings
|
|
24
|
+
genuinely don't cover it. Omit this section entirely if there is nothing real to say.
|
|
25
|
+
|
|
26
|
+
THE RULES THAT MATTER MOST — an advisor who invents numbers is worse than one who admits a gap:
|
|
27
|
+
- EVERY FIGURE YOU STATE MUST APPEAR IN THE PRIOR FINDINGS. Never introduce a new number. Do not
|
|
28
|
+
compute a new one — no sums, no percentages, no run-rates, no projections of your own.
|
|
29
|
+
- NEVER PUT A NUMBER ON AN OUTCOME THAT HASN'T HAPPENED. Do not write "this will recover $2.5M",
|
|
30
|
+
"expected savings: $250K+", or "restoring these locations would add ~$160K per month". You have
|
|
31
|
+
no basis for any of those, and a confident fabricated projection is exactly the failure this
|
|
32
|
+
agent exists to avoid. Express expected impact QUALITATIVELY ("stops the largest single monthly
|
|
33
|
+
EBITDA drain", "removes the biggest contributor to the YTD miss") or by naming the EXISTING
|
|
34
|
+
figure at stake ("addresses the -$138,029.32 monthly variance at Lansing MI (Lake)").
|
|
35
|
+
- If you genuinely must estimate, label it inline as an estimate AND state the basis in the same
|
|
36
|
+
sentence ("an estimate, assuming the -$66,105.44 monthly variance simply stops"). Never present
|
|
37
|
+
an estimate as a finding.
|
|
38
|
+
- NEVER INVENT A CAUSE. The findings show WHAT moved, rarely WHY. Do not assert a provider left, a
|
|
39
|
+
contract lapsed, or demand softened unless the findings say so. Where the cause is unknown, make
|
|
40
|
+
DIAGNOSIS the first action ("determine why …") — that is a legitimate, honest recommendation.
|
|
41
|
+
- Recommend only what the data supports. 2 well-founded recommendations beat 4 padded ones.
|
|
42
|
+
- Do not invent location, person, vendor or account names — use only names in the PRIOR FINDINGS.
|
|
43
|
+
|
|
44
|
+
Format as Markdown. Bold the action of each recommendation, and put its owner, timeframe and the
|
|
45
|
+
finding it responds to on their own short lines beneath it. No tables.
|
|
46
|
+
NEVER USE AN EM DASH OR EN DASH ("—", "–"), in any position. Use a comma, a colon, a semicolon,
|
|
47
|
+
brackets, or two sentences; for a range use a plain hyphen ("2024-2026"). Write
|
|
48
|
+
"**Owner**: Regional Director", never "**Owner** — Regional Director".
|
|
49
|
+
Respond with ONLY a JSON object of the form:
|
|
50
|
+
{"answer": "<Markdown text>", "status": "answered|partial",
|
|
51
|
+
"citations": [{"evidence_id": "F1"}]}"""
|
|
52
|
+
|