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
api_agent/supervisor.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Supervisor: route each query to ONE of several Agents (Phase 2 of the multi-backend plan).
|
|
2
|
+
|
|
3
|
+
Each Agent owns a catalog + scoped credentials (e.g. the HTTP agent over loaded API specs,
|
|
4
|
+
the SQL agent over a warehouse DSN). The supervisor picks the agent whose tools can answer,
|
|
5
|
+
runs it, and — if that agent's *data source* turns out to be unreachable — falls back to the
|
|
6
|
+
next-best agent, so a down warehouse degrades to the API wrapper instead of an abstain.
|
|
7
|
+
|
|
8
|
+
Deliberately boring, per the plan:
|
|
9
|
+
* ONE agent per query — no cross-agent fan-out/merging (it would collide with the
|
|
10
|
+
one-source-per-table anti-stitching rules).
|
|
11
|
+
* Lexical-first routing (the same recall machinery as the op-router, at agent
|
|
12
|
+
granularity); the LLM is consulted ONLY on ambiguity, so routing usually costs zero
|
|
13
|
+
tokens.
|
|
14
|
+
* Ties prefer the agents' construction order — put the preferred backend (e.g. the
|
|
15
|
+
warehouse: fewer hops) first.
|
|
16
|
+
|
|
17
|
+
`Supervisor.run(...)` mirrors `Agent.run(...)` exactly (query/history/enabled_tools/
|
|
18
|
+
progress/cancel_check → AgentResult), so callers treat it as a drop-in agent.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
from collections import Counter
|
|
24
|
+
|
|
25
|
+
from .agent import Agent
|
|
26
|
+
from .config import Config
|
|
27
|
+
from .llm import LLMClient, extract_json
|
|
28
|
+
from .log import get_logger
|
|
29
|
+
from .router import _query_tokens, _tokens
|
|
30
|
+
from .schemas import AgentResult
|
|
31
|
+
|
|
32
|
+
_LOG = get_logger("api_agent.supervisor")
|
|
33
|
+
|
|
34
|
+
# Second-place score within this fraction of first place = ambiguous → LLM tiebreak.
|
|
35
|
+
_AMBIGUITY_RATIO = 0.75
|
|
36
|
+
# How many of an agent's best-matching tools make up its routing score / LLM summary.
|
|
37
|
+
_TOP_TOOLS = 3
|
|
38
|
+
_SUMMARY_TOOLS = 6
|
|
39
|
+
|
|
40
|
+
SUPERVISOR_SYSTEM = (
|
|
41
|
+
"You route a user's question to exactly ONE data agent. Each AGENT below lists a sample "
|
|
42
|
+
"of the operations it can call. Pick the agent whose operations can actually answer the "
|
|
43
|
+
"question — match by MEANING, not exact words.\n"
|
|
44
|
+
'Return ONLY JSON: {"agent": "<label>"}.'
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Supervisor:
|
|
49
|
+
def __init__(self, config: Config, agents: list[tuple[str, Agent]],
|
|
50
|
+
llm: LLMClient | None = None):
|
|
51
|
+
"""``agents`` is an ordered list of (label, Agent) — order breaks ties, so put the
|
|
52
|
+
preferred backend first. ``llm`` (for the ambiguity tiebreak only) defaults to the
|
|
53
|
+
first agent's client."""
|
|
54
|
+
if not agents:
|
|
55
|
+
raise ValueError("Supervisor needs at least one agent")
|
|
56
|
+
self.config = config
|
|
57
|
+
self.agents = agents
|
|
58
|
+
self.llm = llm or agents[0][1].llm
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------ #
|
|
61
|
+
def run(self, query: str, history: list[dict] | None = None,
|
|
62
|
+
enabled_tools: list[str] | None = None,
|
|
63
|
+
progress=None, cancel_check=None) -> AgentResult:
|
|
64
|
+
"""Route to one agent and run it; on a source-down abstain, try the next-best agent.
|
|
65
|
+
Same contract as Agent.run — always returns an AgentResult, never raises."""
|
|
66
|
+
if len(self.agents) == 1:
|
|
67
|
+
label, agent = self.agents[0]
|
|
68
|
+
res = agent.run(query, history=history, enabled_tools=enabled_tools,
|
|
69
|
+
progress=progress, cancel_check=cancel_check)
|
|
70
|
+
res.answered_by = label
|
|
71
|
+
return res
|
|
72
|
+
|
|
73
|
+
order = self.route(query)
|
|
74
|
+
label, agent = order[0]
|
|
75
|
+
self._emit(progress, f"Supervisor → {label} agent")
|
|
76
|
+
res = agent.run(query, history=history, enabled_tools=enabled_tools,
|
|
77
|
+
progress=progress, cancel_check=cancel_check)
|
|
78
|
+
res.answered_by = label
|
|
79
|
+
|
|
80
|
+
# The chosen agent's SOURCE is down (not a data/answer problem) → one fallback step.
|
|
81
|
+
if self._source_down(res) and not (cancel_check and cancel_check()):
|
|
82
|
+
fb_label, fb_agent = order[1]
|
|
83
|
+
self._emit(progress, f"{label} source unreachable — falling back to {fb_label}")
|
|
84
|
+
fb = fb_agent.run(query, history=history, enabled_tools=enabled_tools,
|
|
85
|
+
progress=progress, cancel_check=cancel_check)
|
|
86
|
+
if not self._source_down(fb):
|
|
87
|
+
fb.answered_by = f"{fb_label} (fallback — {label} unreachable)"
|
|
88
|
+
return fb
|
|
89
|
+
return res
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------ #
|
|
92
|
+
def route(self, query: str) -> list[tuple[str, Agent]]:
|
|
93
|
+
"""Agents best-first. Lexical scores over the union corpus decide; construction
|
|
94
|
+
order breaks ties; the LLM is consulted only when the top two are ambiguous."""
|
|
95
|
+
scored = self._lexical_scores(query) # [(label, agent, score)] in construction order
|
|
96
|
+
ranked = sorted(scored, key=lambda x: -x[2]) # stable → ties keep preference order
|
|
97
|
+
top, second = ranked[0], ranked[1]
|
|
98
|
+
ambiguous = top[2] <= 0 or second[2] >= _AMBIGUITY_RATIO * top[2]
|
|
99
|
+
_LOG.info("supervisor route: query=%r scores={%s}%s", query[:200],
|
|
100
|
+
", ".join(f"{label}: {score:.2f}" for label, _, score in scored),
|
|
101
|
+
" — AMBIGUOUS, asking LLM" if ambiguous else f" → {top[0]}")
|
|
102
|
+
if ambiguous:
|
|
103
|
+
pick = self._llm_route(query)
|
|
104
|
+
_LOG.info("supervisor route: LLM tiebreak → %s", pick or "(failed — lexical order stands)")
|
|
105
|
+
if pick is not None:
|
|
106
|
+
ranked.sort(key=lambda x: (x[0] != pick,)) # chosen first, rest keep order
|
|
107
|
+
return [(label, agent) for label, agent, _ in ranked]
|
|
108
|
+
|
|
109
|
+
def _lexical_scores(self, query: str) -> list[tuple[str, Agent, float]]:
|
|
110
|
+
"""Score each agent = the sum of its best-matching tools' idf-weighted overlap with
|
|
111
|
+
the query, with document frequencies computed over the UNION of all agents' tools so
|
|
112
|
+
scores are comparable across agents (same math as Router._lexical_rank)."""
|
|
113
|
+
q = set(_query_tokens(query))
|
|
114
|
+
docs: list[tuple[int, set]] = [] # (agent_index, token set) per tool
|
|
115
|
+
for i, (_label, agent) in enumerate(self.agents):
|
|
116
|
+
for name, tool in agent.catalog.tools.items():
|
|
117
|
+
docs.append((i, set(_tokens(f"{name} {tool.description}"))))
|
|
118
|
+
df: Counter = Counter()
|
|
119
|
+
for _, toks in docs:
|
|
120
|
+
df.update(toks)
|
|
121
|
+
n_docs = max(len(docs), 1)
|
|
122
|
+
per_agent: dict[int, list[float]] = {i: [] for i in range(len(self.agents))}
|
|
123
|
+
for i, toks in docs:
|
|
124
|
+
per_agent[i].append(
|
|
125
|
+
sum(math.log((n_docs + 1) / (df[t] + 0.5)) for t in (q & toks)))
|
|
126
|
+
return [
|
|
127
|
+
(label, agent, sum(sorted(per_agent[i], reverse=True)[:_TOP_TOOLS]))
|
|
128
|
+
for i, (label, agent) in enumerate(self.agents)
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
def _llm_route(self, query: str) -> str | None:
|
|
132
|
+
"""One small call: which agent should take this question? None on any failure —
|
|
133
|
+
the lexical order stands (routing never blocks on the LLM)."""
|
|
134
|
+
blocks = []
|
|
135
|
+
for label, agent in self.agents:
|
|
136
|
+
ranked = sorted(
|
|
137
|
+
agent.catalog.tools.values(),
|
|
138
|
+
key=lambda t: -len(set(_query_tokens(query))
|
|
139
|
+
& set(_tokens(f"{t.name} {t.description}"))))
|
|
140
|
+
sample = "\n".join(
|
|
141
|
+
f" - {t.name}: {(t.description.splitlines() or [''])[0][:120]}"
|
|
142
|
+
for t in ranked[:_SUMMARY_TOOLS])
|
|
143
|
+
blocks.append(f"AGENT \"{label}\":\n{sample}")
|
|
144
|
+
try:
|
|
145
|
+
resp = self.llm.complete(
|
|
146
|
+
[{"role": "system", "content": SUPERVISOR_SYSTEM},
|
|
147
|
+
{"role": "user",
|
|
148
|
+
"content": f"QUESTION:\n{query}\n\n" + "\n\n".join(blocks)}],
|
|
149
|
+
model=self.config.router_model or self.config.generator_model,
|
|
150
|
+
json_mode=True,
|
|
151
|
+
)
|
|
152
|
+
pick = extract_json(resp.choices[0].message.content).get("agent")
|
|
153
|
+
return pick if pick in {label for label, _ in self.agents} else None
|
|
154
|
+
except Exception:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------------ #
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _source_down(res: AgentResult) -> bool:
|
|
160
|
+
"""True when the result is an abstain caused by the agent's data source being
|
|
161
|
+
unreachable (kind taxonomy on the trace, with the abstain note as belt) — the one
|
|
162
|
+
failure class where another backend over the same data can still answer."""
|
|
163
|
+
if res.status != "abstained":
|
|
164
|
+
return False
|
|
165
|
+
if any(not t.ok and getattr(t, "kind", None) == "unreachable" for t in res.trace):
|
|
166
|
+
return True
|
|
167
|
+
notes = (res.faithfulness.notes if res.faithfulness else "") or ""
|
|
168
|
+
return "unreachable" in notes.lower()
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _emit(progress, message: str) -> None:
|
|
172
|
+
_LOG.info("supervisor: %s", message)
|
|
173
|
+
if progress is None:
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
progress({"stage": "route", "message": message})
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|