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.
@@ -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
+