caudate-cli 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 (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
@@ -0,0 +1,297 @@
1
+ """Agentic — bridge to the Agentic marketplace (https://agentic.so).
2
+
3
+ Lifts hosted LLM tools from the Agentic platform into Cognos's tool
4
+ palette via plain HTTP — no Node.js bridge, no `@agentic/platform-tool-
5
+ client` dependency, just the same wire protocol the TypeScript SDK
6
+ uses underneath.
7
+
8
+ Two endpoints (extracted from `@agentic/platform-tool-client/dist/`):
9
+ - GET https://api.agentic.so/v1/projects/public/by-identifier
10
+ Returns the project + its `lastPublishedDeployment.tools` array
11
+ with name/description/inputSchema for each tool.
12
+ - POST https://gateway.agentic.so/<deploymentIdentifier>/<toolName>
13
+ Executes the tool. JSON body in, JSON out.
14
+
15
+ Auth: optional `Authorization: Bearer <AGENTIC_API_KEY>` from env.
16
+
17
+ Two Cognos tools ship from this module:
18
+ - `Agentic` — execute a tool by (identifier, tool_name, args)
19
+ - `AgenticBrowse` — list the tools published in a project
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import os
27
+ import time
28
+ from typing import Any
29
+
30
+ import httpx
31
+
32
+ from core.schemas import ToolResult
33
+ from execution.tools.base import BaseTool
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ _API_BASE = os.environ.get("AGENTIC_API_BASE", "https://api.agentic.so")
39
+ _GATEWAY_BASE = os.environ.get("AGENTIC_GATEWAY_BASE", "https://gateway.agentic.so")
40
+ _DEFAULT_TIMEOUT_S = 30.0
41
+ _DEPLOYMENT_CACHE_TTL_S = 600.0 # re-fetch deployment metadata every 10 min
42
+
43
+
44
+ def _api_key() -> str | None:
45
+ return os.environ.get("AGENTIC_API_KEY") or None
46
+
47
+
48
+ def _auth_headers() -> dict[str, str]:
49
+ key = _api_key()
50
+ return {"Authorization": f"Bearer {key}"} if key else {}
51
+
52
+
53
+ # ---------------------------------------------------------------------
54
+ # Cached deployment-metadata fetch.
55
+ #
56
+ # `AgenticTool` and `AgenticBrowseTool` share this cache so that two
57
+ # back-to-back calls to e.g. '@agentic/search' don't both hit the
58
+ # /v1/projects/public/by-identifier endpoint.
59
+ # ---------------------------------------------------------------------
60
+
61
+ _CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
62
+
63
+
64
+ async def _resolve_deployment(identifier: str) -> dict[str, Any]:
65
+ """Look up a project by identifier and return its latest deployment.
66
+
67
+ Returns the deployment dict, which has at minimum:
68
+ - identifier (string, used in the gateway URL)
69
+ - tools (list of {name, description, inputSchema, ...})
70
+ """
71
+ now = time.time()
72
+ cached = _CACHE.get(identifier)
73
+ if cached and (now - cached[0]) < _DEPLOYMENT_CACHE_TTL_S:
74
+ return cached[1]
75
+
76
+ url = f"{_API_BASE}/v1/projects/public/by-identifier"
77
+ params = {
78
+ "projectIdentifier": identifier,
79
+ "populate[]": "lastPublishedDeployment",
80
+ }
81
+ async with httpx.AsyncClient(timeout=_DEFAULT_TIMEOUT_S) as client:
82
+ resp = await client.get(url, params=params, headers=_auth_headers())
83
+ if resp.status_code == 404:
84
+ raise ValueError(f"Agentic project not found: {identifier!r}")
85
+ resp.raise_for_status()
86
+ project = resp.json()
87
+ deployment = project.get("lastPublishedDeployment")
88
+ if not deployment:
89
+ raise ValueError(
90
+ f"Agentic project {identifier!r} has no published deployment"
91
+ )
92
+ _CACHE[identifier] = (now, deployment)
93
+ return deployment
94
+
95
+
96
+ # ---------------------------------------------------------------------
97
+ # Tools
98
+ # ---------------------------------------------------------------------
99
+
100
+
101
+ class AgenticTool(BaseTool):
102
+ name = "Agentic"
103
+ description = (
104
+ "[CURRENTLY DORMANT — agentic.so service is suspended] "
105
+ "Bridge to the Agentic marketplace. Pass `identifier` "
106
+ "('@agentic/search'), `tool_name`, and `args`. Calls go to "
107
+ "https://gateway.agentic.so but that endpoint returns "
108
+ "'Service Suspended' as of 2026-05-03. Will start working "
109
+ "again automatically if the service comes back, or set "
110
+ "AGENTIC_API_BASE/AGENTIC_GATEWAY_BASE env vars to a self-"
111
+ "hosted compatible gateway. Don't reach for this tool by "
112
+ "default — use OpenAPI for any REST API instead."
113
+ )
114
+
115
+ @property
116
+ def input_schema(self) -> dict[str, Any]:
117
+ return {
118
+ "type": "object",
119
+ "properties": {
120
+ "identifier": {
121
+ "type": "string",
122
+ "description": "Agentic project identifier, e.g. '@agentic/search'.",
123
+ },
124
+ "tool_name": {
125
+ "type": "string",
126
+ "description": "Name of the specific tool within the project. Use AgenticBrowse to list tools first.",
127
+ },
128
+ "args": {
129
+ "type": "object",
130
+ "description": "Arguments to pass to the tool (JSON object).",
131
+ },
132
+ "timeout_s": {
133
+ "type": "number",
134
+ "description": "Override timeout in seconds (default 30).",
135
+ },
136
+ },
137
+ "required": ["identifier", "tool_name"],
138
+ }
139
+
140
+ async def execute(self, **kwargs: Any) -> ToolResult:
141
+ identifier = (kwargs.get("identifier") or "").strip()
142
+ tool_name = (kwargs.get("tool_name") or "").strip()
143
+ args = kwargs.get("args") or {}
144
+ timeout = float(kwargs.get("timeout_s") or _DEFAULT_TIMEOUT_S)
145
+
146
+ if not identifier or not tool_name:
147
+ return ToolResult(
148
+ tool_name=self.name, status="error",
149
+ error="`identifier` and `tool_name` are both required",
150
+ )
151
+
152
+ # Resolve deployment + validate the tool exists locally before
153
+ # we send a request — cheaper to fail early than to get a 404
154
+ # from the gateway with an opaque message.
155
+ try:
156
+ deployment = await _resolve_deployment(identifier)
157
+ except (httpx.HTTPError, ValueError) as e:
158
+ return ToolResult(
159
+ tool_name=self.name, status="error",
160
+ error=f"resolve failed: {e}",
161
+ )
162
+
163
+ tool_specs = deployment.get("tools") or []
164
+ names = [t.get("name") for t in tool_specs]
165
+ if tool_name not in names:
166
+ return ToolResult(
167
+ tool_name=self.name, status="error",
168
+ error=(f"tool {tool_name!r} not in {identifier!r}; "
169
+ f"available: {names}"),
170
+ )
171
+
172
+ deployment_id = (deployment.get("identifier")
173
+ or deployment.get("deploymentIdentifier")
174
+ or identifier)
175
+ url = f"{_GATEWAY_BASE}/{deployment_id}/{tool_name}"
176
+
177
+ try:
178
+ async with httpx.AsyncClient(timeout=timeout) as client:
179
+ resp = await client.post(
180
+ url, json=args, headers=_auth_headers()
181
+ )
182
+ except httpx.TimeoutException:
183
+ return ToolResult(
184
+ tool_name=self.name, status="error",
185
+ error=f"timed out after {timeout}s calling {tool_name}",
186
+ )
187
+ except httpx.RequestError as e:
188
+ return ToolResult(
189
+ tool_name=self.name, status="error",
190
+ error=f"network error: {e}",
191
+ )
192
+
193
+ if resp.status_code >= 400:
194
+ return ToolResult(
195
+ tool_name=self.name, status="error",
196
+ error=(f"{resp.status_code} from gateway: "
197
+ f"{resp.text[:300]}"),
198
+ )
199
+
200
+ # Try to parse JSON, fall back to text
201
+ ctype = resp.headers.get("content-type", "")
202
+ if "json" in ctype:
203
+ try:
204
+ body = resp.json()
205
+ output = json.dumps(body, indent=2)
206
+ except Exception:
207
+ output = resp.text
208
+ body = None
209
+ else:
210
+ output = resp.text
211
+ body = None
212
+
213
+ return ToolResult(
214
+ tool_name=self.name, status="success",
215
+ output=output[:4000] + (
216
+ f"\n\n... ({len(output) - 4000} more chars truncated)"
217
+ if len(output) > 4000 else ""
218
+ ),
219
+ metadata={
220
+ "identifier": identifier,
221
+ "tool_name": tool_name,
222
+ "deployment_id": deployment_id,
223
+ "response": body,
224
+ },
225
+ )
226
+
227
+
228
+ class AgenticBrowseTool(BaseTool):
229
+ name = "AgenticBrowse"
230
+ description = (
231
+ "[CURRENTLY DORMANT — agentic.so suspended] List tools in an "
232
+ "Agentic project. Calls api.agentic.so/v1/projects/public; "
233
+ "currently returns 'Service Suspended'. Will start working "
234
+ "again if the service returns, or point AGENTIC_API_BASE at "
235
+ "a self-hosted gateway."
236
+ )
237
+
238
+ @property
239
+ def input_schema(self) -> dict[str, Any]:
240
+ return {
241
+ "type": "object",
242
+ "properties": {
243
+ "identifier": {
244
+ "type": "string",
245
+ "description": "Agentic project identifier, e.g. '@agentic/search'.",
246
+ },
247
+ },
248
+ "required": ["identifier"],
249
+ }
250
+
251
+ async def execute(self, **kwargs: Any) -> ToolResult:
252
+ identifier = (kwargs.get("identifier") or "").strip()
253
+ if not identifier:
254
+ return ToolResult(
255
+ tool_name=self.name, status="error",
256
+ error="`identifier` is required",
257
+ )
258
+
259
+ try:
260
+ deployment = await _resolve_deployment(identifier)
261
+ except (httpx.HTTPError, ValueError) as e:
262
+ return ToolResult(
263
+ tool_name=self.name, status="error",
264
+ error=f"resolve failed: {e}",
265
+ )
266
+
267
+ tool_specs = deployment.get("tools") or []
268
+ if not tool_specs:
269
+ return ToolResult(
270
+ tool_name=self.name, status="success",
271
+ output=f"{identifier}: no tools published",
272
+ metadata={"identifier": identifier, "tools": []},
273
+ )
274
+
275
+ lines = [f"{identifier} — {len(tool_specs)} tool(s):"]
276
+ for t in tool_specs:
277
+ n = t.get("name") or "?"
278
+ desc = (t.get("description") or "").strip().splitlines()[0:1]
279
+ desc_line = desc[0] if desc else ""
280
+ lines.append(f" - {n}: {desc_line[:160]}")
281
+ schema = t.get("inputSchema") or {}
282
+ props = (schema.get("properties") or {})
283
+ if props:
284
+ for pn, pdef in list(props.items())[:6]:
285
+ ptype = pdef.get("type", "?")
286
+ pdesc = (pdef.get("description") or "")[:80]
287
+ lines.append(f" {pn} ({ptype}): {pdesc}")
288
+
289
+ return ToolResult(
290
+ tool_name=self.name, status="success",
291
+ output="\n".join(lines),
292
+ metadata={
293
+ "identifier": identifier,
294
+ "tools": tool_specs,
295
+ "deployment": deployment.get("identifier"),
296
+ },
297
+ )
@@ -0,0 +1,191 @@
1
+ """Artifact — create an interactive content pane (HTML / SVG / markdown / code).
2
+
3
+ The LLM uses this when it wants to produce a substantive piece of
4
+ content the user should view as a *thing* rather than as inline chat
5
+ text. Examples: a small standalone web page, a flowchart SVG, a
6
+ multi-paragraph design doc, a self-contained Python module.
7
+
8
+ How it shows up in the UI:
9
+ - The tool saves content to FileStore with the right MIME type
10
+ - Returns a `markdown` field with a link the LLM pastes verbatim
11
+ - Open WebUI / chat UI renders the link inline:
12
+ * HTML / SVG / images → opens in a new tab on click
13
+ * Markdown / text → preview shown by the chat client
14
+ * Code → can be downloaded or viewed raw
15
+
16
+ This is the substrate for "artifact panes." The full Anthropic-style
17
+ side-pane experience requires UI work in the chat client; what we
18
+ ship here is the server side: a clean, addressable URL per artifact
19
+ and a generated markdown link the LLM can echo. The chat client
20
+ decides how to render it.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import os
27
+ import tempfile
28
+ from typing import Any
29
+
30
+ from core.schemas import ToolResult
31
+ from execution.tools.base import BaseTool
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # Map artifact kind → file suffix (drives MIME via mimetypes.guess_type)
37
+ _KIND_SUFFIX: dict[str, str] = {
38
+ "html": ".html",
39
+ "svg": ".svg",
40
+ "markdown": ".md",
41
+ "md": ".md",
42
+ "json": ".json",
43
+ "css": ".css",
44
+ "js": ".js",
45
+ "javascript": ".js",
46
+ "python": ".py",
47
+ "py": ".py",
48
+ "yaml": ".yaml",
49
+ "yml": ".yaml",
50
+ "xml": ".xml",
51
+ "txt": ".txt",
52
+ "text": ".txt",
53
+ }
54
+
55
+ # Kinds the chat UI tends to render inline as a preview vs. open in
56
+ # a new tab. Used to choose the right markdown shape: image-syntax
57
+ # `![alt](url)` for inline-renderables, link-syntax `[alt](url)` for
58
+ # new-tab-on-click ones.
59
+ _INLINE_RENDER = frozenset({"svg"})
60
+
61
+
62
+ def _public_base_url() -> str:
63
+ return os.environ.get("COGNOS_PUBLIC_URL", "http://127.0.0.1:8000").rstrip("/")
64
+
65
+
66
+ class ArtifactTool(BaseTool):
67
+ mutates = True
68
+ name = "Artifact"
69
+ description = (
70
+ "Create a substantive piece of content the user should view "
71
+ "as an artifact (a standalone HTML page, SVG diagram, design "
72
+ "doc, code module, etc.) rather than as inline chat text. "
73
+ "Returns a `markdown` field — paste it VERBATIM into your "
74
+ "reply, the chat UI renders it as a link/preview. "
75
+ "Use this when:\n"
76
+ " - The output is >30 lines AND meaningful as a unit\n"
77
+ " - The user asks to see something visually (SVG, web page)\n"
78
+ " - The output should be saveable / downloadable\n"
79
+ "Don't use it for short answers, short code snippets, or "
80
+ "chat-flow text — those go in your reply directly."
81
+ )
82
+
83
+ def __init__(self, file_store: Any | None = None):
84
+ self._file_store = file_store
85
+
86
+ def set_file_store(self, file_store: Any) -> None:
87
+ self._file_store = file_store
88
+
89
+ @property
90
+ def input_schema(self) -> dict[str, Any]:
91
+ return {
92
+ "type": "object",
93
+ "properties": {
94
+ "kind": {
95
+ "type": "string",
96
+ "enum": list(_KIND_SUFFIX.keys()),
97
+ "description": "Content type — drives the MIME type and how the UI renders it.",
98
+ },
99
+ "content": {
100
+ "type": "string",
101
+ "description": "The artifact body (HTML, SVG, markdown, code, etc.).",
102
+ },
103
+ "title": {
104
+ "type": "string",
105
+ "description": "Short title used as the link label and stored filename.",
106
+ },
107
+ },
108
+ "required": ["kind", "content"],
109
+ }
110
+
111
+ async def execute(self, **kwargs: Any) -> ToolResult:
112
+ kind = (kwargs.get("kind") or "").lower().strip()
113
+ content = kwargs.get("content") or ""
114
+ title = (kwargs.get("title") or "artifact").strip()
115
+
116
+ if kind not in _KIND_SUFFIX:
117
+ return ToolResult(
118
+ tool_name=self.name, status="error",
119
+ error=f"unknown kind {kind!r}. supported: "
120
+ f"{sorted(_KIND_SUFFIX.keys())}",
121
+ )
122
+ if not content.strip():
123
+ return ToolResult(
124
+ tool_name=self.name, status="error",
125
+ error="content is empty",
126
+ )
127
+ if self._file_store is None:
128
+ return ToolResult(
129
+ tool_name=self.name, status="error",
130
+ error="Artifact tool not wired to a FileStore",
131
+ )
132
+
133
+ suffix = _KIND_SUFFIX[kind]
134
+
135
+ # Write content to a tempfile with the right suffix, hand to
136
+ # FileStore (same pattern Draw uses).
137
+ try:
138
+ with tempfile.NamedTemporaryFile(
139
+ "w", suffix=suffix, delete=False
140
+ ) as tmp:
141
+ tmp.write(content)
142
+ tmp_path = tmp.name
143
+ # Friendly stored filename — title + suffix, sanitised
144
+ safe_title = "".join(
145
+ c if c.isalnum() or c in "-_." else "_" for c in title
146
+ )[:60] or "artifact"
147
+ stored_name = f"{safe_title}{suffix}"
148
+ record = self._file_store.upload(tmp_path, filename=stored_name)
149
+ except Exception as e:
150
+ logger.exception("Artifact tool failed")
151
+ return ToolResult(
152
+ tool_name=self.name, status="error",
153
+ error=f"failed to store artifact: {e}",
154
+ )
155
+
156
+ # The /artifact/{id} route is the polished viewer (Phase 1 of
157
+ # COGNOS_UI_ROADMAP.md) — HTML preview / source toggle /
158
+ # download. Use it for the user-facing link. The raw-bytes
159
+ # /files/{id}/content URL stays available for inline-image
160
+ # rendering when the chat UI needs the binary directly.
161
+ viewer_url = f"{_public_base_url()}/artifact/{record.id}"
162
+ raw_url = f"{_public_base_url()}/files/{record.id}/content"
163
+ if kind in _INLINE_RENDER:
164
+ # Inline-renderables (SVG) use the raw URL so the image
165
+ # shows directly in chat. Add a viewer link below for
166
+ # source/download access.
167
+ markdown = f"![{title}]({raw_url})\n\n[📄 Open in viewer]({viewer_url})"
168
+ else:
169
+ markdown = f"[📄 {title}]({viewer_url})"
170
+ url = viewer_url # what the metadata reports as the primary URL
171
+
172
+ return ToolResult(
173
+ tool_name=self.name, status="success",
174
+ output=(
175
+ f"Artifact created.\n"
176
+ f" kind: {kind}\n"
177
+ f" filename: {record.filename}\n"
178
+ f" size: {record.size_bytes} bytes\n"
179
+ f" url: {url}\n\n"
180
+ f"Paste the markdown into your reply VERBATIM:\n"
181
+ f" {markdown}"
182
+ ),
183
+ metadata={
184
+ "file_id": record.id,
185
+ "filename": record.filename,
186
+ "url": url,
187
+ "markdown": markdown,
188
+ "kind": kind,
189
+ "size_bytes": record.size_bytes,
190
+ },
191
+ )
@@ -0,0 +1,137 @@
1
+ """AskUserQuestion — structured disambiguation prompt for the user.
2
+
3
+ The agent uses this when it needs the user to pick between several
4
+ options before continuing. The tool returns a markdown-formatted
5
+ prompt that the LLM is expected to echo verbatim as its turn-end
6
+ response. The user's next chat reply provides the answer; there's
7
+ no real "blocking wait" because Cognos is chat-driven, not REPL.
8
+
9
+ Output shape (what the LLM sees back from the tool):
10
+ ## <question>
11
+
12
+ - **A.** <choice 1>
13
+ - **B.** <choice 2>
14
+ - **C.** <choice 3>
15
+
16
+ *Reply with the letter (or the choice text) to continue.*
17
+
18
+ Why a dedicated tool instead of just typing the question?
19
+ 1. Gives Caudate a clear "I am asking the user" signal in the
20
+ replay record so she can learn the disambiguation pattern.
21
+ 2. Standardises the format so the WebUI could later detect the
22
+ marker and render real radio buttons.
23
+ 3. Keeps the LLM honest: when there are concrete choices, present
24
+ them — don't ramble open-endedly.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import string
30
+ from typing import Any
31
+
32
+ from core.schemas import ToolResult
33
+ from execution.tools.base import BaseTool
34
+
35
+
36
+ # Sentinel marker the WebUI / chat-front-end can detect to render a
37
+ # nicer choice picker once that hook is added. Plain text below it
38
+ # stays human-readable in any UI.
39
+ _MARKER = "<!-- cognos:ask_user_question -->"
40
+
41
+
42
+ class AskUserQuestionTool(BaseTool):
43
+ name = "AskUserQuestion"
44
+ description = (
45
+ "Ask the user a multiple-choice question to disambiguate "
46
+ "before proceeding. Provide a clear `question` and 2-6 "
47
+ "`choices`. The tool returns a markdown prompt the LLM "
48
+ "should surface verbatim as its turn-end response; the "
49
+ "user's next reply (a letter A/B/C... or the choice text) "
50
+ "is the answer. Use this whenever the work depends on a "
51
+ "decision the user can make in one tap — file path, "
52
+ "destination, model variant, scope, etc. Don't use it for "
53
+ "open-ended questions; just ask those directly."
54
+ )
55
+
56
+ @property
57
+ def input_schema(self) -> dict[str, Any]:
58
+ return {
59
+ "type": "object",
60
+ "properties": {
61
+ "question": {
62
+ "type": "string",
63
+ "description": "The question to ask the user. One sentence ideally.",
64
+ },
65
+ "choices": {
66
+ "type": "array",
67
+ "items": {"type": "string"},
68
+ "minItems": 2,
69
+ "maxItems": 6,
70
+ "description": (
71
+ "Between 2 and 6 distinct options. Each is a "
72
+ "single phrase or short sentence."
73
+ ),
74
+ },
75
+ "context": {
76
+ "type": "string",
77
+ "description": (
78
+ "Optional one-line context shown above the "
79
+ "question (e.g. why you're asking)."
80
+ ),
81
+ },
82
+ },
83
+ "required": ["question", "choices"],
84
+ }
85
+
86
+ async def execute(self, **kwargs: Any) -> ToolResult:
87
+ question = (kwargs.get("question") or "").strip()
88
+ choices = kwargs.get("choices") or []
89
+ context = (kwargs.get("context") or "").strip()
90
+
91
+ if not question:
92
+ return ToolResult(
93
+ tool_name=self.name, status="error",
94
+ error="`question` is required",
95
+ )
96
+ if not isinstance(choices, list) or len(choices) < 2:
97
+ return ToolResult(
98
+ tool_name=self.name, status="error",
99
+ error="provide at least 2 choices",
100
+ )
101
+ if len(choices) > 6:
102
+ return ToolResult(
103
+ tool_name=self.name, status="error",
104
+ error="too many choices (max 6)",
105
+ )
106
+
107
+ clean_choices = [str(c).strip() for c in choices if str(c).strip()]
108
+ if len(clean_choices) < 2:
109
+ return ToolResult(
110
+ tool_name=self.name, status="error",
111
+ error="need at least 2 non-empty choices",
112
+ )
113
+
114
+ letters = string.ascii_uppercase[: len(clean_choices)]
115
+ lines: list[str] = [_MARKER, ""]
116
+ if context:
117
+ lines.append(f"_{context}_")
118
+ lines.append("")
119
+ lines.append(f"## {question}")
120
+ lines.append("")
121
+ for letter, choice in zip(letters, clean_choices):
122
+ lines.append(f"- **{letter}.** {choice}")
123
+ lines.append("")
124
+ lines.append(
125
+ "*Reply with the letter (or the choice text) to continue.*"
126
+ )
127
+
128
+ return ToolResult(
129
+ tool_name=self.name, status="success",
130
+ output="\n".join(lines),
131
+ metadata={
132
+ "question": question,
133
+ "choices": clean_choices,
134
+ "letters": list(letters),
135
+ "context": context,
136
+ },
137
+ )