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,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