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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- 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
|
+
# `` 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"\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
|
+
)
|