columna-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- columna_server/__init__.py +2 -0
- columna_server/__main__.py +6 -0
- columna_server/agent/__init__.py +10 -0
- columna_server/agent/conversation.py +14 -0
- columna_server/agent/loop.py +220 -0
- columna_server/agent/mcp_client.py +80 -0
- columna_server/agent/providers.py +115 -0
- columna_server/agent/runner.py +39 -0
- columna_server/agent/system_prompt.md +66 -0
- columna_server/cli.py +136 -0
- columna_server/demo/README.md +7 -0
- columna_server/demo/benchmark/data.toml +10 -0
- columna_server/demo/benchmark/manifold.cml +61 -0
- columna_server/demo/benchmark/warehouse/calendar.parquet +0 -0
- columna_server/demo/benchmark/warehouse/categories.parquet +0 -0
- columna_server/demo/benchmark/warehouse/customers.parquet +0 -0
- columna_server/demo/benchmark/warehouse/daily_revenue_summary.parquet +0 -0
- columna_server/demo/benchmark/warehouse/engagement_scores.parquet +0 -0
- columna_server/demo/benchmark/warehouse/eom_inventory.parquet +0 -0
- columna_server/demo/benchmark/warehouse/monthly_avg_order_value.parquet +0 -0
- columna_server/demo/benchmark/warehouse/monthly_store_inventory.parquet +0 -0
- columna_server/demo/benchmark/warehouse/monthly_unique_visitors.parquet +0 -0
- columna_server/demo/benchmark/warehouse/product_categories.parquet +0 -0
- columna_server/demo/benchmark/warehouse/products.parquet +0 -0
- columna_server/demo/benchmark/warehouse/stores.parquet +0 -0
- columna_server/demo/benchmark/warehouse/support_tickets.parquet +0 -0
- columna_server/demo/benchmark/warehouse/transactions.parquet +0 -0
- columna_server/demo/benchmark/warehouse/warehouse_notes.parquet +0 -0
- columna_server/demo.py +84 -0
- columna_server/frameql.py +106 -0
- columna_server/server.py +53 -0
- columna_server/store.py +124 -0
- columna_server/tools.py +143 -0
- columna_server-0.1.0.dist-info/METADATA +83 -0
- columna_server-0.1.0.dist-info/RECORD +37 -0
- columna_server-0.1.0.dist-info/WHEEL +4 -0
- columna_server-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""columna_server.agent — the natural-language query agent (a true MCP client; ADR-032 D8)."""
|
|
2
|
+
from .conversation import AGENT, ENGINE, USER, Turn
|
|
3
|
+
from .loop import Agent, load_system_prompt, prompt_example_queries, render_manifold_context
|
|
4
|
+
from .providers import (AnthropicProvider, Provider, ProviderUnavailable, ScriptedProvider,
|
|
5
|
+
make_provider)
|
|
6
|
+
|
|
7
|
+
__all__ = ["Turn", "USER", "AGENT", "ENGINE", "Agent", "load_system_prompt",
|
|
8
|
+
"prompt_example_queries", "render_manifold_context",
|
|
9
|
+
"Provider", "ProviderUnavailable", "ScriptedProvider", "AnthropicProvider",
|
|
10
|
+
"make_provider"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""columna_server.agent.conversation — the shared turn model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
USER = "user" # a human message
|
|
7
|
+
AGENT = "agent" # the agent's proposed line (QUERY:/ASK:)
|
|
8
|
+
ENGINE = "engine" # a compact wire summary the system appends after each query
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Turn:
|
|
13
|
+
role: str # USER | AGENT | ENGINE
|
|
14
|
+
text: str
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
columna_server.agent.loop — the agent conversation loop (WP-2.4 architecture #2).
|
|
3
|
+
|
|
4
|
+
user NL → provider proposes ONE Frame-QL query (or an ASK) → the MCP `query` tool → the outcome
|
|
5
|
+
routes the turn. The SYSTEM presents every server reply to the human, rendering wire values verbatim
|
|
6
|
+
(so the agent never emits a number — grounding is structural). A clarify is relayed to the human and
|
|
7
|
+
resolved only by their explicit choice (never auto-picked); a refuse permits ONE reformulation.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from .conversation import AGENT, ENGINE, USER, Turn
|
|
16
|
+
from .providers import Provider
|
|
17
|
+
|
|
18
|
+
_PROMPT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "system_prompt.md")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_system_prompt() -> str:
|
|
22
|
+
with open(_PROMPT_PATH, encoding="utf-8") as f:
|
|
23
|
+
return f.read()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def prompt_example_queries() -> list[str]:
|
|
27
|
+
"""Every `QUERY:` example line in the system prompt (for the drift-guard parse test)."""
|
|
28
|
+
out = []
|
|
29
|
+
for line in load_system_prompt().splitlines():
|
|
30
|
+
s = line.strip().lstrip("- ").strip()
|
|
31
|
+
if s.startswith("QUERY:"):
|
|
32
|
+
out.append(s[len("QUERY:"):].strip())
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_manifold_context(describe: dict) -> str:
|
|
37
|
+
lines = [f"Manifold: {describe['manifold_id']}"]
|
|
38
|
+
lines.append("Universes (a measure lives in exactly one):")
|
|
39
|
+
for u in describe["universes"]:
|
|
40
|
+
pred = f" WHERE {u['predicate']}" if u["predicate"] else ""
|
|
41
|
+
lines.append(f" - {u['name']}: over {', '.join(u['base_dimensions'])}{pred}")
|
|
42
|
+
lines.append("Dimension levels (anchors you may address at):")
|
|
43
|
+
lines.append(" " + ", ".join(sorted(d["level"] for d in describe["dimensions"])))
|
|
44
|
+
if describe["edges"]:
|
|
45
|
+
lines.append("Functional edges (finer -> coarser, along a lineage):")
|
|
46
|
+
for e in describe["edges"]:
|
|
47
|
+
lines.append(f" - {e['frm']} -> {e['to']} ({e['lineage']})")
|
|
48
|
+
lines.append("Measures (name — family members — universe):")
|
|
49
|
+
for m in describe["measures"]:
|
|
50
|
+
fam = "/".join(m["family"]) if m["family"] else "(single)"
|
|
51
|
+
lines.append(f" - {m['name']} [{fam}] on {m['universe']}")
|
|
52
|
+
return "\n".join(lines)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_NUM = re.compile(r"-?\d+(?:\.\d+)?")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Agent:
|
|
59
|
+
"""Drives one conversation. `run_turn(user_msg)` returns the human-facing replies for the turn.
|
|
60
|
+
|
|
61
|
+
Not an MCP server itself — it holds an `MCPServerConnection` and calls tools across the wire.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, conn, provider: Provider, describe: dict):
|
|
65
|
+
self.conn = conn
|
|
66
|
+
self.provider = provider
|
|
67
|
+
self.system = load_system_prompt().replace("{MANIFOLD_CONTEXT}", render_manifold_context(describe))
|
|
68
|
+
self.history: list[Turn] = []
|
|
69
|
+
self._pending: tuple[list[dict], str] | None = None # (alternatives, frameql) awaiting a choice
|
|
70
|
+
|
|
71
|
+
# ---- turn routing -------------------------------------------------------
|
|
72
|
+
async def run_turn(self, user_msg: str) -> list[str]:
|
|
73
|
+
replies: list[str] = []
|
|
74
|
+
|
|
75
|
+
# (A) a clarify is open — this message is the human's CHOICE (never auto-picked)
|
|
76
|
+
if self._pending is not None:
|
|
77
|
+
alts, frameql = self._pending
|
|
78
|
+
self._pending = None
|
|
79
|
+
idx = self._resolve_choice(user_msg, alts)
|
|
80
|
+
self.history.append(Turn(USER, user_msg))
|
|
81
|
+
if idx is None:
|
|
82
|
+
replies.append("I didn't catch which option — reply with its number, or ask something new.")
|
|
83
|
+
# fall through and treat this message as a fresh request
|
|
84
|
+
else:
|
|
85
|
+
universe = (alts[idx].get("apply") or {}).get("universe")
|
|
86
|
+
wire = await self.conn.query(frameql, universe=universe)
|
|
87
|
+
self.history.append(Turn(ENGINE, self._engine_summary(wire)))
|
|
88
|
+
replies.append(self._present(wire, chosen_universe=universe))
|
|
89
|
+
return replies
|
|
90
|
+
|
|
91
|
+
# (B) fresh proposal — allow exactly ONE reformulation after a refuse/error
|
|
92
|
+
reformulations_left = 1
|
|
93
|
+
current = user_msg
|
|
94
|
+
while True:
|
|
95
|
+
line = self.provider.propose(self.system, list(self.history), current)
|
|
96
|
+
self.history.append(Turn(USER, current))
|
|
97
|
+
self.history.append(Turn(AGENT, line))
|
|
98
|
+
kind, body = _parse_line(line)
|
|
99
|
+
|
|
100
|
+
if kind == "ask":
|
|
101
|
+
replies.append(body)
|
|
102
|
+
return replies
|
|
103
|
+
if kind == "invalid":
|
|
104
|
+
replies.append("(couldn't read that as a Frame-QL query — try rephrasing your question)")
|
|
105
|
+
return replies
|
|
106
|
+
|
|
107
|
+
wire = await self.conn.query(body)
|
|
108
|
+
self.history.append(Turn(ENGINE, self._engine_summary(wire)))
|
|
109
|
+
replies.append(self._present(wire))
|
|
110
|
+
outcome = wire["outcome"]
|
|
111
|
+
|
|
112
|
+
if outcome == "clarify":
|
|
113
|
+
self._pending = (self._alternatives(wire), body)
|
|
114
|
+
return replies
|
|
115
|
+
if outcome in ("refuse", "error") and reformulations_left > 0:
|
|
116
|
+
reformulations_left -= 1
|
|
117
|
+
current = ("[reformulate] The previous query was not answerable (see the engine "
|
|
118
|
+
"note). If a reformulation addresses the reason, propose exactly one; "
|
|
119
|
+
"otherwise ask the human or stop.")
|
|
120
|
+
continue
|
|
121
|
+
return replies
|
|
122
|
+
|
|
123
|
+
# ---- presentation (verbatim from the wire; the agent adds no numbers) ---
|
|
124
|
+
def _present(self, wire: dict, chosen_universe: str | None = None) -> str:
|
|
125
|
+
outcome = wire["outcome"]
|
|
126
|
+
if outcome in ("serve", "disclose"):
|
|
127
|
+
parts = []
|
|
128
|
+
if chosen_universe:
|
|
129
|
+
parts.append(f"[over universe '{chosen_universe}']")
|
|
130
|
+
for col in wire["columns"]:
|
|
131
|
+
if col["status"] != "served":
|
|
132
|
+
continue
|
|
133
|
+
parts.append(f"{col['name']}: {_render_values(col)}")
|
|
134
|
+
for d in col["disclosures"]:
|
|
135
|
+
parts.append(f" · disclosure [{d['code']}, {d['materiality']}]: {d['detail']}")
|
|
136
|
+
for d in wire["frame"]["disclosures"]:
|
|
137
|
+
parts.append(f"· disclosure [{d['code']}, {d['materiality']}]: {d['detail']}")
|
|
138
|
+
head = "Here is the answer:" if outcome == "serve" else "Answer (with caveats):"
|
|
139
|
+
return head + "\n" + "\n".join(parts)
|
|
140
|
+
|
|
141
|
+
if outcome == "clarify":
|
|
142
|
+
col = next(c for c in wire["columns"] if c["status"] == "clarify")
|
|
143
|
+
nr = col["no_result"]
|
|
144
|
+
lines = [f"That question is ambiguous: {nr['detail']}", "Please choose:"]
|
|
145
|
+
for i, a in enumerate(self._alternatives(wire), 1):
|
|
146
|
+
lines.append(f" [{i}] {a['description']}")
|
|
147
|
+
lines.append("Reply with the number of your choice.")
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
# refuse / error
|
|
151
|
+
col = next((c for c in wire["columns"] if c["status"] in ("refuse", "error")), None)
|
|
152
|
+
if col is not None:
|
|
153
|
+
nr = col["no_result"]
|
|
154
|
+
verb = "can't be answered" if outcome == "refuse" else "isn't a valid query"
|
|
155
|
+
return f"That {verb}: {nr['detail']}"
|
|
156
|
+
err = wire.get("error", {})
|
|
157
|
+
return f"That isn't a valid query: {err.get('detail', 'unknown error')}"
|
|
158
|
+
|
|
159
|
+
# ---- engine note (compact context for the model's NEXT proposal) --------
|
|
160
|
+
def _engine_summary(self, wire: dict) -> str:
|
|
161
|
+
outcome = wire["outcome"]
|
|
162
|
+
if outcome in ("serve", "disclose"):
|
|
163
|
+
cols = ", ".join(c["name"] for c in wire["columns"] if c["status"] == "served")
|
|
164
|
+
mats = [d["code"] for c in wire["columns"] for d in c["disclosures"] if d["materiality"] == "material"]
|
|
165
|
+
mats += [d["code"] for d in wire["frame"]["disclosures"] if d["materiality"] == "material"]
|
|
166
|
+
extra = f"; material disclosures: {mats}" if mats else ""
|
|
167
|
+
return f"engine: outcome={outcome}; served columns [{cols}]{extra}. (values shown to the human)"
|
|
168
|
+
col = next((c for c in wire["columns"] if c["status"] in ("clarify", "refuse", "error")), None)
|
|
169
|
+
nr = col["no_result"] if col else {}
|
|
170
|
+
alts = [a.get("token", a.get("description", "")) for a in self._alternatives(wire)]
|
|
171
|
+
tail = f"; alternatives: {alts}" if alts else ""
|
|
172
|
+
return (f"engine: outcome={outcome}; reason={nr.get('reason')}; detail={nr.get('detail')}"
|
|
173
|
+
f"{tail}. (shown to the human; you do not restate it)")
|
|
174
|
+
|
|
175
|
+
# ---- helpers ------------------------------------------------------------
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _alternatives(wire: dict) -> list[dict]:
|
|
178
|
+
for c in wire["columns"]:
|
|
179
|
+
if c.get("no_result") and c["no_result"].get("alternatives"):
|
|
180
|
+
return c["no_result"]["alternatives"]
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _resolve_choice(user_msg: str, alts: list[dict]) -> int | None:
|
|
185
|
+
s = user_msg.strip().lower()
|
|
186
|
+
m = re.search(r"\d+", s)
|
|
187
|
+
if m:
|
|
188
|
+
i = int(m.group()) - 1
|
|
189
|
+
if 0 <= i < len(alts):
|
|
190
|
+
return i
|
|
191
|
+
for i, a in enumerate(alts):
|
|
192
|
+
uni = (a.get("apply") or {}).get("universe", "")
|
|
193
|
+
if uni and uni.lower() in s:
|
|
194
|
+
return i
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _render_values(col: dict) -> str:
|
|
199
|
+
if "value" in col:
|
|
200
|
+
return _s(col["value"])
|
|
201
|
+
rows = col.get("values", [])
|
|
202
|
+
out = []
|
|
203
|
+
for r in rows:
|
|
204
|
+
dims = " ".join(f"{k}={_s(v)}" for k, v in r.items() if k != "value")
|
|
205
|
+
out.append(f"{dims}={_s(r['value'])}" if dims else _s(r["value"]))
|
|
206
|
+
return "; ".join(out) if out else "(no rows)"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _s(v) -> str:
|
|
210
|
+
"""Verbatim stringification — no rounding, so grounding matches strictly."""
|
|
211
|
+
return json.dumps(v) if isinstance(v, str) else str(v)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _parse_line(line: str) -> tuple[str, str]:
|
|
215
|
+
s = line.strip()
|
|
216
|
+
if s.startswith("QUERY:"):
|
|
217
|
+
return "query", s[len("QUERY:"):].strip()
|
|
218
|
+
if s.startswith("ASK:"):
|
|
219
|
+
return "ask", s[len("ASK:"):].strip()
|
|
220
|
+
return "invalid", s
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
columna_server.agent.mcp_client — the agent's TRUE MCP client (WP-2.4 architecture #1).
|
|
3
|
+
|
|
4
|
+
The agent answers ONLY by spawning `columna-server mcp/demo` over stdio and speaking the MCP
|
|
5
|
+
protocol. This module imports the MCP client SDK and nothing from columna_core — the engine is
|
|
6
|
+
reached across the protocol boundary, never in-process. Bypassing this boundary fails the WP.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
|
|
15
|
+
from mcp import ClientSession, StdioServerParameters
|
|
16
|
+
from mcp.client.stdio import stdio_client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _server_args(manifolds: str | None) -> list[str]:
|
|
20
|
+
# Spawn the server as its own process, exactly as any external agent would.
|
|
21
|
+
if manifolds:
|
|
22
|
+
return ["-m", "columna_server", "mcp", "--manifolds", manifolds]
|
|
23
|
+
return ["-m", "columna_server", "demo"] # the packaged demo, zero path args
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MCPServerConnection:
|
|
27
|
+
"""A live MCP session to a columna server. Read-only tool calls; results are the wire JSON."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, session: ClientSession):
|
|
30
|
+
self._s = session
|
|
31
|
+
self.manifold_id: str | None = None
|
|
32
|
+
|
|
33
|
+
async def _call(self, name: str, **args) -> dict:
|
|
34
|
+
res = await self._s.call_tool(name, args)
|
|
35
|
+
if res.isError:
|
|
36
|
+
text = res.content[0].text if res.content else "tool error"
|
|
37
|
+
raise RuntimeError(f"MCP tool '{name}' error: {text}")
|
|
38
|
+
return json.loads(res.content[0].text)
|
|
39
|
+
|
|
40
|
+
async def tool_names(self) -> list[str]:
|
|
41
|
+
return sorted(t.name for t in (await self._s.list_tools()).tools)
|
|
42
|
+
|
|
43
|
+
async def list_manifolds(self) -> dict:
|
|
44
|
+
return await self._call("list_manifolds")
|
|
45
|
+
|
|
46
|
+
async def describe_manifold(self, manifold_id: str) -> dict:
|
|
47
|
+
return await self._call("describe_manifold", manifold_id=manifold_id)
|
|
48
|
+
|
|
49
|
+
async def describe_measure(self, manifold_id: str, measure: str) -> dict:
|
|
50
|
+
return await self._call("describe_measure", manifold_id=manifold_id, measure=measure)
|
|
51
|
+
|
|
52
|
+
async def query(self, frameql: str, universe: str | None = None) -> dict:
|
|
53
|
+
args = {"manifold_id": self.manifold_id, "frameql": frameql}
|
|
54
|
+
if universe:
|
|
55
|
+
args["universe"] = universe
|
|
56
|
+
return await self._call("query", **args)
|
|
57
|
+
|
|
58
|
+
async def explain(self, frameql: str, universe: str | None = None) -> dict:
|
|
59
|
+
args = {"manifold_id": self.manifold_id, "frameql": frameql}
|
|
60
|
+
if universe:
|
|
61
|
+
args["universe"] = universe
|
|
62
|
+
return await self._call("explain", **args)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@asynccontextmanager
|
|
66
|
+
async def connect(manifolds: str | None = None):
|
|
67
|
+
"""Spawn a columna server over stdio and yield a connected MCPServerConnection.
|
|
68
|
+
|
|
69
|
+
`manifolds=None` connects to the packaged demo (`columna-server demo`); a path connects to
|
|
70
|
+
`columna-server mcp --manifolds <path>`. The first hosted manifold is selected by default.
|
|
71
|
+
"""
|
|
72
|
+
params = StdioServerParameters(command=sys.executable, args=_server_args(manifolds),
|
|
73
|
+
env=dict(os.environ))
|
|
74
|
+
async with stdio_client(params) as (read, write):
|
|
75
|
+
async with ClientSession(read, write) as session:
|
|
76
|
+
await session.initialize()
|
|
77
|
+
conn = MCPServerConnection(session)
|
|
78
|
+
listed = await conn.list_manifolds()
|
|
79
|
+
conn.manifold_id = listed["manifolds"][0]["manifold_id"]
|
|
80
|
+
yield conn
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
columna_server.agent.providers — the tiny provider layer (WP-2.4 ruling 3-B).
|
|
3
|
+
|
|
4
|
+
`propose(system, history, user_msg) -> str` returns the model's next line — either `QUERY: <frameql>`
|
|
5
|
+
or `ASK: <question>`. The provider ONLY produces text; it never touches MCP or the engine. Two
|
|
6
|
+
implementations: `anthropic` (default, BYO key) and `scripted` (deterministic, for tests/demos).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from .conversation import AGENT, ENGINE, Turn
|
|
14
|
+
|
|
15
|
+
DEFAULT_MODEL = "claude-opus-4-8"
|
|
16
|
+
MODEL_ENV = "COLUMNA_AGENT_MODEL"
|
|
17
|
+
KEY_ENV = "ANTHROPIC_API_KEY"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProviderUnavailable(RuntimeError):
|
|
21
|
+
"""The requested provider cannot run (e.g. no API key)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class Provider(Protocol):
|
|
26
|
+
name: str
|
|
27
|
+
|
|
28
|
+
def propose(self, system: str, history: list[Turn], user_msg: str) -> str:
|
|
29
|
+
"""Return the next agent line: 'QUERY: <frameql>' or 'ASK: <question>'."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ScriptedProvider:
|
|
34
|
+
"""Deterministic provider: replays a fixed list of lines, one per call. For tests and demos —
|
|
35
|
+
no network, no key. Raises if the script runs dry (a sign the conversation went further than
|
|
36
|
+
the test scripted)."""
|
|
37
|
+
|
|
38
|
+
name = "scripted"
|
|
39
|
+
|
|
40
|
+
def __init__(self, script: list[str]):
|
|
41
|
+
self._script = list(script)
|
|
42
|
+
self._i = 0
|
|
43
|
+
|
|
44
|
+
def propose(self, system: str, history: list[Turn], user_msg: str) -> str:
|
|
45
|
+
if self._i >= len(self._script):
|
|
46
|
+
raise ProviderUnavailable("scripted provider exhausted — the agent asked for more "
|
|
47
|
+
"proposals than the script supplies")
|
|
48
|
+
line = self._script[self._i]
|
|
49
|
+
self._i += 1
|
|
50
|
+
return line
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AnthropicProvider:
|
|
54
|
+
"""Default provider — one `messages.create` per turn via the official anthropic SDK. Model from
|
|
55
|
+
COLUMNA_AGENT_MODEL (default claude-opus-4-8); key from ANTHROPIC_API_KEY. Deps live in the
|
|
56
|
+
`[agent]` extra; no key -> a clear error pointing at the packaged demo."""
|
|
57
|
+
|
|
58
|
+
name = "anthropic"
|
|
59
|
+
|
|
60
|
+
def __init__(self, model: str | None = None):
|
|
61
|
+
self.model = model or os.environ.get(MODEL_ENV, DEFAULT_MODEL)
|
|
62
|
+
if not os.environ.get(KEY_ENV):
|
|
63
|
+
raise ProviderUnavailable(
|
|
64
|
+
f"{KEY_ENV} is not set. Set it to use the natural-language agent, or try the "
|
|
65
|
+
f"no-key packaged demo: `columna-server demo --play`."
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
import anthropic
|
|
69
|
+
except ModuleNotFoundError as e:
|
|
70
|
+
raise ProviderUnavailable(
|
|
71
|
+
"the anthropic SDK is not installed — install the agent extra: "
|
|
72
|
+
"`pip install -e \"packages/columna-server[agent]\"`."
|
|
73
|
+
) from e
|
|
74
|
+
self._client = anthropic.Anthropic()
|
|
75
|
+
|
|
76
|
+
def propose(self, system: str, history: list[Turn], user_msg: str) -> str:
|
|
77
|
+
# Render the conversation as alternating user/assistant messages. The engine notes and the
|
|
78
|
+
# human turns become `user` content (context for the model); the agent's own prior lines
|
|
79
|
+
# become `assistant` content. The system prompt is passed separately.
|
|
80
|
+
messages = []
|
|
81
|
+
for t in history:
|
|
82
|
+
if t.role == AGENT:
|
|
83
|
+
messages.append({"role": "assistant", "content": t.text})
|
|
84
|
+
else: # USER or ENGINE — both are context the model reads
|
|
85
|
+
prefix = "[engine] " if t.role == ENGINE else ""
|
|
86
|
+
messages.append({"role": "user", "content": prefix + t.text})
|
|
87
|
+
messages.append({"role": "user", "content": user_msg})
|
|
88
|
+
|
|
89
|
+
resp = self._client.messages.create(
|
|
90
|
+
model=self.model, max_tokens=512, system=system, messages=_coalesce(messages),
|
|
91
|
+
)
|
|
92
|
+
return "".join(b.text for b in resp.content if b.type == "text").strip()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _coalesce(messages: list[dict]) -> list[dict]:
|
|
96
|
+
"""The Messages API requires the first message to be `user` and forbids empty history. Merge
|
|
97
|
+
consecutive same-role messages so a run of engine+user notes stays one turn."""
|
|
98
|
+
out: list[dict] = []
|
|
99
|
+
for m in messages:
|
|
100
|
+
if out and out[-1]["role"] == m["role"]:
|
|
101
|
+
out[-1]["content"] = out[-1]["content"] + "\n" + m["content"]
|
|
102
|
+
else:
|
|
103
|
+
out.append(dict(m))
|
|
104
|
+
if not out or out[0]["role"] != "user":
|
|
105
|
+
out.insert(0, {"role": "user", "content": "(begin)"})
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def make_provider(name: str, model: str | None = None) -> Provider:
|
|
110
|
+
if name == "scripted":
|
|
111
|
+
raise ProviderUnavailable("the scripted provider requires an explicit script; "
|
|
112
|
+
"construct ScriptedProvider(script) directly")
|
|
113
|
+
if name == "anthropic":
|
|
114
|
+
return AnthropicProvider(model=model)
|
|
115
|
+
raise ProviderUnavailable(f"unknown provider '{name}' (have: anthropic, scripted)")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""columna_server.agent.runner — the `columna-server agent` REPL."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .loop import Agent
|
|
8
|
+
from .mcp_client import connect
|
|
9
|
+
from .providers import ProviderUnavailable, make_provider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _run(manifolds, provider):
|
|
13
|
+
async with connect(manifolds) as conn:
|
|
14
|
+
describe = await conn.describe_manifold(conn.manifold_id)
|
|
15
|
+
agent = Agent(conn, provider, describe)
|
|
16
|
+
print(f"Columna agent — manifold '{conn.manifold_id}', provider '{provider.name}'. "
|
|
17
|
+
f"Ask a question; Ctrl-D to exit.", file=sys.stderr)
|
|
18
|
+
while True:
|
|
19
|
+
try:
|
|
20
|
+
line = input("you> ")
|
|
21
|
+
except EOFError:
|
|
22
|
+
print(file=sys.stderr)
|
|
23
|
+
break
|
|
24
|
+
if not line.strip():
|
|
25
|
+
continue
|
|
26
|
+
for reply in await agent.run_turn(line):
|
|
27
|
+
print(reply)
|
|
28
|
+
print()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_agent_cli(manifolds: str | None = None, provider_name: str = "anthropic",
|
|
32
|
+
model: str | None = None) -> int:
|
|
33
|
+
try:
|
|
34
|
+
provider = make_provider(provider_name, model=model)
|
|
35
|
+
except ProviderUnavailable as e:
|
|
36
|
+
print(f"error: {e}", file=sys.stderr)
|
|
37
|
+
return 2
|
|
38
|
+
asyncio.run(_run(manifolds, provider))
|
|
39
|
+
return 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
You are the Columna query agent. You turn a person's natural-language question into ONE Frame-QL
|
|
2
|
+
query, hand it to the Columna server (the analytic engine), and let the system relay what the engine
|
|
3
|
+
returns. You are a proposer, not an authority: the engine decides whether a query is answerable, and
|
|
4
|
+
the human decides between genuine alternatives. Your job is to propose well.
|
|
5
|
+
|
|
6
|
+
## Your role boundary
|
|
7
|
+
|
|
8
|
+
You never write result numbers or answer prose. The server's reply — values, disclosures,
|
|
9
|
+
alternatives, refusal reasons — is presented to the human by the system, verbatim from the wire. You
|
|
10
|
+
only propose queries and ask questions. Do not restate, summarize, round, or interpret results.
|
|
11
|
+
|
|
12
|
+
## What you may emit
|
|
13
|
+
|
|
14
|
+
On every turn you emit EXACTLY ONE of:
|
|
15
|
+
1. A Frame-QL query line, prefixed literally with `QUERY: ` — nothing else on the line.
|
|
16
|
+
2. A short question back to the human, prefixed literally with `ASK: ` — only when the request is
|
|
17
|
+
too ambiguous to form any reasonable query, so you need the human to say what they want.
|
|
18
|
+
|
|
19
|
+
Never emit both. Never emit prose outside these two prefixes. Never wrap the query in backticks.
|
|
20
|
+
|
|
21
|
+
## Frame-QL (the only query language — no SQL, ever)
|
|
22
|
+
|
|
23
|
+
A query is `<columns> @ <anchor>`:
|
|
24
|
+
- `<columns>` is one or more comma-separated columns; each is `name: expression` or a bare
|
|
25
|
+
`expression` (which names itself). An expression is measure arithmetic over the manifold's
|
|
26
|
+
measures, e.g. `revenue`, `revenue / orders`, `level.sum`, `level.last`.
|
|
27
|
+
- `<anchor>` is one or more comma-separated dimension levels, e.g. `store`, `store, day`,
|
|
28
|
+
`cal.month`, `region`.
|
|
29
|
+
- Commas inside function calls (e.g. `lag(revenue.sum, n=1)`) are not column separators.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
- QUERY: revenue @ region
|
|
33
|
+
- QUERY: rate: revenue / level.last @ store, day
|
|
34
|
+
- QUERY: rev: revenue, inv: level.last @ store, day
|
|
35
|
+
- QUERY: m: lag(revenue.sum, n=1) @ cal.month
|
|
36
|
+
|
|
37
|
+
Only use measure, derived, dimension, and universe names that appear in the manifold description
|
|
38
|
+
below. Do not invent columns, operators, or anchors. If a measure has a family (e.g. `sum`,
|
|
39
|
+
`last`), address a member as `measure.member`.
|
|
40
|
+
|
|
41
|
+
## What the engine sends back (your context, not the human's answer)
|
|
42
|
+
|
|
43
|
+
After each query the system adds an `engine` note to the conversation summarizing the outcome:
|
|
44
|
+
`serve` / `disclose` (the values were shown), `clarify` (the population is ambiguous — the human is
|
|
45
|
+
being shown the candidate alternatives and will choose; you do not choose), `refuse` / `error` (the
|
|
46
|
+
reason was shown). Use these notes only to decide your NEXT proposal — never to write an answer.
|
|
47
|
+
|
|
48
|
+
## Rules you must follow
|
|
49
|
+
|
|
50
|
+
- GROUNDING: you never emit a number, so you can never state a wrong one. If the human asks for a
|
|
51
|
+
figure you do not have (e.g. a total across rows), propose a query that measures it — never do the
|
|
52
|
+
arithmetic yourself.
|
|
53
|
+
- POPULATION: a measure belongs to one universe. A ratio of measures from different universes has no
|
|
54
|
+
single population; the engine will clarify and the human will pick. Do not pre-pick a population.
|
|
55
|
+
- CLARIFY IS THE HUMAN'S CALL: on a clarify, do not re-propose the same ratio with a population
|
|
56
|
+
guessed in. The system applies the human's chosen alternative for them.
|
|
57
|
+
- ONE REFORMULATION AFTER A REFUSE: when the engine refuses (e.g. a measure addressed outside its
|
|
58
|
+
universe), you MAY propose exactly ONE reformulated query that addresses the stated reason (for a
|
|
59
|
+
cross-universe ratio, that is usually the two measures as separate columns). If the reformulation
|
|
60
|
+
also fails, stop — do not keep retrying.
|
|
61
|
+
- HONEST NAMES: use the manifold's names as written (e.g. `sell_through_rate`). Do not rename a
|
|
62
|
+
measure to something more familiar.
|
|
63
|
+
|
|
64
|
+
## The manifold you are querying
|
|
65
|
+
|
|
66
|
+
{MANIFOLD_CONTEXT}
|