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.
Files changed (37) hide show
  1. columna_server/__init__.py +2 -0
  2. columna_server/__main__.py +6 -0
  3. columna_server/agent/__init__.py +10 -0
  4. columna_server/agent/conversation.py +14 -0
  5. columna_server/agent/loop.py +220 -0
  6. columna_server/agent/mcp_client.py +80 -0
  7. columna_server/agent/providers.py +115 -0
  8. columna_server/agent/runner.py +39 -0
  9. columna_server/agent/system_prompt.md +66 -0
  10. columna_server/cli.py +136 -0
  11. columna_server/demo/README.md +7 -0
  12. columna_server/demo/benchmark/data.toml +10 -0
  13. columna_server/demo/benchmark/manifold.cml +61 -0
  14. columna_server/demo/benchmark/warehouse/calendar.parquet +0 -0
  15. columna_server/demo/benchmark/warehouse/categories.parquet +0 -0
  16. columna_server/demo/benchmark/warehouse/customers.parquet +0 -0
  17. columna_server/demo/benchmark/warehouse/daily_revenue_summary.parquet +0 -0
  18. columna_server/demo/benchmark/warehouse/engagement_scores.parquet +0 -0
  19. columna_server/demo/benchmark/warehouse/eom_inventory.parquet +0 -0
  20. columna_server/demo/benchmark/warehouse/monthly_avg_order_value.parquet +0 -0
  21. columna_server/demo/benchmark/warehouse/monthly_store_inventory.parquet +0 -0
  22. columna_server/demo/benchmark/warehouse/monthly_unique_visitors.parquet +0 -0
  23. columna_server/demo/benchmark/warehouse/product_categories.parquet +0 -0
  24. columna_server/demo/benchmark/warehouse/products.parquet +0 -0
  25. columna_server/demo/benchmark/warehouse/stores.parquet +0 -0
  26. columna_server/demo/benchmark/warehouse/support_tickets.parquet +0 -0
  27. columna_server/demo/benchmark/warehouse/transactions.parquet +0 -0
  28. columna_server/demo/benchmark/warehouse/warehouse_notes.parquet +0 -0
  29. columna_server/demo.py +84 -0
  30. columna_server/frameql.py +106 -0
  31. columna_server/server.py +53 -0
  32. columna_server/store.py +124 -0
  33. columna_server/tools.py +143 -0
  34. columna_server-0.1.0.dist-info/METADATA +83 -0
  35. columna_server-0.1.0.dist-info/RECORD +37 -0
  36. columna_server-0.1.0.dist-info/WHEEL +4 -0
  37. columna_server-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,2 @@
1
+ """Columna MCP server — a library of Manifolds exposed to AI agents over MCP (ADR-032 D8)."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -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}